mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 13:06:13 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e506cfbde |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -1000,12 +1000,33 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
|
||||
// Reasoning is one response-wide channel: it stays open alongside text and
|
||||
// refusal output so late reasoning deltas and details join the same block,
|
||||
// and `finishEvents` closes it once with the complete metadata.
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1111,12 +1132,10 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
// Snapshot details at publish time so the emitted event never observes later
|
||||
// mutation of the accumulated `reasoningDetails` array.
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
@@ -1150,7 +1149,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1167,35 +1166,8 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
@@ -1241,18 +1213,7 @@ describe("OpenAI Chat route", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
// Snapshot reasoning-end metadata as each event is published so the
|
||||
// assertion cannot pass through later mutation of a shared array.
|
||||
const publishedEndMetadata: unknown[] = []
|
||||
const response = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.reasoningEnd(event))
|
||||
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
|
||||
}),
|
||||
),
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.map((state) => LLMResponse.complete(state)!),
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -1273,12 +1234,10 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
// Reasoning stays open alongside text and closes once during finalization.
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export function createSessionRequestModel() {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell")
|
||||
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
|
||||
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
|
||||
return [message.metadata.jobID]
|
||||
return []
|
||||
}),
|
||||
)
|
||||
@@ -121,7 +121,6 @@ export function createSessionRequestModel() {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
if (typeof shellID === "string" && completed.has(shellID)) return []
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionInboxInfo,
|
||||
SessionInboxCompaction,
|
||||
ShellInfo,
|
||||
SkillInfo,
|
||||
VcsInfo,
|
||||
@@ -285,11 +284,12 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||
}
|
||||
|
||||
// Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
|
||||
// only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
|
||||
// row the server does not know about yet. Prompts clear on their durable
|
||||
// echo, positive pending read, or rollback; compactions also reconcile the
|
||||
// POST's canonical ID.
|
||||
// Inbox IDs of optimistic prompt admissions still awaiting their durable
|
||||
// echo. This is the one deliberate piece of in-flight bookkeeping in this
|
||||
// layer: it exists so a rejection only rolls back rows the server never
|
||||
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
|
||||
// server does not know about yet. Entries clear on the enqueued echo or on
|
||||
// rollback — not on POST success, which typically precedes the echo.
|
||||
const outbox = new Set<string>()
|
||||
|
||||
// Session IDs of optimistic create admissions still awaiting acknowledgement
|
||||
@@ -303,13 +303,11 @@ export function createData(config: CreateDataInput) {
|
||||
// to exist server-side instead of failing with "not found".
|
||||
const creating = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Per-session send chain: prompts and compactions must be admitted in
|
||||
// submission order. Each waits for the previous POST to settle, so one
|
||||
// failure does not block the next.
|
||||
// Per-session send chain: prompts must be admitted in submission order,
|
||||
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
|
||||
// for the previous prompt's POST (settled, so one failure does not block
|
||||
// the next) before sending its own.
|
||||
const sending = new Map<string, Promise<unknown>>()
|
||||
const messageLoads = new Map<string, Promise<unknown>>()
|
||||
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
|
||||
onCleanup(() => compacting.clear())
|
||||
|
||||
// Register `promise` under `key` until it settles. A later registration
|
||||
// replaces an earlier one; settlement only clears its own entry.
|
||||
@@ -321,24 +319,9 @@ export function createData(config: CreateDataInput) {
|
||||
void promise.then(settle, settle)
|
||||
}
|
||||
|
||||
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
|
||||
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
|
||||
const created = creating.get(sessionID)
|
||||
const previous = sending.get(sessionID)
|
||||
const request = Promise.resolve()
|
||||
.then(() => Promise.all([gate, created, previous]))
|
||||
.then(send)
|
||||
track(
|
||||
sending,
|
||||
sessionID,
|
||||
request.catch(() => undefined),
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// handler and by optimistic prompt admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
function admitLocal(item: SessionInboxInfo) {
|
||||
@@ -351,7 +334,6 @@ export function createData(config: CreateDataInput) {
|
||||
item.sessionID,
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
@@ -686,7 +668,6 @@ export function createData(config: CreateDataInput) {
|
||||
draft.push(existing)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivery.changed":
|
||||
@@ -694,7 +675,6 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.inbox.cancelled": {
|
||||
retractLocal(event.data.sessionID, event.data.inboxID)
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.enqueued": {
|
||||
@@ -705,12 +685,6 @@ export function createData(config: CreateDataInput) {
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
})
|
||||
if (event.data.item.type === "compaction") {
|
||||
const active = compacting.get(event.data.sessionID)
|
||||
active?.observed.add(event.data.inboxID)
|
||||
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
|
||||
removePending(event.data.sessionID, active.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
@@ -1009,7 +983,6 @@ export function createData(config: CreateDataInput) {
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
@@ -1107,7 +1080,6 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
message.append(draft, index, failed)
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "permission.asked":
|
||||
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
|
||||
@@ -1294,17 +1266,12 @@ export function createData(config: CreateDataInput) {
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await api().session.inbox.list({ sessionID })
|
||||
// A positive read acknowledges admission even when its SSE echo is delayed.
|
||||
pending.forEach((item) => outbox.delete(item.id))
|
||||
// Compactions also coalesce by Session, not just by the proposed ID.
|
||||
if (pending.some((item) => item.type === "compaction"))
|
||||
store.session.pending[sessionID]
|
||||
?.filter((item) => item.type === "compaction")
|
||||
.forEach((item) => outbox.delete(item.id))
|
||||
// Keep optimistic rows still awaiting their echo: this fetch may
|
||||
// have raced ahead of an in-flight admission the server does not
|
||||
// know about yet.
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter(
|
||||
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
|
||||
)
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
@@ -1378,56 +1345,13 @@ export function createData(config: CreateDataInput) {
|
||||
if (fresh) track(creating, id, request)
|
||||
return { id, request }
|
||||
},
|
||||
compact(input: { sessionID: string; model?: ModelRef }) {
|
||||
const active = compacting.get(input.sessionID)
|
||||
if (active) return active.request
|
||||
// A known pending control ID may be consumed while setup waits. Propose
|
||||
// a fresh ID and let the server coalesce, without duplicating its row.
|
||||
const id = SessionMessage.ID.create()
|
||||
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
// Compaction admission can coalesce onto a different ID. Retire the
|
||||
// speculative row on an echo, and remember consumed IDs until the POST
|
||||
// settles so its older response cannot resurrect a queued row.
|
||||
const observed = new Set<string>()
|
||||
const request = sendAdmission(input.sessionID, async () => {
|
||||
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
|
||||
return api().session.compact({ sessionID: input.sessionID, id })
|
||||
})
|
||||
.then((item) => {
|
||||
batch(() => {
|
||||
outbox.delete(id)
|
||||
if (item.id !== id) removePending(input.sessionID, id)
|
||||
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id)) admitLocal(item)
|
||||
})
|
||||
return item
|
||||
})
|
||||
.catch((error) => {
|
||||
if (outbox.delete(id)) removePending(input.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (compacting.get(input.sessionID)?.request === request) compacting.delete(input.sessionID)
|
||||
})
|
||||
compacting.set(input.sessionID, { id, observed, request })
|
||||
return request
|
||||
},
|
||||
// Optimistic prompt admission: render the prompt immediately under a
|
||||
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
||||
// upsert that same ID with the server's payload. Server admission is
|
||||
// idempotent per ID, so retrying with the identical payload cannot
|
||||
// double-admit.
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
|
||||
const { gate, prepare, ...request } = input
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
|
||||
const { gate, ...request } = input
|
||||
const id = request.id ?? SessionMessage.ID.create()
|
||||
// A retry may reuse an ID that is already rendered — and possibly
|
||||
// already durable. Admit optimistically only for new IDs so a failed
|
||||
@@ -1453,15 +1377,25 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
})
|
||||
}
|
||||
return sendAdmission(
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
// The POST additionally waits for the caller's gate, for any
|
||||
// in-flight optimistic create of this session, and for the previous
|
||||
// prompt's POST: the row renders now, the send happens once the
|
||||
// session exists server-side and earlier prompts are admitted.
|
||||
const previous = sending.get(request.sessionID)
|
||||
const send = Promise.resolve()
|
||||
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
|
||||
.then(() => api().session.prompt({ ...request, id }))
|
||||
track(
|
||||
sending,
|
||||
request.sessionID,
|
||||
async () => {
|
||||
await prepare?.()
|
||||
return api().session.prompt({ ...request, id })
|
||||
},
|
||||
gate,
|
||||
).catch((error) => {
|
||||
// Roll back only rows this call admitted and the server has not
|
||||
send.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return send.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
|
||||
throw error
|
||||
@@ -1530,70 +1464,20 @@ export function createData(config: CreateDataInput) {
|
||||
loading(sessionID: string) {
|
||||
return store.session.messageLoading[sessionID] ?? false
|
||||
},
|
||||
async loadMore(
|
||||
sessionID: string,
|
||||
options?: {
|
||||
all?: boolean
|
||||
signal?: AbortSignal
|
||||
/** Runs synchronously inside the store-publication batch. */
|
||||
beforePublish?: () => void
|
||||
},
|
||||
) {
|
||||
const signal = options?.signal
|
||||
if (signal?.aborted) return
|
||||
while (messageLoads.has(sessionID)) {
|
||||
const published = await (() => {
|
||||
const pending = messageLoads.get(sessionID)
|
||||
if (!signal) return pending
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
const cancel = () => aborted.resolve()
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
return Promise.race([pending, aborted.promise])
|
||||
.catch((error) => {
|
||||
if (!signal.aborted) throw error
|
||||
})
|
||||
.finally(() => signal.removeEventListener("abort", cancel))
|
||||
})()
|
||||
if ((!options?.all && published) || signal?.aborted) return
|
||||
}
|
||||
async loadMore(sessionID: string) {
|
||||
const cursor = store.session.messageCursor[sessionID]
|
||||
if (!cursor || signal?.aborted) return
|
||||
if (!cursor || store.session.messageLoading[sessionID]) return
|
||||
setStore("session", "messageLoading", sessionID, true)
|
||||
const request = (async () => {
|
||||
const fetched: SessionMessageInfo[] = []
|
||||
let next: string | undefined = cursor
|
||||
do {
|
||||
const response = await api().message.list(
|
||||
{
|
||||
sessionID,
|
||||
limit: options?.all ? 200 : messagePageLimit,
|
||||
cursor: next,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
if (signal?.aborted) return
|
||||
fetched.push(...response.data)
|
||||
next = response.cursor.next ?? undefined
|
||||
if (!options?.all) break
|
||||
} while (next)
|
||||
// A jump through history publishes once, not once per page of offscreen messages.
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
|
||||
batch(() => {
|
||||
options?.beforePublish?.()
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, next)
|
||||
})
|
||||
return true
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (!signal?.aborted) throw error
|
||||
})
|
||||
const response = await api()
|
||||
.message.list({ sessionID, limit: messagePageLimit, cursor })
|
||||
.finally(() => setStore("session", "messageLoading", sessionID, false))
|
||||
track(messageLoads, sessionID, request)
|
||||
await request
|
||||
const older = response.data.toReversed()
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
|
||||
|
||||
test("admits compaction before model setup and serializes the following prompt", async () => {
|
||||
using fixture = setup()
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0]
|
||||
expect(proposed).toMatchObject({ type: "compaction", sessionID })
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(fixture.data.session.message.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.status(sessionID)).toBe("idle")
|
||||
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
expect(fixture.calls).toEqual(["model"])
|
||||
fixture.model.resolve()
|
||||
await wait(() => fixture.calls.length === 2)
|
||||
expect(fixture.calls).toEqual(["model", "compact"])
|
||||
fixture.response.resolve(Response.json({ data: item(proposed.id) }))
|
||||
await Promise.all([compact, prompt])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "prompt"])
|
||||
expect(fixture.proposals).toEqual([proposed.id])
|
||||
})
|
||||
|
||||
test("coalesces duplicate gestures until the admission request settles", async () => {
|
||||
using fixture = setup()
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.data.session.compact({ sessionID })).toBe(first)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await first
|
||||
expect(fixture.calls).toEqual(["compact"])
|
||||
const next = fixture.data.session.compact({ sessionID })
|
||||
expect(next).not.toBe(first)
|
||||
await next
|
||||
expect(fixture.calls).toEqual(["compact", "compact"])
|
||||
expect(new Set(fixture.proposals).size).toBe(2)
|
||||
expect(fixture.proposals).not.toContain("msg_canonical")
|
||||
})
|
||||
|
||||
test("substitutes the canonical response ID and reconciles its later echo", async () => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0].id
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.id)).toEqual([proposed])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical")])
|
||||
fixture.enqueue("msg_canonical", 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical", 20)])
|
||||
expect(fixture.data.session.input.list(sessionID)).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical"])("adopts the %s echo before the response without duplicating it", async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
fixture.enqueue(id, 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
fixture.response.resolve(Response.json({ data: item(id) }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
})
|
||||
|
||||
test.each(["started", "cancelled", "failed"])(
|
||||
"does not resurrect a canonical item already %s before the response",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
fixture.enqueue("msg_canonical")
|
||||
if (kind === "started")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_canonical", reason: "manual" },
|
||||
})
|
||||
if (kind === "cancelled")
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: "msg_canonical" } })
|
||||
if (kind === "failed")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.failed",
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "msg_canonical",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancelled" },
|
||||
},
|
||||
})
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["model", "compact"])("rolls back a rejected %s RPC and releases the following prompt", async (rpc) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
if (rpc === "model") fixture.model.reject(new Error("Model setup failed"))
|
||||
if (rpc === "compact") {
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(new Response("Admission failed", { status: 500 }))
|
||||
}
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
await prompt
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.type)).toEqual(["user"])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical", "existing"])(
|
||||
"preserves acknowledged %s compaction after an HTTP error",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
if (kind === "existing") fixture.enqueue("msg_canonical")
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
if (kind !== "existing") fixture.enqueue(id)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
},
|
||||
)
|
||||
|
||||
test("uses a fresh control ID when the known pending compaction starts during model setup", async () => {
|
||||
const proposed = Promise.withResolvers<string>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
const body = await request.json()
|
||||
proposed.resolve(body.id)
|
||||
if (body.id === "msg_existing") return Response.json({ message: "Control ID already consumed" }, { status: 409 })
|
||||
return Response.json({ data: item(body.id) })
|
||||
})
|
||||
fixture.enqueue("msg_existing")
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_existing")])
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_existing", reason: "manual" },
|
||||
})
|
||||
fixture.model.resolve()
|
||||
expect(await proposed.promise).not.toBe("msg_existing")
|
||||
expect(await result).toEqual(item(await proposed.promise))
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(await proposed.promise)])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ id: "msg_existing", type: "compaction", status: "running" },
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["compaction", "canonical compaction", "user"])(
|
||||
"preserves a fetched durable %s when SSE is delayed and HTTP fails",
|
||||
async (type) => {
|
||||
using fixture = setup(async (request) => {
|
||||
if (request.url.endsWith("/prompt")) return fixture.response.promise
|
||||
return undefined
|
||||
})
|
||||
const request =
|
||||
type === "user"
|
||||
? fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
: fixture.data.session.compact({ sessionID })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
const id = type === "canonical compaction" ? "msg_canonical" : fixture.data.session.pending.list(sessionID)[0].id
|
||||
const durable: SessionInboxInfo =
|
||||
type === "user" ? { ...item(id, 20), type: "user", payload: { text: "Follow up" } } : item(id, 20)
|
||||
fixture.pending.push(durable)
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await result).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
if (type === "user")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ id, type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps one event listener and removes it when the data owner is disposed during a gate", async () => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.dispose()
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
gate.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([first, compact])
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("routes concurrent compaction observations by session through one listener", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>()
|
||||
const secondResponse = Promise.withResolvers<Response>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
return request.url.includes(`/session/${sessionID}/`) ? firstResponse.promise : secondResponse.promise
|
||||
})
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
const second = fixture.data.session.compact({ sessionID: "ses_other" })
|
||||
const firstID = fixture.data.session.pending.list(sessionID)[0].id
|
||||
const secondID = fixture.data.session.pending.list("ses_other")[0].id
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: firstID } })
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other").map((row) => row.id)).toEqual([secondID])
|
||||
|
||||
firstResponse.resolve(Response.json({ data: item(firstID) }))
|
||||
secondResponse.resolve(Response.json({ data: { ...item(secondID), sessionID: "ses_other" } }))
|
||||
await Promise.all([first, second])
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other")).toEqual([{ ...item(secondID), sessionID: "ses_other" }])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
})
|
||||
|
||||
test.each(["gate", "prepare"])(
|
||||
"a preceding prompt's failed %s does not block compaction or following model preparation",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const first = fixture.data.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: "msg_first",
|
||||
text: "First",
|
||||
gate: kind === "gate" ? gate.promise : undefined,
|
||||
prepare: () => {
|
||||
prepared.push("first")
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "first" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: () => {
|
||||
prepared.push("following")
|
||||
return fixture.api.session.switchModel({ sessionID, model: { providerID: "demo", id: "second" } })
|
||||
},
|
||||
})
|
||||
if (kind === "prepare") await wait(() => prepared.includes("first"))
|
||||
gate.reject(new Error("Preparation failed"))
|
||||
expect(await first).toBeInstanceOf(Error)
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
expect(prepared).toEqual(kind === "prepare" ? ["first"] : [])
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([compact, following])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "model", "prompt"])
|
||||
expect(prepared.at(-1)).toBe("following")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("creation failure rejects gated prompt, compaction, and following preparation without sending their RPCs", async () => {
|
||||
const creation = Promise.withResolvers<Response>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/api/session")) return undefined
|
||||
requested.resolve()
|
||||
return creation.promise
|
||||
})
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const created = fixture.data.session.create({ id: sessionID })
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: async () => {
|
||||
prepared.push("following")
|
||||
},
|
||||
})
|
||||
const results = Promise.allSettled([created.request, first, compact, following])
|
||||
await requested.promise
|
||||
creation.resolve(new Response("Creation failed", { status: 500 }))
|
||||
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"])
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(prepared).toEqual([])
|
||||
expect(fixture.data.session.get(sessionID)).toBeUndefined()
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
gate.resolve()
|
||||
})
|
||||
|
||||
const sessionID = "ses_compact"
|
||||
const event = { id: "evt_compact", created: 10, durable: { aggregateID: sessionID, seq: 1, version: 1 } }
|
||||
const item = (id: string, timeCreated = 10): SessionInboxCompaction => ({
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated,
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
|
||||
function setup(override?: (request: Request) => Promise<Response | undefined>) {
|
||||
const model = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls: string[] = []
|
||||
const proposals: string[] = []
|
||||
const pending: SessionInboxInfo[] = []
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const overridden = await override?.(request)
|
||||
if (overridden) return overridden
|
||||
const rpc = new URL(request.url).pathname.split("/").at(-1)
|
||||
if (rpc === "inbox") return Response.json({ data: pending })
|
||||
if (rpc === "model") {
|
||||
calls.push(rpc)
|
||||
await model.promise
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (rpc === "compact") {
|
||||
calls.push(rpc)
|
||||
proposals.push((await request.json()).id)
|
||||
return (await response.promise).clone()
|
||||
}
|
||||
if (rpc === "prompt") {
|
||||
calls.push(rpc)
|
||||
return Response.json({
|
||||
data: { ...item((await request.json()).id), type: "user", payload: { text: "Follow up" } },
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${request.url}`)
|
||||
},
|
||||
})
|
||||
const root = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
return {
|
||||
data: root.data,
|
||||
api,
|
||||
dispose: root.dispose,
|
||||
[Symbol.dispose]: root.dispose,
|
||||
model,
|
||||
response,
|
||||
calls,
|
||||
proposals,
|
||||
pending,
|
||||
listeners,
|
||||
emit,
|
||||
enqueue(id: string, created = 10) {
|
||||
emit({
|
||||
...event,
|
||||
created,
|
||||
type: "session.inbox.enqueued",
|
||||
data: { sessionID, inboxID: id, item: { type: "compaction", delivery: "steer", payload: {} } },
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error("Timed out waiting for request")
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { getEventListeners } from "node:events"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
|
||||
@@ -415,120 +414,6 @@ test("loads bounded message pages", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
|
||||
"bulk history (%s)",
|
||||
async (mode) => {
|
||||
const messages = [1, 2, 3].map((index) => ({
|
||||
id: `msg_${index}`,
|
||||
type: "user",
|
||||
text: `Message ${index}`,
|
||||
time: { created: index },
|
||||
}))
|
||||
const release = Promise.withResolvers<void>()
|
||||
const controller = new AbortController()
|
||||
const requests: URL[] = []
|
||||
const publications: string[][] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
requests.push(url)
|
||||
const cursor = url.searchParams.get("cursor")
|
||||
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
|
||||
if (cursor === "recent") {
|
||||
if (mode.startsWith("join")) await release.promise
|
||||
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
|
||||
}
|
||||
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
|
||||
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
|
||||
await release.promise
|
||||
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
return { data, dispose }
|
||||
})
|
||||
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
|
||||
const load = setup.data.session.message.loadMore(
|
||||
"ses_refresh",
|
||||
mode.startsWith("join")
|
||||
? undefined
|
||||
: {
|
||||
all: true,
|
||||
signal: controller.signal,
|
||||
beforePublish: () => {
|
||||
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
},
|
||||
},
|
||||
)
|
||||
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
|
||||
const settled = Promise.allSettled([load, joined])
|
||||
if (mode.startsWith("join")) {
|
||||
await wait(() => requests.length === 2)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
|
||||
controller.abort()
|
||||
let cancelled = false
|
||||
void joined.then(() => {
|
||||
cancelled = true
|
||||
})
|
||||
await wait(() => cancelled)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
|
||||
)
|
||||
return
|
||||
}
|
||||
await wait(() => requests.length === 4)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
|
||||
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
|
||||
if (mode.startsWith("cancel")) controller.abort()
|
||||
const retry =
|
||||
mode === "cancel-retry" || mode === "cancel-page"
|
||||
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
|
||||
: undefined
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
await retry
|
||||
const success = mode === "success" || mode === "cancel-retry"
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
|
||||
)
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
|
||||
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("preserves assistant content replacement events across an active message read", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
@@ -84,7 +84,10 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(input.settings),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -293,7 +296,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -308,6 +311,7 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
...extra,
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
@@ -341,21 +345,28 @@ function mapOpenRouter(
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return mapProviderOptions(settings, [
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
])
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
|
||||
@@ -482,7 +482,8 @@ function toolMessage(input: LLMRequest["messages"][number]) {
|
||||
const value = part.result.value.filter((item) => {
|
||||
if (item.type !== "file") return true
|
||||
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
|
||||
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
@@ -506,7 +507,7 @@ function text(part: ContentPart) {
|
||||
function userPart(part: ContentPart): UserContent {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "media")
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -515,7 +516,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
@@ -534,15 +535,6 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
}
|
||||
}
|
||||
|
||||
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
|
||||
if (typeof data !== "string") return data
|
||||
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
|
||||
if (base64 !== undefined) return base64
|
||||
if (!URL.canParse(data)) return data
|
||||
const url = new URL(data)
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : data
|
||||
}
|
||||
|
||||
function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
if (part.type !== "tool-result") return []
|
||||
return [
|
||||
|
||||
+154
-148
@@ -294,156 +294,162 @@ export function configured(options?: Options) {
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = definition.durable
|
||||
if (!durable) return yield* Effect.void
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string")
|
||||
return yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export * as CommandInvocation from "./invocation.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
|
||||
// Invocation for configured template commands; source loading and registration stay with the caller.
|
||||
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
@@ -0,0 +1,150 @@
|
||||
export * as ConfigFile from "./file.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parseTree,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser"
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
const isJson = Schema.is(Schema.MutableJson)
|
||||
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
/**
|
||||
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
|
||||
* The synchronous callback mutates a source clone; its return value is ignored.
|
||||
* Validates JSON only; normalization and substitution remain the reader's job.
|
||||
* Does not discover files, start watchers, or refresh Config state.
|
||||
* Read-modify-write calls are serialized within this process.
|
||||
*/
|
||||
export const update = Effect.fn("ConfigFile.update")(
|
||||
function* (
|
||||
filepath: string,
|
||||
mutate: (draft: Schema.MutableJsonObject) => void,
|
||||
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
|
||||
const fs = yield* FSUtil.Service
|
||||
const text = yield* fs
|
||||
.readFileString(filepath)
|
||||
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
|
||||
const errors: ParseError[] = []
|
||||
const current = parseSource(text, errors)
|
||||
if (errors.length || !isDocument(current))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
|
||||
|
||||
const next = yield* Effect.try({
|
||||
try: () => {
|
||||
const draft = structuredClone(current)
|
||||
mutate(draft)
|
||||
return draft
|
||||
},
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
if (!isDocument(next))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
|
||||
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return next
|
||||
const updated = yield* Effect.try({
|
||||
try: () => edits.reduce(patch, text),
|
||||
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
|
||||
})
|
||||
// Duplicate keys can make parse choose the last value while modify edits the first.
|
||||
const written = parseSource(updated, errors)
|
||||
if (errors.length || !isDeepStrictEqual(written, next))
|
||||
return yield* Effect.fail(
|
||||
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
|
||||
)
|
||||
const temporary = filepath + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, filepath)),
|
||||
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
|
||||
)
|
||||
return next
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function parseSource(text: string, errors: ParseError[]) {
|
||||
const root = parseTree(text, errors, { allowTrailingComma: true })
|
||||
if (!root || errors.length) return undefined
|
||||
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
|
||||
// an own JSON key. Construct object entries from the AST without those setters.
|
||||
const value = (node: Node): unknown => {
|
||||
if (node.type === "array") return (node.children ?? []).map(value)
|
||||
if (node.type === "object")
|
||||
return Object.fromEntries(
|
||||
(node.children ?? []).map((property) => {
|
||||
const child = property.children?.[1]
|
||||
return [property.children?.[0]?.value, child && value(child)]
|
||||
}),
|
||||
)
|
||||
return node.value
|
||||
}
|
||||
return value(root)
|
||||
}
|
||||
|
||||
function patch(text: string, edit: Edit) {
|
||||
if (edit.value !== undefined)
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
|
||||
const tree = parseTree(text)
|
||||
const node = tree && findNodeAtLocation(tree, edit.path)
|
||||
if (!node) return text
|
||||
// jsonc-parser removes adjacent comments along with the separator. Remove only
|
||||
// the property/element itself and one comma, leaving surrounding comments intact.
|
||||
const target = node.parent?.type === "property" ? node.parent : node
|
||||
const siblings = target.parent?.children ?? []
|
||||
const previous = siblings[siblings.indexOf(target) - 1]
|
||||
const scanner = createScanner(text, true)
|
||||
scanner.setPosition(target.offset + target.length)
|
||||
scanner.scan()
|
||||
const following = text[scanner.getTokenOffset()] === ","
|
||||
if (!following && previous) {
|
||||
scanner.setPosition(previous.offset + previous.length)
|
||||
scanner.scan()
|
||||
}
|
||||
return applyEdits(text, [
|
||||
{ offset: target.offset, length: target.length, content: "" },
|
||||
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (isDeepStrictEqual(before, after)) return []
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
return [
|
||||
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
|
||||
// Remove from the end so earlier deletions cannot shift later paths.
|
||||
...before
|
||||
.slice(after.length)
|
||||
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
|
||||
.toReversed(),
|
||||
]
|
||||
}
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -1,18 +1,12 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { CommandInvocation } from "../../command/invocation.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -29,9 +23,7 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const invoke = yield* CommandInvocation.make(ctx)
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -63,37 +55,7 @@ export const Plugin = define({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
execute: (input) => invoke(command, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -146,66 +108,3 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
@@ -20,13 +20,14 @@ export const Plugin = define({
|
||||
const global = yield* Global.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
draft.add(
|
||||
entries.set(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
@@ -47,6 +48,7 @@ export const Plugin = define({
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Effect Drizzle SQLite Adapter
|
||||
|
||||
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
|
||||
builders over Effect's generic `SqlClient`. It is maintained source, not
|
||||
generated output.
|
||||
|
||||
## Provenance
|
||||
|
||||
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
|
||||
SQLite Effect query builders, and shared query-builder utilities. The
|
||||
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
|
||||
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
|
||||
|
||||
The exact upstream revision originally copied into this repository is unknown.
|
||||
The currently pinned `drizzle-orm` version is a compatibility dependency, not
|
||||
copy provenance.
|
||||
|
||||
## Local Boundary
|
||||
|
||||
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
|
||||
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
|
||||
facade from `database/database.ts`.
|
||||
|
||||
Material local adaptations include:
|
||||
|
||||
- a runtime-independent driver over Effect's generic `SqlClient`
|
||||
- local cache, mapping, and runtime-inspection helpers
|
||||
- suppressed statement tracing beneath the database operation boundary
|
||||
- explicit SQLite transactions and savepoints
|
||||
- native transaction delegation for Durable Object SQLite
|
||||
- deliberate query-builder variance annotations
|
||||
|
||||
Preserve these adaptations when comparing or synchronizing upstream code.
|
||||
Focused regression coverage is in `test/database-drizzle.test.ts` and
|
||||
`test/sqlite-workerd.test.ts`.
|
||||
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { SqliteClient } from "@effect/sql-sqlite-node"
|
||||
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
* import { Effect } from "effect"
|
||||
* import { SqliteClient } from '@effect/sql-sqlite-node';
|
||||
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
|
||||
* import * as Effect from 'effect/Effect';
|
||||
*
|
||||
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
|
||||
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
|
||||
* )
|
||||
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
|
||||
* Effect.provide(SQLiteDrizzle.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
|
||||
|
||||
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
if (paired.has(item.row.id)) return []
|
||||
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
|
||||
if (item.value.role === "user") {
|
||||
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
|
||||
if (compaction) {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
|
||||
(candidate) =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary === true,
|
||||
candidate.value.summary,
|
||||
)
|
||||
if (!pairedSummary) return []
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
|
||||
paired.add(pairedSummary.row.id)
|
||||
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
|
||||
const summary = pairedSummary
|
||||
const summaryText = (byMessage.get(summary.row.id) ?? [])
|
||||
.map((part) => part.value)
|
||||
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => part.text)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
@@ -313,14 +313,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
|
||||
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
|
||||
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part) => part.type === "file")
|
||||
const agents = owned.filter((part) => part.type === "agent")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
const ordinary = visible.filter((part) => !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.synthetic)
|
||||
const attachments = files.flatMap((part) => migrateFile(part))
|
||||
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
|
||||
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
|
||||
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
|
||||
const unavailable = files.flatMap((part) =>
|
||||
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
|
||||
)
|
||||
const text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
@@ -328,12 +330,16 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) => ({
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}))
|
||||
const agentAttachments = agents.map((part) =>
|
||||
part.type === "agent"
|
||||
? {
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}
|
||||
: { name: "" },
|
||||
)
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
@@ -345,7 +351,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -363,7 +369,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -437,6 +443,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
})
|
||||
.map((item, seq) => ({ ...item, seq }))
|
||||
const assistants = messages
|
||||
.filter((item) => item.value.role === "assistant")
|
||||
.map((item) => item.value)
|
||||
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
|
||||
const latestUser = messages.findLast((item) => {
|
||||
@@ -481,7 +488,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
|
||||
if (runtimeState.status === "error") return runtimeState
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
return { status: "required" as const }
|
||||
})
|
||||
}).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
@@ -521,75 +528,76 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
const migrate = Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
DELETE FROM event
|
||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
||||
`)
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
@@ -604,79 +612,81 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -705,7 +715,7 @@ function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
)
|
||||
).pipe(Effect.orElseSucceed(() => 0))
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
export * as StateMachine from "./state-machine.js"
|
||||
|
||||
import { Cause, Effect, Exit, Fiber, Queue, type Scope } from "effect"
|
||||
|
||||
export type Command<Operation> =
|
||||
| {
|
||||
readonly _tag: "Invoke"
|
||||
readonly id: string
|
||||
readonly operation: Operation
|
||||
}
|
||||
| {
|
||||
readonly _tag: "Stop"
|
||||
readonly id: string
|
||||
}
|
||||
| {
|
||||
readonly _tag: "StopAndJoin"
|
||||
readonly id: string
|
||||
readonly ids: ReadonlyArray<string>
|
||||
readonly waitFor: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type InvocationExited<Event, Operation, Error> = {
|
||||
readonly _tag: "InvocationExited"
|
||||
readonly id: string
|
||||
readonly generation: number
|
||||
readonly operation: Operation
|
||||
readonly exit: Exit.Exit<Event, Error>
|
||||
}
|
||||
|
||||
export type RuntimeEvent<Event, Operation, Error> =
|
||||
| {
|
||||
readonly _tag: "Input"
|
||||
readonly input: Event
|
||||
readonly cause?: Cause.Cause<never>
|
||||
}
|
||||
| InvocationExited<Event, Operation, Error>
|
||||
| {
|
||||
readonly _tag: "InvocationsStopped"
|
||||
readonly id: string
|
||||
readonly exits: ReadonlyArray<InvocationExited<Event, Operation, Error>>
|
||||
}
|
||||
|
||||
export type Continue<State, Operation> = {
|
||||
readonly _tag: "Continue"
|
||||
readonly state: State
|
||||
readonly commands: ReadonlyArray<Command<Operation>>
|
||||
}
|
||||
|
||||
export type Decision<State, Operation, Output> =
|
||||
| Continue<State, Operation>
|
||||
| {
|
||||
readonly _tag: "Done"
|
||||
readonly output: Output
|
||||
}
|
||||
|
||||
export type Definition<State, Event, Operation, Error, Output> = {
|
||||
readonly initial: Continue<State, Operation>
|
||||
readonly transition: (
|
||||
state: State,
|
||||
event: RuntimeEvent<Event, Operation, Error>,
|
||||
) => Decision<State, Operation, Output>
|
||||
readonly interruption?: Event
|
||||
}
|
||||
|
||||
export type Executor<Event, Operation, Error, Requirements> = (
|
||||
operation: Operation,
|
||||
) => Effect.Effect<Event, Error, Requirements>
|
||||
|
||||
export function define<State, Event, Operation, Error, Output>(
|
||||
definition: Definition<State, Event, Operation, Error, Output>,
|
||||
) {
|
||||
return definition
|
||||
}
|
||||
|
||||
export function next<State, Operation = never>(state: State, ...commands: ReadonlyArray<Command<Operation>>) {
|
||||
return { _tag: "Continue", state, commands } as const
|
||||
}
|
||||
|
||||
export function done<Output>(output: Output) {
|
||||
return { _tag: "Done", output } as const
|
||||
}
|
||||
|
||||
export function invoke<Operation>(id: string, operation: Operation): Command<Operation> {
|
||||
return { _tag: "Invoke", id, operation }
|
||||
}
|
||||
|
||||
export function stop(id: string): Command<never> {
|
||||
return { _tag: "Stop", id }
|
||||
}
|
||||
|
||||
/** Stops `ids`, awaits `waitFor` without interruption, and delivers their exits as one batch. */
|
||||
export function stopAndJoin(
|
||||
id: string,
|
||||
ids: ReadonlyArray<string>,
|
||||
waitFor: ReadonlyArray<string> = [],
|
||||
): Command<never> {
|
||||
return { _tag: "StopAndJoin", id, ids, waitFor }
|
||||
}
|
||||
|
||||
export const run = Effect.fn("StateMachine.run")(function* <State, Event, Operation, Error, Output, Requirements>(
|
||||
definition: Definition<State, Event, Operation, Error, Output>,
|
||||
execute: Executor<Event, Operation, Error, Requirements>,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const queue = yield* Queue.unbounded<RuntimeEvent<Event, Operation, Error>>()
|
||||
const invocations = new Map<
|
||||
string,
|
||||
{
|
||||
readonly generation: number
|
||||
readonly operation: Operation
|
||||
readonly fiber: Fiber.Fiber<Event, Error>
|
||||
}
|
||||
>()
|
||||
let generation = 0
|
||||
|
||||
const executeCommands = Effect.fnUntraced(function* (
|
||||
commands: ReadonlyArray<Command<Operation>>,
|
||||
interruptibleExecution: boolean,
|
||||
) {
|
||||
yield* Effect.forEach(
|
||||
commands,
|
||||
(command) =>
|
||||
Effect.gen(function* () {
|
||||
if (command._tag === "Stop") {
|
||||
const invocation = invocations.get(command.id)
|
||||
yield* invocation
|
||||
? Fiber.interrupt(invocation.fiber)
|
||||
: Effect.die(new Error(`Unknown state machine invocation: ${command.id}`))
|
||||
return
|
||||
}
|
||||
|
||||
if (command._tag === "StopAndJoin") {
|
||||
const captured = [...command.ids, ...command.waitFor].flatMap((id) => {
|
||||
const invocation = invocations.get(id)
|
||||
return invocation ? [{ id, ...invocation }] : []
|
||||
})
|
||||
if (captured.length !== command.ids.length + command.waitFor.length)
|
||||
yield* Effect.die(new Error("Unknown state machine invocation in StopAndJoin"))
|
||||
|
||||
// Invalidate individual exits, including ones already queued, before interrupting.
|
||||
captured.forEach((invocation) => invocations.delete(invocation.id))
|
||||
yield* Fiber.interruptAll(captured.slice(0, command.ids.length).map((invocation) => invocation.fiber))
|
||||
const exits = yield* Effect.forEach(captured, (invocation) =>
|
||||
Fiber.await(invocation.fiber).pipe(
|
||||
Effect.map((exit) => ({
|
||||
_tag: "InvocationExited" as const,
|
||||
id: invocation.id,
|
||||
generation: invocation.generation,
|
||||
operation: invocation.operation,
|
||||
exit,
|
||||
})),
|
||||
),
|
||||
)
|
||||
yield* Queue.offer(queue, { _tag: "InvocationsStopped", id: command.id, exits })
|
||||
return
|
||||
}
|
||||
|
||||
const previous = invocations.get(command.id)
|
||||
if (previous) yield* Fiber.interrupt(previous.fiber)
|
||||
|
||||
generation += 1
|
||||
const current = generation
|
||||
const execution = interruptibleExecution
|
||||
? restore(execute(command.operation))
|
||||
: execute(command.operation)
|
||||
const fiber = yield* execution.pipe(Effect.forkScoped({ startImmediately: false }))
|
||||
invocations.set(command.id, { generation: current, operation: command.operation, fiber })
|
||||
// A deferred child may be interrupted before an Effect.onExit observer starts.
|
||||
fiber.addObserver((exit) => {
|
||||
Queue.offerUnsafe(queue, {
|
||||
_tag: "InvocationExited",
|
||||
id: command.id,
|
||||
generation: current,
|
||||
operation: command.operation,
|
||||
exit,
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const handleInterruption = (
|
||||
state: State,
|
||||
cause: Cause.Cause<never>,
|
||||
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
if (!Cause.hasInterruptsOnly(cause) || definition.interruption === undefined)
|
||||
return yield* Effect.failCause(cause)
|
||||
return yield* dispatch(
|
||||
definition.transition(state, {
|
||||
_tag: "Input",
|
||||
input: definition.interruption,
|
||||
cause,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
const dispatch = (
|
||||
decision: Decision<State, Operation, Output>,
|
||||
interrupted: boolean,
|
||||
): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
if (decision._tag === "Done") return decision.output
|
||||
yield* executeCommands(decision.commands, !interrupted)
|
||||
if (interrupted) return yield* Effect.suspend(() => loop(decision.state, true))
|
||||
|
||||
const boundary = yield* restore(Effect.void).pipe(Effect.exit)
|
||||
if (Exit.isFailure(boundary)) return yield* handleInterruption(decision.state, boundary.cause)
|
||||
return yield* Effect.suspend(() => loop(decision.state, false))
|
||||
})
|
||||
|
||||
const loop = (state: State, interrupted: boolean): Effect.Effect<Output, never, Requirements | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const received = yield* (interrupted ? Queue.take(queue) : restore(Queue.take(queue))).pipe(Effect.exit)
|
||||
if (Exit.isFailure(received)) return yield* handleInterruption(state, received.cause)
|
||||
|
||||
if (received.value._tag === "InvocationExited") {
|
||||
const invocation = invocations.get(received.value.id)
|
||||
if (!invocation || invocation.generation !== received.value.generation) {
|
||||
return yield* Effect.suspend(() => loop(state, interrupted))
|
||||
}
|
||||
invocations.delete(received.value.id)
|
||||
}
|
||||
|
||||
return yield* dispatch(definition.transition(state, received.value), interrupted)
|
||||
})
|
||||
|
||||
return yield* dispatch(definition.initial, false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -61,23 +61,21 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) =>
|
||||
Effect.suspend(() => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
}),
|
||||
read: (value, range) =>
|
||||
Effect.gen(function* () {
|
||||
const original = lookup(value)
|
||||
if (!original) return yield* new NotFound({ path: value })
|
||||
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return { info: info(node), bytes: bytes.slice() }
|
||||
}),
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
@@ -91,17 +89,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) =>
|
||||
Effect.gen(function* () {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
return [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
@@ -109,33 +107,32 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) =>
|
||||
Effect.gen(function* () {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return yield* new NotFound({ path: from })
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -62,8 +62,9 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Mutation locking is process-local and serializes cooperating OpenCode
|
||||
* changes; external writes can still race.
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* not overwrite changes made from the same stale content.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -128,6 +129,7 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
|
||||
/**
|
||||
* Deferred until the corresponding integrations exist.
|
||||
*/
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
||||
|
||||
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
|
||||
find: () => Effect.succeed([]),
|
||||
})
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
# GitHub Copilot AI SDK Adapters
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
|
||||
This directory contains upstream-derived AI SDK implementations adapted for
|
||||
GitHub Copilot. It is not a generic OpenAI-compatible provider.
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
|
||||
## Provenance
|
||||
|
||||
- `chat/` is derived from the Vercel AI SDK
|
||||
`@ai-sdk/openai-compatible` chat implementation.
|
||||
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
|
||||
implementation.
|
||||
- The exact upstream revisions originally copied into this repository are
|
||||
unknown. Current dependency versions and the `VERSION` constant in
|
||||
`copilot-provider.ts` are not copy provenance.
|
||||
|
||||
## Ownership
|
||||
|
||||
Keep `chat/` and `responses/` structurally close to their upstream modules, but
|
||||
preserve the intentional Copilot adaptations: the `copilot` options and metadata
|
||||
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
|
||||
Responses requests with encrypted reasoning, rotating response item IDs, and
|
||||
explicit function-tool strictness taking precedence over the global fallback.
|
||||
|
||||
`copilot-provider.ts` is the local adapter assembly entrypoint used by
|
||||
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
|
||||
reconciliation, not vendored SDK code. Authentication, request headers, model
|
||||
routing, and integration lifecycle are also owned by the provider plugin.
|
||||
|
||||
When updating the upstream-shaped modules, compare against both source packages
|
||||
and reapply the documented Copilot adaptations. Focused regression coverage is
|
||||
in:
|
||||
|
||||
- `test/github-copilot/copilot-chat-model.test.ts`
|
||||
- `test/github-copilot/convert-to-copilot-messages.test.ts`
|
||||
- `test/github-copilot/openai-responses-language-model.test.ts`
|
||||
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
|
||||
- `test/github-copilot/models.test.ts`
|
||||
- `test/plugin/provider-github-copilot.test.ts`
|
||||
Avoid making edits to these files
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
@@ -33,7 +33,12 @@ export const make = Effect.gen(function* () {
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
limits: Readonly<Limits>,
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
|
||||
@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
}
|
||||
@@ -346,7 +346,8 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -395,7 +396,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
CallToolResultSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type Implementation,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
ListPromptsResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
PromptSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
@@ -26,7 +29,6 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
@@ -39,6 +41,10 @@ const toError = (error: unknown) => (error instanceof Error ? error : new Error(
|
||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
|
||||
prompts: PromptSchema.array(),
|
||||
})
|
||||
|
||||
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||
server: Schema.String,
|
||||
}) {
|
||||
@@ -157,7 +163,6 @@ export interface Connection {
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
@@ -296,8 +301,12 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const prompts = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
(cursor) =>
|
||||
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
|
||||
timeout: catalogTimeout,
|
||||
})
|
||||
},
|
||||
(result) => result.prompts,
|
||||
),
|
||||
catch: toError,
|
||||
@@ -387,7 +396,11 @@ export const connect = Effect.fnUntraced(function* (
|
||||
prompt: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
|
||||
client.request(
|
||||
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
|
||||
GetPromptResultSchema,
|
||||
{ signal, timeout: executionTimeout },
|
||||
),
|
||||
catch: toError,
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
@@ -398,11 +411,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{
|
||||
name: input.name,
|
||||
arguments: input.args ?? {},
|
||||
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
|
||||
},
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
CallToolResultSchema,
|
||||
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
|
||||
{ signal, timeout: executionTimeout, onprogress: () => {} },
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as Mcp from "./index.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
@@ -154,7 +153,6 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
@@ -475,11 +473,11 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}),
|
||||
),
|
||||
)
|
||||
connection.onLog((message) => fork(serverLog(name, message)))
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onToolsChanged(() =>
|
||||
live(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
@@ -514,7 +512,7 @@ export const layer = (options?: Options) =>
|
||||
// Announce the handshake so connect() and credential reconnects don't show a stale
|
||||
// disabled/failed status for the duration of the connection attempt.
|
||||
entry.status = { status: "pending" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
@@ -545,9 +543,9 @@ export const layer = (options?: Options) =>
|
||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||
return
|
||||
}
|
||||
@@ -559,7 +557,7 @@ export const layer = (options?: Options) =>
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -570,9 +568,9 @@ export const layer = (options?: Options) =>
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(PromptsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -594,7 +592,7 @@ export const layer = (options?: Options) =>
|
||||
yield* register(name, entry)
|
||||
if (serverConfig.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
return
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
@@ -610,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 })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
@@ -633,7 +631,7 @@ export const layer = (options?: Options) =>
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
entry.startup.openUnsafe()
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
@@ -675,6 +673,7 @@ 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>({
|
||||
@@ -739,7 +738,7 @@ export const layer = (options?: Options) =>
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
target.entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||
@@ -764,7 +763,7 @@ export const layer = (options?: Options) =>
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
|
||||
@@ -213,11 +213,20 @@ export const authorize = (input: {
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
yield* Effect.tryPromise({
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return {
|
||||
url: input.config.url,
|
||||
instructions: `Connected to ${input.name}.`,
|
||||
mode: "auto" as const,
|
||||
callback: finalize,
|
||||
}
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -26,9 +26,19 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Info = PersistentPty.Info
|
||||
export type Info = Pty.Info & {
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
|
||||
export type Snapshot = PersistentPty.Snapshot
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
@@ -151,7 +161,15 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: Parameters<Interface["create"]>[1],
|
||||
input: {
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
@@ -320,7 +338,14 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: Parameters<Interface["attach"]>[1],
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
|
||||
@@ -111,7 +111,7 @@ const layer = Layer.effect(
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
@@ -142,7 +142,7 @@ const layer = Layer.effect(
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
|
||||
@@ -98,7 +98,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
||||
return response(agents.list())
|
||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
|
||||
@@ -4,6 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
languageModel: (modelID: string) => LanguageModelV3
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
|
||||
@@ -58,8 +58,10 @@ export const CloudflareAIGatewayPlugin = define({
|
||||
const config = gatewayConfig(evt.options)
|
||||
if (!config) return
|
||||
const metadata = gatewayMetadata(evt.options)
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const gateway = createAiGateway({
|
||||
accountId: config.accountId,
|
||||
gateway: config.gatewayId,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "opencode.provider.dynamic",
|
||||
@@ -12,7 +13,18 @@ export const DynamicProviderPlugin = define({
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(evt.options)
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const GitLabPlugin = define({
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
if (id.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const workflowRef =
|
||||
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
|
||||
const workflowDefinition =
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
@@ -17,7 +18,17 @@ export const SapAICorePlugin = define({
|
||||
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
|
||||
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
|
||||
|
||||
const factory = yield* loadSDKFactory(npm, evt.package)
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
|
||||
const factory = mod[match]
|
||||
if (typeof factory !== "function")
|
||||
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
|
||||
const installedPath = packageName.startsWith("file://")
|
||||
? packageName
|
||||
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
|
||||
return mod[match]
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export const Plugin = define({
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
|
||||
app: Context["app"],
|
||||
) {
|
||||
const plugins = yield* configuredPlugins()
|
||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||
return [
|
||||
ReportContent,
|
||||
"",
|
||||
|
||||
@@ -76,13 +76,11 @@ to every project for that user. Project configuration can live in any directory
|
||||
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
||||
in a monorepo.
|
||||
|
||||
During ordinary project discovery, OpenCode searches the current Location
|
||||
directory and every ancestor through the filesystem root, including directories
|
||||
above the detected project or repository root. It merges direct
|
||||
`opencode.json(c)` files from the farthest ancestor to the current directory,
|
||||
When OpenCode starts, it searches from the current directory up to the project
|
||||
root. It merges direct `opencode.json(c)` files from root to current directory,
|
||||
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||
discovered `.opencode` config overrides every discovered direct config. Global
|
||||
filesystem configuration has lower precedence than these discovered documents.
|
||||
`.opencode` config overrides every direct config. Global configuration has the
|
||||
lowest precedence.
|
||||
|
||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
|
||||
@@ -78,9 +78,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
|
||||
...post.filter((plugin) => enabled.has(plugin.id)),
|
||||
],
|
||||
failures: [...failures.values()],
|
||||
refreshes: [...packages.entries()].flatMap(([target, plugin]) =>
|
||||
!path.isAbsolute(target) && enabled.has(plugin.id) ? [target] : [],
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -92,7 +89,6 @@ export const layer = Layer.effect(
|
||||
const instance = yield* InstancePlugins.Service
|
||||
const sources = yield* ConfigPluginSource.Service
|
||||
const bus = yield* Bus.Service
|
||||
const npm = yield* Npm.Service
|
||||
const ready = yield* Latch.make()
|
||||
let observed = 0
|
||||
|
||||
@@ -117,18 +113,6 @@ export const layer = Layer.effect(
|
||||
const resolved = yield* resolve(pre, post, operations)
|
||||
// Replace the active generation in one scoped, batched activation.
|
||||
yield* registry.activate(resolved.plugins, resolved.failures)
|
||||
if (resolved.refreshes.length) {
|
||||
yield* Effect.forEach(
|
||||
resolved.refreshes,
|
||||
(target) =>
|
||||
npm
|
||||
.add(target, { subpaths: ["server", ""], refresh: true })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("failed to refresh package plugin", { target, cause })),
|
||||
),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.forkDetach)
|
||||
}
|
||||
})
|
||||
const updates = Stream.merge(sources.changes(), bus.subscribe([Event.Updated, SdkPlugins.Updated])).pipe(
|
||||
// Make accepted work visible to flush before coalescing the burst.
|
||||
|
||||
@@ -137,10 +137,11 @@ const layer = Layer.effect(
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact. The row insert commits atomically with the event, so a crash between
|
||||
// checks retries on the next resolve instead of stranding the announcement.
|
||||
// The in-flight set keeps concurrent resolves from publishing the same fact
|
||||
// twice.
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
// sessions and never announce). The row insert commits atomically with the
|
||||
// event, so a crash between checks retries on the next resolve instead of
|
||||
// stranding the announcement. The in-flight set keeps concurrent resolves
|
||||
// from publishing the same fact twice.
|
||||
for (const item of directories) {
|
||||
const key = item.projectID + "\u0000" + item.directory
|
||||
if (announcing.has(key)) continue
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "eff
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Workspace } from "./workspace.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
|
||||
@@ -393,27 +393,26 @@ export const layer = Layer.effect(
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
inputID: input.inputID,
|
||||
})
|
||||
return yield* input.resolveModel(input.session).pipe(
|
||||
Effect.matchEffect({
|
||||
onFailure: (cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
onSuccess: (resolved) =>
|
||||
execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
}),
|
||||
}),
|
||||
const resolved = yield* input.resolveModel(input.session).pipe(
|
||||
Effect.catch((cause) =>
|
||||
failed({
|
||||
sessionID: input.session.id,
|
||||
reason: "manual",
|
||||
error: toSessionError(cause),
|
||||
inputID: input.inputID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
if ("status" in resolved) return resolved
|
||||
return yield* execute({
|
||||
session: input.session,
|
||||
resolved,
|
||||
prepare: input.prepare,
|
||||
reason: "manual",
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
...content,
|
||||
})
|
||||
})
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
|
||||
@@ -204,6 +204,7 @@ export const layer = (options?: Options) =>
|
||||
yield* jobs.background(background.id)
|
||||
yield* jobs.wait({ id: background.id }).pipe(
|
||||
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Project } from "../project.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import type { SessionTable } from "./sql.js"
|
||||
import { SessionTable } from "./sql.js"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
|
||||
@@ -131,7 +131,11 @@ const layer = Layer.effect(
|
||||
return (yield* rows(sessionID, false)).map((row) => ({ key: row.key, value: row.value }))
|
||||
})
|
||||
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: Parameters<Interface["put"]>[0]) {
|
||||
const put = Effect.fn("InstructionEntry.put")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
readonly value: Schema.Json
|
||||
}) {
|
||||
const actualBytes = Buffer.byteLength(JSON.stringify(input.value), "utf8")
|
||||
if (actualBytes > MaxValueBytes)
|
||||
yield* new ValueTooLargeError({
|
||||
@@ -155,7 +159,10 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: Parameters<Interface["remove"]>[0]) {
|
||||
const remove = Effect.fn("InstructionEntry.remove")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly key: Key
|
||||
}) {
|
||||
yield* db
|
||||
.update(InstructionEntryTable)
|
||||
.set({ value: null, removed: true, time_updated: Date.now() })
|
||||
|
||||
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||
observation: Observation,
|
||||
) {
|
||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||
// The rendered text is frozen into the durable event because re-rendering it
|
||||
// later would require the original Location-scoped instruction sources.
|
||||
// The rendered text is frozen into the durable event: replaying it later would
|
||||
// require the Location-scoped registry that produced it.
|
||||
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||
yield* bus.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
|
||||
@@ -43,7 +43,10 @@ const layer = Layer.effect(
|
||||
// are re-discovered and re-injected instead of staying silently lost.
|
||||
const inFlight = yield* Ref.make<Map<SessionSchema.ID, Set<string>>>(new Map())
|
||||
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: Parameters<Interface["load"]>[0]) {
|
||||
const load = Effect.fn("SessionInstructions.load")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly paths: ReadonlyArray<string>
|
||||
}) {
|
||||
const claimed = yield* Ref.modify(inFlight, (map) => {
|
||||
const existing = map.get(input.sessionID) ?? new Set<string>()
|
||||
const newlyClaimed = input.paths.filter((path) => !existing.has(path))
|
||||
|
||||
@@ -4,17 +4,21 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
}
|
||||
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
@@ -34,14 +38,16 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
const created = DateTime.makeUnsafe(event.created)
|
||||
|
||||
const latestTool = (assistant: DraftAssistant, id: string) =>
|
||||
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
|
||||
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
|
||||
const latestText = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftText => item.type === "text")
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -6,13 +6,13 @@ import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Model } from "../model.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
@@ -26,10 +26,8 @@ import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
@@ -55,16 +53,16 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: sql`${SessionTable.cost} + ${value.cost}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
|
||||
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
@@ -75,7 +73,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
|
||||
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
sessionID: SessionSchema.ID,
|
||||
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({
|
||||
|
||||
@@ -15,14 +15,13 @@ import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { DrainResult, Service, type Continuation } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../../effect/app-node-platform.js"
|
||||
import { StepFailedError } from "../error.js"
|
||||
import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionStep } from "./step.js"
|
||||
import { SessionStepMachine } from "./step-machine.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { PluginSupervisor } from "../../plugin/supervisor.js"
|
||||
import { MAX_STEPS_PROMPT } from "./max-steps.js"
|
||||
@@ -45,7 +44,12 @@ const layer = Layer.effect(
|
||||
// Title generation starts once input is visible and must not delay model execution.
|
||||
const titles = yield* FiberMap.make<SessionSchema.ID, void, never>()
|
||||
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: Parameters<Interface["drain"]>[0]) {
|
||||
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly force: boolean
|
||||
readonly continuation?: Continuation
|
||||
readonly promotable?: SessionInbox.Promotable
|
||||
}) {
|
||||
const sessionID = input.sessionID
|
||||
let force = input.force
|
||||
let continuing = input.continuation !== undefined
|
||||
@@ -139,7 +143,7 @@ const layer = Layer.effect(
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) step = 1
|
||||
@@ -168,83 +172,91 @@ const layer = Layer.effect(
|
||||
return selected
|
||||
})
|
||||
|
||||
/** Owns logical Step policy; each attempt owns provider observation, tools, and durable settlement. */
|
||||
/** Owns logical Step policy; each attempt owns its streaming, tools, and durable settlement. */
|
||||
const runStep = Effect.fn("SessionRunner.runStep")(function* (first: SessionContext.Loaded, step: number) {
|
||||
const sessionID = first.session.id
|
||||
let assistantMessageID = SessionMessage.ID.create()
|
||||
const retry = yield* Schedule.toStepWithSleep(SessionRunnerRetry.schedule(bus, sessionID))
|
||||
let initial: SessionContext.Loaded | undefined = first
|
||||
return yield* SessionStepMachine.run(SessionMessage.ID.create(), {
|
||||
prepare: Effect.fnUntraced(function* (state) {
|
||||
// Reuse boundary preparation once; retries refresh context without delivering more input.
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
session: loaded.session,
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
prepare: context.prepare,
|
||||
}
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
return SessionStepMachine.Preparation.Rebuilt()
|
||||
}
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
return SessionStepMachine.Preparation.Ready({
|
||||
attempt: yield* steps.open({
|
||||
sessionID,
|
||||
assistantMessageID: state.assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
recoverContinuation: state.recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
compaction.enabled()
|
||||
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
retry: (state, outcome) =>
|
||||
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID: state.assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
outcome._tag === "Retry"
|
||||
? bus
|
||||
.publish(SessionEvent.Step.Failed, {
|
||||
sessionID,
|
||||
assistantMessageID: state.assistantMessageID,
|
||||
error: outcome.error,
|
||||
})
|
||||
.pipe(Effect.andThen(outcome.cause))
|
||||
: outcome.cause,
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
publishSynthetic: bus.publish(SessionEvent.Synthetic, {
|
||||
let recoverOverflow = true
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
// Reuse boundary preparation once; retries refresh context without delivering more input.
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
session: loaded.session,
|
||||
messages: loaded.messages,
|
||||
resolved: loaded.model,
|
||||
prepare: context.prepare,
|
||||
}
|
||||
if (compaction.required(compactionInput)) {
|
||||
const compacted = yield* compaction.compact(compactionInput)
|
||||
if (compacted.status !== "completed") return yield* new StepFailedError({ error: compacted.error })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
const stepLimitReached = loaded.agent.info.steps !== undefined && step >= loaded.agent.info.steps
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: loaded.agent.info,
|
||||
model: loaded.model,
|
||||
tools: loaded.tools,
|
||||
initial: loaded.initial,
|
||||
messages: loaded.messages,
|
||||
})
|
||||
const prepared = yield* context.prepare({
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
},
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
})
|
||||
const outcome = yield* steps.attempt({
|
||||
sessionID,
|
||||
text: CONTINUE_AFTER_INCOMPLETE_STREAM,
|
||||
}),
|
||||
})
|
||||
assistantMessageID,
|
||||
agent: loaded.agent.id,
|
||||
model: loaded.model,
|
||||
prepared,
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
? compaction.compact(compactionInput).pipe(Effect.map((result) => result.status === "completed"))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
})
|
||||
const completed = yield* SessionStep.Outcome.$match(outcome, {
|
||||
Completed: (outcome) => Effect.succeed(outcome.needsContinuation),
|
||||
Retry: (outcome) =>
|
||||
retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() =>
|
||||
bus
|
||||
.publish(SessionEvent.Step.Failed, { sessionID, assistantMessageID, error: outcome.error })
|
||||
.pipe(Effect.andThen(outcome.cause)),
|
||||
),
|
||||
Effect.asVoid,
|
||||
),
|
||||
Continue: Effect.fnUntraced(function* (outcome) {
|
||||
yield* retry({ cause: outcome.cause, error: outcome.error, assistantMessageID }).pipe(
|
||||
Pull.catchDone(() => outcome.cause),
|
||||
)
|
||||
yield* bus.publish(SessionEvent.Synthetic, { sessionID, text: CONTINUE_AFTER_INCOMPLETE_STREAM })
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}),
|
||||
Compacted: Effect.fnUntraced(function* () {
|
||||
recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}),
|
||||
RecoverFull: Effect.fnUntraced(function* () {
|
||||
recoverContinuation = false
|
||||
}),
|
||||
})
|
||||
if (completed !== undefined) return completed
|
||||
}
|
||||
})
|
||||
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
export * as SessionStepMachine from "./step-machine.js"
|
||||
|
||||
import { AIError, type ToolCall } from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit } from "effect"
|
||||
import { StateMachine } from "../../effect/state-machine.js"
|
||||
import { StepFailedError } from "../error.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionStep } from "./step.js"
|
||||
|
||||
const PREPARATION = "preparation"
|
||||
const PROVIDER = "provider"
|
||||
const COMPACTION = "compaction"
|
||||
const SETTLEMENT = "settlement"
|
||||
const RETRY = "retry"
|
||||
|
||||
export type Context = {
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly recoverOverflow: boolean
|
||||
readonly recoverContinuation: boolean
|
||||
}
|
||||
|
||||
export type Preparation = Data.TaggedEnum<{
|
||||
Rebuilt: {}
|
||||
Ready: { readonly attempt: SessionStep.Attempt }
|
||||
}>
|
||||
export const Preparation = Data.taggedEnum<Preparation>()
|
||||
|
||||
type AttemptFailure = AIError | StepFailedError
|
||||
type BackoffOutcome = Data.TaggedEnum.Value<SessionStep.Outcome, "Retry" | "Continue">
|
||||
|
||||
type ToolRun = {
|
||||
readonly call: ToolCall
|
||||
readonly exit?: SessionStep.ToolExit
|
||||
}
|
||||
|
||||
type ActiveAttempt = {
|
||||
readonly context: Context
|
||||
readonly attempt: SessionStep.Attempt
|
||||
readonly tools: ReadonlyMap<string, ToolRun>
|
||||
}
|
||||
|
||||
type AttemptState = Data.TaggedEnum<{
|
||||
ObservingProvider: { readonly active: ActiveAttempt }
|
||||
FinalizingProvider: {
|
||||
readonly active: ActiveAttempt
|
||||
readonly stream: Exit.Exit<void, AIError>
|
||||
readonly stopping?: Cause.Cause<never>
|
||||
}
|
||||
AwaitingTools: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
|
||||
RecoveringOverflow: { readonly active: ActiveAttempt; readonly stream: Exit.Exit<void, AIError> }
|
||||
}>
|
||||
|
||||
export type State =
|
||||
| AttemptState
|
||||
| Data.TaggedEnum<{
|
||||
PreparingAttempt: { readonly context: Context }
|
||||
SettlingAttempt: { readonly active: ActiveAttempt; readonly stopping?: Cause.Cause<never> }
|
||||
BackingOff: {
|
||||
readonly context: Context
|
||||
readonly outcome: BackoffOutcome
|
||||
}
|
||||
Stopping: { readonly from?: AttemptState; readonly cause: Cause.Cause<never> }
|
||||
}>
|
||||
export const State = Data.taggedEnum<State>()
|
||||
|
||||
export type Event<Failure> = Data.TaggedEnum<{
|
||||
Prepared: { readonly exit: Exit.Exit<{ readonly context: Context; readonly preparation: Preparation }, Failure> }
|
||||
ProviderObserved: { readonly exit: Exit.Exit<SessionStep.ProviderObservation, AIError> }
|
||||
ToolFinished: { readonly call: ToolCall; readonly exit: SessionStep.ToolExit }
|
||||
ProviderFinished: { readonly exit: Exit.Exit<void> }
|
||||
OverflowRecovered: { readonly exit: Exit.Exit<boolean> }
|
||||
AttemptSettled: { readonly exit: Exit.Exit<SessionStep.Outcome, AttemptFailure> }
|
||||
RetryFinished: { readonly exit: Exit.Exit<void, Failure> }
|
||||
CancelRequested: {}
|
||||
}>
|
||||
interface EventDefinition extends Data.TaggedEnum.WithGenerics<1> {
|
||||
readonly taggedEnum: Event<this["A"]>
|
||||
}
|
||||
export const Event = Data.taggedEnum<EventDefinition>()
|
||||
|
||||
export type Operation = Data.TaggedEnum<{
|
||||
PrepareAttempt: { readonly context: Context; readonly freshAssistant: boolean }
|
||||
ObserveProvider: { readonly attempt: SessionStep.Attempt }
|
||||
RunTool: { readonly attempt: SessionStep.Attempt; readonly call: ToolCall }
|
||||
FinishProvider: { readonly attempt: SessionStep.Attempt; readonly stream: Exit.Exit<void, AIError> }
|
||||
RecoverOverflow: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
|
||||
SettleAttempt: { readonly attempt: SessionStep.Attempt; readonly settlement: SessionStep.Settlement }
|
||||
Retry: {
|
||||
readonly context: Context
|
||||
readonly outcome: BackoffOutcome
|
||||
}
|
||||
}>
|
||||
export const Operation = Data.taggedEnum<Operation>()
|
||||
|
||||
export type Capabilities<Failure, RetryFailure, Requirements> = {
|
||||
readonly prepare: (context: Context) => Effect.Effect<Preparation, Failure, Requirements>
|
||||
readonly retry: (context: Context, outcome: BackoffOutcome) => Effect.Effect<void, RetryFailure, Requirements>
|
||||
readonly publishSynthetic: Effect.Effect<void, Failure, Requirements>
|
||||
}
|
||||
|
||||
export const run = Effect.fn("SessionStepMachine.run")(function* <Failure, RetryFailure, Requirements>(
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
capabilities: Capabilities<Failure, RetryFailure, Requirements>,
|
||||
) {
|
||||
const execute = Operation.$match({
|
||||
PrepareAttempt: (operation) =>
|
||||
Effect.suspend(() => {
|
||||
const context = operation.freshAssistant
|
||||
? { ...operation.context, assistantMessageID: SessionMessage.ID.create() }
|
||||
: operation.context
|
||||
return capabilities.prepare(context).pipe(Effect.map((preparation) => ({ context, preparation })))
|
||||
}).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.Prepared({ exit })),
|
||||
),
|
||||
ObserveProvider: (operation) =>
|
||||
operation.attempt.observeUntilBoundary().pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.ProviderObserved({ exit })),
|
||||
),
|
||||
RunTool: (operation) =>
|
||||
operation.attempt.runTool(operation.call).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.ToolFinished({ call: operation.call, exit })),
|
||||
),
|
||||
FinishProvider: (operation) =>
|
||||
operation.attempt.finishProvider(operation.stream).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.ProviderFinished({ exit })),
|
||||
),
|
||||
RecoverOverflow: (operation) =>
|
||||
operation.attempt.recoverOverflow(operation.settlement).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.OverflowRecovered({ exit })),
|
||||
),
|
||||
SettleAttempt: (operation) =>
|
||||
operation.attempt.settle(operation.settlement).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.AttemptSettled({ exit })),
|
||||
),
|
||||
Retry: (operation) =>
|
||||
capabilities.retry(operation.context, operation.outcome).pipe(
|
||||
Effect.andThen(operation.outcome._tag === "Continue" ? capabilities.publishSynthetic : Effect.void),
|
||||
Effect.exit,
|
||||
Effect.map((exit) => Event.RetryFinished({ exit })),
|
||||
),
|
||||
})
|
||||
const result = yield* StateMachine.run(definition<Failure, RetryFailure>(assistantMessageID), execute)
|
||||
return yield* result
|
||||
})
|
||||
|
||||
export const definition = <Failure, RetryFailure>(assistantMessageID: SessionMessage.ID) => {
|
||||
const context = {
|
||||
assistantMessageID,
|
||||
recoverOverflow: true,
|
||||
recoverContinuation: true,
|
||||
}
|
||||
type MachineFailure = Failure | RetryFailure | AttemptFailure
|
||||
type Decision = StateMachine.Decision<State, Operation, Exit.Exit<boolean, MachineFailure>>
|
||||
|
||||
const prepare = (context: Context, freshAssistant = false): StateMachine.Continue<State, Operation> =>
|
||||
StateMachine.next(
|
||||
State.PreparingAttempt({ context }),
|
||||
StateMachine.invoke(PREPARATION, Operation.PrepareAttempt({ context, freshAssistant })),
|
||||
)
|
||||
|
||||
const pull = (active: ActiveAttempt): Decision =>
|
||||
StateMachine.next(
|
||||
State.ObservingProvider({ active }),
|
||||
StateMachine.invoke(PROVIDER, Operation.ObserveProvider({ attempt: active.attempt })),
|
||||
)
|
||||
|
||||
const settlement = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): SessionStep.Settlement => ({
|
||||
stream,
|
||||
tools: Array.from(active.tools.values()).flatMap((tool) =>
|
||||
tool.exit ? [{ call: tool.call, exit: tool.exit }] : [],
|
||||
),
|
||||
})
|
||||
|
||||
const settle = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>, stopping?: Cause.Cause<never>): Decision =>
|
||||
StateMachine.next(
|
||||
State.SettlingAttempt({ active, stopping }),
|
||||
StateMachine.invoke(
|
||||
SETTLEMENT,
|
||||
Operation.SettleAttempt({
|
||||
attempt: active.attempt,
|
||||
settlement: settlement(active, stream),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const afterProvider = (active: ActiveAttempt, stream: Exit.Exit<void, AIError>): Decision => {
|
||||
if (Array.from(active.tools.values()).some((tool) => tool.exit === undefined))
|
||||
return StateMachine.next(State.AwaitingTools({ active, stream }))
|
||||
if (!active.context.recoverOverflow) return settle(active, stream)
|
||||
return StateMachine.next(
|
||||
State.RecoveringOverflow({ active, stream }),
|
||||
StateMachine.invoke(
|
||||
COMPACTION,
|
||||
Operation.RecoverOverflow({
|
||||
attempt: active.attempt,
|
||||
settlement: settlement(active, stream),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const finishProvider = (
|
||||
active: ActiveAttempt,
|
||||
stream: Exit.Exit<void, AIError>,
|
||||
stopping?: Cause.Cause<never>,
|
||||
): Decision =>
|
||||
StateMachine.next(
|
||||
State.FinalizingProvider({ active, stream, stopping }),
|
||||
StateMachine.invoke(
|
||||
PROVIDER,
|
||||
Operation.FinishProvider({
|
||||
attempt: active.attempt,
|
||||
stream,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const stop = (cause: Cause.Cause<never>, ids: ReadonlyArray<string>, from?: AttemptState): Decision => {
|
||||
return StateMachine.next(
|
||||
State.Stopping({ cause, from }),
|
||||
StateMachine.stopAndJoin("step", ids, from?._tag === "FinalizingProvider" ? [PROVIDER] : []),
|
||||
)
|
||||
}
|
||||
|
||||
const interrupt = (state: State, cause: Cause.Cause<never>): Decision => {
|
||||
const stopAttempt = (state: Exclude<AttemptState, { readonly _tag: "RecoveringOverflow" }>) =>
|
||||
stop(
|
||||
cause,
|
||||
[
|
||||
...(state._tag === "ObservingProvider" ? [PROVIDER] : []),
|
||||
...Array.from(state.active.tools.values()).flatMap((tool) =>
|
||||
tool.exit === undefined ? [toolID(tool.call)] : [],
|
||||
),
|
||||
],
|
||||
state,
|
||||
)
|
||||
return State.$match(state, {
|
||||
PreparingAttempt: () => stop(cause, [PREPARATION]),
|
||||
ObservingProvider: stopAttempt,
|
||||
FinalizingProvider: stopAttempt,
|
||||
AwaitingTools: stopAttempt,
|
||||
SettlingAttempt: (state) => StateMachine.next(State.SettlingAttempt({ active: state.active, stopping: cause })),
|
||||
RecoveringOverflow: (state) => stop(cause, [COMPACTION], state),
|
||||
BackingOff: () => stop(cause, [RETRY]),
|
||||
Stopping: (state) => StateMachine.next(state),
|
||||
})
|
||||
}
|
||||
|
||||
return StateMachine.define<
|
||||
State,
|
||||
Event<Failure | RetryFailure>,
|
||||
Operation,
|
||||
never,
|
||||
Exit.Exit<boolean, MachineFailure>
|
||||
>({
|
||||
initial: prepare(context),
|
||||
interruption: Event.CancelRequested(),
|
||||
transition: (state, runtimeEvent): Decision => {
|
||||
if (runtimeEvent._tag === "Input") return interrupt(state, runtimeEvent.cause ?? Cause.interrupt(undefined))
|
||||
if (runtimeEvent._tag === "InvocationsStopped") {
|
||||
if (state._tag !== "Stopping") return unexpected(state, runtimeEvent)
|
||||
if (!state.from) return StateMachine.done(Exit.failCause(state.cause))
|
||||
const finished = runtimeEvent.exits.map(completed)
|
||||
if (state.from._tag === "RecoveringOverflow") {
|
||||
const recovered = finished.some(
|
||||
(event) => event._tag === "OverflowRecovered" && Exit.isSuccess(event.exit) && event.exit.value,
|
||||
)
|
||||
return recovered
|
||||
? StateMachine.done(Exit.failCause(state.cause))
|
||||
: settle(state.from.active, Exit.failCause(state.cause), state.cause)
|
||||
}
|
||||
const tools = new Map(state.from.active.tools)
|
||||
finished.forEach((event) => {
|
||||
if (event._tag === "ToolFinished") tools.set(event.call.id, { call: event.call, exit: event.exit })
|
||||
})
|
||||
const active = { ...state.from.active, tools }
|
||||
if (state.from._tag === "ObservingProvider")
|
||||
return finishProvider(active, Exit.failCause(state.cause), state.cause)
|
||||
const provider = finished.find((event) => event._tag === "ProviderFinished")
|
||||
const stream =
|
||||
provider && Exit.isFailure(provider.exit) ? Exit.failCause(provider.exit.cause) : state.from.stream
|
||||
return settle(active, stream, state.cause)
|
||||
}
|
||||
|
||||
const event = completed(runtimeEvent)
|
||||
if (event._tag === "ToolFinished") {
|
||||
if (
|
||||
state._tag === "ObservingProvider" ||
|
||||
state._tag === "FinalizingProvider" ||
|
||||
state._tag === "AwaitingTools"
|
||||
) {
|
||||
const tools = new Map(state.active.tools)
|
||||
tools.set(event.call.id, { call: event.call, exit: event.exit })
|
||||
const active = { ...state.active, tools }
|
||||
return state._tag === "AwaitingTools"
|
||||
? afterProvider(active, state.stream)
|
||||
: StateMachine.next({ ...state, active })
|
||||
}
|
||||
return unexpected(state, event)
|
||||
}
|
||||
|
||||
return State.$match(state, {
|
||||
PreparingAttempt: (state) => {
|
||||
if (event._tag !== "Prepared") return unexpected(state, event)
|
||||
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
|
||||
if (event.exit.value.preparation._tag === "Rebuilt") return prepare(event.exit.value.context, true)
|
||||
const active = {
|
||||
context: event.exit.value.context,
|
||||
attempt: event.exit.value.preparation.attempt,
|
||||
tools: new Map<string, ToolRun>(),
|
||||
}
|
||||
return pull(active)
|
||||
},
|
||||
ObservingProvider: (state) => {
|
||||
if (event._tag !== "ProviderObserved") return unexpected(state, event)
|
||||
if (Exit.isFailure(event.exit)) return finishProvider(state.active, Exit.failCause(event.exit.cause))
|
||||
const observed = event.exit.value
|
||||
if (observed._tag === "ProviderEnd") return finishProvider(state.active, Exit.succeed(undefined))
|
||||
const tools = new Map(state.active.tools)
|
||||
tools.set(observed.call.id, { call: observed.call })
|
||||
const next = { ...state.active, tools }
|
||||
return StateMachine.next(
|
||||
State.ObservingProvider({ active: next }),
|
||||
StateMachine.invoke<Operation>(
|
||||
toolID(observed.call),
|
||||
Operation.RunTool({
|
||||
attempt: next.attempt,
|
||||
call: observed.call,
|
||||
}),
|
||||
),
|
||||
StateMachine.invoke<Operation>(PROVIDER, Operation.ObserveProvider({ attempt: next.attempt })),
|
||||
)
|
||||
},
|
||||
FinalizingProvider: (state) => {
|
||||
if (event._tag !== "ProviderFinished") return unexpected(state, event)
|
||||
const stream = Exit.isFailure(event.exit) ? Exit.failCause(event.exit.cause) : state.stream
|
||||
return state.stopping ? settle(state.active, stream, state.stopping) : afterProvider(state.active, stream)
|
||||
},
|
||||
RecoveringOverflow: (state) => {
|
||||
if (event._tag !== "OverflowRecovered") return unexpected(state, event)
|
||||
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
|
||||
if (!event.exit.value) return settle(state.active, state.stream)
|
||||
const context = { ...state.active.context, recoverOverflow: false }
|
||||
return prepare(context, true)
|
||||
},
|
||||
SettlingAttempt: (state) => {
|
||||
if (event._tag !== "AttemptSettled") return unexpected(state, event)
|
||||
if (state.stopping) return StateMachine.done(Exit.failCause(state.stopping))
|
||||
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
|
||||
const backoff = (outcome: BackoffOutcome) =>
|
||||
StateMachine.next(
|
||||
State.BackingOff({ context: state.active.context, outcome }),
|
||||
StateMachine.invoke(RETRY, Operation.Retry({ context: state.active.context, outcome })),
|
||||
)
|
||||
return SessionStep.Outcome.$match(event.exit.value, {
|
||||
Completed: (outcome) => StateMachine.done(Exit.succeed(outcome.needsContinuation)),
|
||||
Retry: backoff,
|
||||
Continue: backoff,
|
||||
RecoverFull: () => prepare({ ...state.active.context, recoverContinuation: false }),
|
||||
})
|
||||
},
|
||||
BackingOff: (state) => {
|
||||
if (event._tag !== "RetryFinished") return unexpected(state, event)
|
||||
if (Exit.isFailure(event.exit)) return StateMachine.done(Exit.failCause(event.exit.cause))
|
||||
return prepare(state.context, state.outcome._tag === "Continue")
|
||||
},
|
||||
AwaitingTools: (state) => unexpected(state, event),
|
||||
Stopping: (state) => unexpected(state, event),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toolID = (call: ToolCall) => `tool:${call.id}`
|
||||
|
||||
// Pre-start interruption can bypass the interpreter's Effect.exit.
|
||||
// Normalize outer failures once without erasing operation-specific error types.
|
||||
function completed<Failure>(
|
||||
invocation: StateMachine.InvocationExited<Event<Failure>, Operation, never>,
|
||||
): Event<Failure> {
|
||||
if (Exit.isSuccess(invocation.exit)) return invocation.exit.value
|
||||
const exit = Exit.failCause(invocation.exit.cause)
|
||||
return Operation.$match(invocation.operation, {
|
||||
PrepareAttempt: () => Event.Prepared({ exit }),
|
||||
ObserveProvider: () => Event.ProviderObserved({ exit }),
|
||||
RunTool: (operation) => Event.ToolFinished({ call: operation.call, exit }),
|
||||
FinishProvider: () => Event.ProviderFinished({ exit }),
|
||||
RecoverOverflow: () => Event.OverflowRecovered({ exit }),
|
||||
SettleAttempt: () => Event.AttemptSettled({ exit }),
|
||||
Retry: () => Event.RetryFinished({ exit }),
|
||||
})
|
||||
}
|
||||
|
||||
function unexpected(state: State, event: { readonly _tag: string }): never {
|
||||
throw new Error(`Unexpected ${event._tag} event while Session Step machine is ${state._tag}`)
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
type ProviderErrorEvent,
|
||||
type ToolCall,
|
||||
} from "@opencode-ai/ai"
|
||||
import { Cause, Data, Effect, Exit, Option, Pull, Scope, Stream } from "effect"
|
||||
import { Cause, Data, Effect, Exit, Fiber, Option, Stream } from "effect"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { Tool } from "../../tool.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
import { QuestionTool } from "../../tool/plugin/question.js"
|
||||
import { StepFailedError } from "../error.js"
|
||||
@@ -33,10 +34,11 @@ export type Outcome = Data.TaggedEnum<{
|
||||
Retry: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
Continue: { readonly cause: AIError; readonly error: SessionError.Error }
|
||||
RecoverFull: {}
|
||||
Compacted: {}
|
||||
}>
|
||||
export const Outcome = Data.taggedEnum<Outcome>()
|
||||
|
||||
export interface Input {
|
||||
interface Input {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly agent: Agent.ID
|
||||
@@ -47,27 +49,6 @@ export interface Input {
|
||||
readonly recoverOverflow: Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export type ProviderObservation = Data.TaggedEnum<{
|
||||
ToolCall: { readonly call: ToolCall }
|
||||
ProviderEnd: {}
|
||||
}>
|
||||
export const ProviderObservation = Data.taggedEnum<ProviderObservation>()
|
||||
|
||||
export type ToolExit = Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>
|
||||
|
||||
export interface Settlement {
|
||||
readonly stream: Exit.Exit<void, AIError>
|
||||
readonly tools: ReadonlyArray<{ readonly call: ToolCall; readonly exit: ToolExit }>
|
||||
}
|
||||
|
||||
export interface Attempt {
|
||||
readonly observeUntilBoundary: () => Effect.Effect<ProviderObservation, AIError>
|
||||
readonly runTool: (call: ToolCall) => Effect.Effect<void, Permission.DeclinedError | QuestionTool.CancelledError>
|
||||
readonly finishProvider: (stream: Exit.Exit<void, AIError>) => Effect.Effect<void>
|
||||
readonly recoverOverflow: (settlement: Settlement) => Effect.Effect<boolean>
|
||||
readonly settle: (settlement: Settlement) => Effect.Effect<Outcome, AIError | StepFailedError>
|
||||
}
|
||||
|
||||
const TOOLS_INTERRUPTED = { type: "aborted", message: "Tool execution interrupted" } as const
|
||||
const STEP_INTERRUPTED = { type: "aborted", message: "Step interrupted" } as const
|
||||
const RESULT_MISSING = { type: "tool.result-missing", message: "Provider did not return a tool result" } as const
|
||||
@@ -79,7 +60,7 @@ export const make = Effect.gen(function* () {
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
|
||||
const open = Effect.fn("SessionStep.open")(function* (input: Input) {
|
||||
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
const publisher = createLLMEventPublisher(bus, {
|
||||
sessionID: input.sessionID,
|
||||
@@ -89,197 +70,180 @@ export const make = Effect.gen(function* () {
|
||||
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
|
||||
snapshot: startSnapshot,
|
||||
})
|
||||
const scope = yield* Scope.Scope
|
||||
const providerScope = yield* Scope.fork(scope)
|
||||
const pull = yield* llm
|
||||
.stream(input.prepared.request, input.prepared.options)
|
||||
.pipe(Stream.ensuring(publisher.flush()), Stream.toPull, Scope.provide(providerScope))
|
||||
let buffered: ReadonlyArray<LLMEvent> = []
|
||||
let offset = 0
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const toolRuns: Array<{
|
||||
readonly call: ToolCall
|
||||
readonly fiber: Fiber.Fiber<void, Permission.DeclinedError | QuestionTool.CancelledError>
|
||||
}> = []
|
||||
const interruptTools = Effect.suspend(() => Fiber.interruptAll(toolRuns.map((run) => run.fiber)))
|
||||
const executeTool = (call: ToolCall) => {
|
||||
if (input.prepared.request.toolChoice?.type === "none")
|
||||
return new Tool.Error({ message: "Tools are disabled after the maximum agent steps" })
|
||||
return input.prepared.executeTool({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.assistantMessageID,
|
||||
call,
|
||||
progress: (update) => publisher.progress(call.id, update),
|
||||
})
|
||||
}
|
||||
|
||||
const observeUntilBoundary = Effect.fnUntraced(function* (): Effect.fn.Return<ProviderObservation, AIError> {
|
||||
while (true) {
|
||||
const event = buffered[offset]
|
||||
if (event) {
|
||||
offset += 1
|
||||
if (overflowFailure || publisher.hasProviderError()) continue
|
||||
// Provider and tool fibers retain per-source order without a shared writer queue.
|
||||
// A local execution starts only after its Tool.Called publication completes.
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
// Read to the end, not just the finish event, so the next request can reuse this response.
|
||||
const providerStream = llm.stream(input.prepared.request, input.prepared.options).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
if (
|
||||
LLMEvent.is.providerError(event) &&
|
||||
isContextOverflowFailure(event) &&
|
||||
!publisher.record().outputStarted
|
||||
) {
|
||||
overflowFailure = event
|
||||
continue
|
||||
return
|
||||
}
|
||||
// Keep the publisher's in-memory mark and durable write indivisible under cancellation.
|
||||
yield* publisher.publish(event).pipe(Effect.uninterruptible)
|
||||
if (event.type === "tool-call" && !event.providerExecuted)
|
||||
return ProviderObservation.ToolCall({ call: event })
|
||||
continue
|
||||
}
|
||||
const chunk = yield* pull.pipe(Pull.catchDone(() => Effect.succeed(undefined)))
|
||||
if (!chunk) return ProviderObservation.ProviderEnd()
|
||||
buffered = chunk
|
||||
offset = 0
|
||||
}
|
||||
})
|
||||
|
||||
const runTool = Effect.fnUntraced(function* (call: ToolCall) {
|
||||
return yield* Effect.uninterruptibleMask((restore) => {
|
||||
if (input.prepared.request.toolChoice?.type === "none")
|
||||
return publisher
|
||||
.failTool(call.id, { type: "tool.execution", message: "Tools are disabled after the maximum agent steps" })
|
||||
.pipe(Effect.asVoid)
|
||||
return restore(
|
||||
input.prepared.executeTool({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
messageID: input.assistantMessageID,
|
||||
call,
|
||||
progress: (update) => publisher.progress(call.id, update),
|
||||
}),
|
||||
).pipe(
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(call.id, call.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(call.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const finishProvider = Effect.fnUntraced(function* (stream: Exit.Exit<void, AIError>) {
|
||||
yield* Scope.close(providerScope, stream)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const recoverOverflow = (settlement: Settlement) => {
|
||||
if (publisher.record().outputStarted) return Effect.succeed(false)
|
||||
const failure = overflowFailure ?? Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
|
||||
return isContextOverflowFailure(failure) ? input.recoverOverflow : Effect.succeed(false)
|
||||
}
|
||||
|
||||
const settle = Effect.fn("SessionStep.settle")(function* (settlement: Settlement) {
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(settlement.stream))
|
||||
const streamInterrupted = Exit.hasInterrupts(settlement.stream)
|
||||
const tools = classifyToolExits(settlement.tools)
|
||||
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
const recorded = publisher.record()
|
||||
const unknownFinish =
|
||||
Exit.isSuccess(settlement.stream) && recorded.finish?.finish === "unknown"
|
||||
? new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
classification: "incomplete-stream",
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
|
||||
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
input.recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!recorded.outputStarted
|
||||
)
|
||||
return Outcome.RecoverFull()
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
|
||||
yield* publisher.startAssistant()
|
||||
return Outcome.Retry({ cause: llmFailure, error: llmError })
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
|
||||
const toolFailure = interrupted
|
||||
? TOOLS_INTERRUPTED
|
||||
: tools.failure !== undefined
|
||||
? toSessionError(Cause.squash(tools.failure))
|
||||
: recorded.providerFailed
|
||||
? TOOLS_INTERRUPTED
|
||||
: undefined
|
||||
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
|
||||
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
|
||||
if (llmError || (Exit.isSuccess(settlement.stream) && !recorded.providerFailed)) {
|
||||
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
|
||||
const record = publisher.record()
|
||||
if (record.finish || record.failure) {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? startSnapshot === snapshot
|
||||
? []
|
||||
: yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const usage = record.finish
|
||||
? {
|
||||
cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens),
|
||||
tokens: record.finish.tokens,
|
||||
}
|
||||
: undefined
|
||||
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
|
||||
if (record.finish && usage && !record.failure)
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: record.finish.finish,
|
||||
rawFinish: record.finish.rawFinish,
|
||||
providerState: record.finish.providerState,
|
||||
...usage,
|
||||
snapshot,
|
||||
files,
|
||||
yield* publisher.publish(event)
|
||||
if (event.type !== "tool-call" || event.providerExecuted) return
|
||||
toolRuns.push({
|
||||
call: event,
|
||||
fiber: yield* Effect.uninterruptibleMask((restore) =>
|
||||
restore(executeTool(event)).pipe(
|
||||
Effect.flatMap(toolOutput.truncate),
|
||||
Effect.flatMap((outcome) => publisher.toolExecution(event.id, event.name, outcome)),
|
||||
Effect.catchTag("Tool.Error", (error) =>
|
||||
publisher.failTool(event.id, toSessionError(error), error.metadata).pipe(Effect.asVoid),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.forkScoped),
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.ensuring(publisher.flush()),
|
||||
)
|
||||
|
||||
// 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) || SessionRunnerRetry.isRetryable(llmFailure)) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
)
|
||||
return Outcome.Continue({ cause: llmFailure, error: llmError })
|
||||
// Keep the final tool and Step events uninterruptible, even when the work itself is cancelled.
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
const streamInterrupted = Exit.hasInterrupts(stream)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
if (Exit.isFailure(joined)) yield* interruptTools
|
||||
const tools = classifyToolExits(joined, toolRuns)
|
||||
|
||||
if (Exit.isFailure(settlement.stream)) return yield* Effect.failCause(settlement.stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
|
||||
})
|
||||
}, Effect.uninterruptible)
|
||||
if (
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
(yield* restore(input.recoverOverflow))
|
||||
)
|
||||
return Outcome.Compacted()
|
||||
|
||||
return {
|
||||
observeUntilBoundary,
|
||||
runTool,
|
||||
finishProvider,
|
||||
recoverOverflow,
|
||||
settle,
|
||||
} satisfies Attempt
|
||||
})
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
const recorded = publisher.record()
|
||||
const unknownFinish =
|
||||
Exit.isSuccess(stream) && recorded.finish?.finish === "unknown"
|
||||
? new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
message: "The provider response ended with an unknown finish reason.",
|
||||
classification: "incomplete-stream",
|
||||
}),
|
||||
})
|
||||
: undefined
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : unknownFinish
|
||||
const llmError = llmFailure && !recorded.providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
input.recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!recorded.outputStarted
|
||||
)
|
||||
return Outcome.RecoverFull()
|
||||
if (llmFailure && llmError && SessionRunnerRetry.isRetryable(llmFailure) && !recorded.outputStarted) {
|
||||
// Retry state projects onto the existing assistant, even before it has produced output.
|
||||
yield* publisher.startAssistant()
|
||||
return Outcome.Retry({ cause: llmFailure, error: llmError })
|
||||
}
|
||||
if (llmError) yield* publisher.failAssistant(llmError)
|
||||
|
||||
return { open }
|
||||
for (const decline of tools.declines)
|
||||
yield* publisher.failTool(decline.call.id, {
|
||||
type: "aborted",
|
||||
message:
|
||||
decline.reason._tag === "QuestionTool.CancelledError"
|
||||
? decline.reason.message
|
||||
: "The user declined this tool call",
|
||||
})
|
||||
const interrupted = tools.declines.length > 0 || streamInterrupted || tools.interrupted
|
||||
const toolFailure = interrupted
|
||||
? TOOLS_INTERRUPTED
|
||||
: tools.failure !== undefined
|
||||
? toSessionError(Cause.squash(tools.failure))
|
||||
: recorded.providerFailed
|
||||
? TOOLS_INTERRUPTED
|
||||
: undefined
|
||||
if (toolFailure) yield* publisher.failUnsettledTools(toolFailure)
|
||||
if (interrupted) yield* publisher.failAssistant(STEP_INTERRUPTED)
|
||||
|
||||
// All local fibers have joined; only provider-hosted results can still be missing.
|
||||
if (llmError || (Exit.isSuccess(stream) && !recorded.providerFailed)) {
|
||||
const missing = yield* publisher.failUnsettledTools(RESULT_MISSING, "hosted")
|
||||
if (missing && !llmError && !recorded.finish) yield* publisher.failAssistant(RESULT_MISSING)
|
||||
}
|
||||
|
||||
const record = publisher.record()
|
||||
if (record.finish || record.failure) {
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
? startSnapshot === snapshot
|
||||
? []
|
||||
: yield* snapshots
|
||||
.files({ from: startSnapshot, to: snapshot })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
const usage = record.finish
|
||||
? { cost: SessionUsage.calculateCost(input.model.cost, record.finish.tokens), tokens: record.finish.tokens }
|
||||
: undefined
|
||||
if (record.failure) yield* publisher.publishStepFailure({ ...usage, snapshot, files })
|
||||
if (record.finish && usage && !record.failure)
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: record.finish.finish,
|
||||
rawFinish: record.finish.rawFinish,
|
||||
providerState: record.finish.providerState,
|
||||
...usage,
|
||||
snapshot,
|
||||
files,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
)
|
||||
return Outcome.Continue({ cause: llmFailure, error: llmError })
|
||||
|
||||
if (Exit.isFailure(stream)) return yield* Effect.failCause(stream.cause)
|
||||
if (tools.declines.length > 0) return yield* Effect.interrupt
|
||||
if (tools.interrupted && tools.failure) return yield* Effect.failCause(tools.failure)
|
||||
if (tools.interrupted && Exit.isFailure(joined)) return yield* Effect.failCause(joined.cause)
|
||||
if (record.failure) return yield* new StepFailedError({ error: record.failure })
|
||||
return Outcome.Completed({
|
||||
needsContinuation: input.prepared.request.toolChoice?.type !== "none" && record.needsContinuation,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, Effect.scoped)
|
||||
|
||||
return { attempt }
|
||||
})
|
||||
|
||||
const isInterruptedStream = (failure: AIError) => {
|
||||
@@ -290,19 +254,20 @@ const isInterruptedStream = (failure: AIError) => {
|
||||
|
||||
/** Tool.Error settles in each fiber; only user declines remain in the typed error channel. */
|
||||
const classifyToolExits = (
|
||||
runs: ReadonlyArray<{
|
||||
readonly call: ToolCall
|
||||
readonly exit: ToolExit
|
||||
}>,
|
||||
settled: Exit.Exit<Array<Exit.Exit<void, Permission.DeclinedError | QuestionTool.CancelledError>>>,
|
||||
runs: ReadonlyArray<{ readonly call: ToolCall }>,
|
||||
) => {
|
||||
const declines = runs.flatMap((run) =>
|
||||
Exit.isFailure(run.exit)
|
||||
? run.exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) ? [{ call: run.call, reason: reason.error }] : [],
|
||||
const exits = Exit.isSuccess(settled) ? settled.value : []
|
||||
const declines = exits.flatMap((exit, index) =>
|
||||
Exit.isFailure(exit)
|
||||
? exit.cause.reasons.flatMap((reason) =>
|
||||
Cause.isFailReason(reason) ? [{ call: runs[index].call, reason: reason.error }] : [],
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const causes = runs.flatMap((run) => (Exit.isFailure(run.exit) ? [run.exit.cause] : []))
|
||||
const causes = Exit.isFailure(settled)
|
||||
? [settled.cause]
|
||||
: exits.flatMap((exit) => (Exit.isFailure(exit) ? [exit.cause] : []))
|
||||
const failure = causes
|
||||
.flatMap((cause) => {
|
||||
if (Cause.hasInterrupts(cause)) return []
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import { PermissionV1 } from "../v1/permission.js"
|
||||
import { Project } from "../project.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
addToolStatus(toolTotals, row.status)
|
||||
addToolStatus(toolTotals, row.status, 1)
|
||||
if (!row.name) return
|
||||
const tool = tools.get(row.name) ?? {
|
||||
name: row.name,
|
||||
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
durations: [],
|
||||
}
|
||||
tools.set(row.name, tool)
|
||||
addToolStatus(tool, row.status)
|
||||
addToolStatus(tool, row.status, 1)
|
||||
if (row.duration !== null) tool.durations.push(row.duration)
|
||||
})
|
||||
}),
|
||||
@@ -385,17 +385,18 @@ function tokenTotal(tokens: Tokens) {
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
count: number,
|
||||
) {
|
||||
target.calls++
|
||||
target.calls += count
|
||||
if (status === "completed") {
|
||||
target.succeeded++
|
||||
target.succeeded += count
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
target.failed++
|
||||
target.failed += count
|
||||
return
|
||||
}
|
||||
target.unfinished++
|
||||
target.unfinished += count
|
||||
}
|
||||
|
||||
function makeDateKey(timezone = "UTC") {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
|
||||
import type { Usage } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Model } from "../model.js"
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
+51
-50
@@ -122,8 +122,8 @@ const layer = () =>
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const commands = new Map<Shell.ID, Active>()
|
||||
const exitOrder: Shell.ID[] = []
|
||||
const sessions = new Map<string, Active>()
|
||||
const exitOrder: string[] = []
|
||||
|
||||
const outputDir = path.join(global.data, DIRECTORY, location.project.id)
|
||||
const { mkdir, unlink } = yield* Effect.promise(() => import("fs/promises"))
|
||||
@@ -132,44 +132,44 @@ const layer = () =>
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
for (const command of commands.values()) {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
for (const session of sessions.values()) {
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Teardown interrupts pending commands; it is not a terminal command failure.
|
||||
yield* Deferred.interrupt(command.done)
|
||||
yield* Deferred.interrupt(session.done)
|
||||
}
|
||||
commands.clear()
|
||||
sessions.clear()
|
||||
exitOrder.length = 0
|
||||
}),
|
||||
)
|
||||
|
||||
const require = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
if (!command) return yield* new NotFoundError({ id })
|
||||
return command
|
||||
const session = sessions.get(id)
|
||||
if (!session) return yield* new NotFoundError({ id })
|
||||
return session
|
||||
})
|
||||
|
||||
const removeCommand = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const command = commands.get(id)
|
||||
const removeSession = Effect.fnUntraced(function* (id: Shell.ID) {
|
||||
const session = sessions.get(id)
|
||||
const index = exitOrder.indexOf(id)
|
||||
if (index !== -1) exitOrder.splice(index, 1)
|
||||
if (!command) return
|
||||
commands.delete(id)
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (!session) return
|
||||
sessions.delete(id)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
// Unblock any wait still pending when the command is removed before it terminated.
|
||||
yield* Deferred.fail(command.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(command.file).catch(() => {}))
|
||||
yield* Deferred.fail(session.done, new NotFoundError({ id }))
|
||||
yield* Effect.promise(() => unlink(session.file).catch(() => {}))
|
||||
yield* bus.publish(Shell.Event.Deleted, { id })
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Shell.remove")(function* (id: Shell.ID) {
|
||||
yield* require(id)
|
||||
yield* removeCommand(id)
|
||||
yield* removeSession(id)
|
||||
})
|
||||
|
||||
const list = Effect.fn("Shell.list")(function* () {
|
||||
return Array.from(commands.values())
|
||||
.filter((command) => command.info.status === "running")
|
||||
.map((command) => command.info)
|
||||
return Array.from(sessions.values())
|
||||
.filter((session) => session.info.status === "running")
|
||||
.map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Shell.get")(function* (id: Shell.ID) {
|
||||
@@ -181,24 +181,24 @@ const layer = () =>
|
||||
})
|
||||
|
||||
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
|
||||
const command = yield* require(id)
|
||||
if (command.info.status !== "running" || !command.timeout) return command.info
|
||||
yield* command.timeout(duration)
|
||||
return command.info
|
||||
const session = yield* require(id)
|
||||
if (session.info.status !== "running" || !session.timeout) return session.info
|
||||
yield* session.timeout(duration)
|
||||
return session.info
|
||||
})
|
||||
|
||||
const output = Effect.fnUntraced(function* (id: Shell.ID, input?: Shell.OutputInput) {
|
||||
const command = yield* require(id)
|
||||
const session = yield* require(id)
|
||||
const cursor = input?.cursor ?? 0
|
||||
const limit = input?.limit ?? 65536
|
||||
if (cursor >= command.size) return { output: "", cursor: command.size, size: command.size, truncated: false }
|
||||
if (cursor >= session.size) return { output: "", cursor: session.size, size: session.size, truncated: false }
|
||||
const start = Math.max(0, cursor)
|
||||
const length = Math.min(limit, command.size - start)
|
||||
const length = Math.min(limit, session.size - start)
|
||||
const buffer = Buffer.alloc(length)
|
||||
const bytesRead = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<number>((resolve) => {
|
||||
const stream = createReadStream(command.file, { start, end: start + length - 1 })
|
||||
const stream = createReadStream(session.file, { start, end: start + length - 1 })
|
||||
let offset = 0
|
||||
stream.on("data", (chunk: string | Buffer) => {
|
||||
const bytes = Buffer.from(chunk)
|
||||
@@ -212,7 +212,7 @@ const layer = () =>
|
||||
return {
|
||||
output: buffer.subarray(0, bytesRead).toString("utf8"),
|
||||
cursor: start + bytesRead,
|
||||
size: command.size,
|
||||
size: session.size,
|
||||
truncated: false,
|
||||
}
|
||||
})
|
||||
@@ -257,7 +257,7 @@ const layer = () =>
|
||||
|
||||
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
|
||||
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
|
||||
// end). `create` returns once `ready` resolves with the registered command.
|
||||
// end). `create` returns once `ready` resolves with the registered session.
|
||||
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
|
||||
runFork(
|
||||
Effect.scoped(
|
||||
@@ -275,7 +275,7 @@ const layer = () =>
|
||||
.pipe(
|
||||
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
|
||||
)
|
||||
const command: Active = {
|
||||
const session: Active = {
|
||||
info: produce(info, (draft) => {
|
||||
draft.pid = handle.pid
|
||||
}),
|
||||
@@ -283,7 +283,7 @@ const layer = () =>
|
||||
size: 0,
|
||||
done: Deferred.makeUnsafe<Info, NotFoundError>(),
|
||||
}
|
||||
commands.set(id, command)
|
||||
sessions.set(id, session)
|
||||
|
||||
const stream = createWriteStream(file)
|
||||
const outputDone = Latch.makeUnsafe()
|
||||
@@ -291,7 +291,7 @@ const layer = () =>
|
||||
Stream.runForEach((chunk: Uint8Array) =>
|
||||
Effect.sync(() => {
|
||||
stream.write(chunk)
|
||||
command.size += chunk.length
|
||||
session.size += chunk.length
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -305,7 +305,7 @@ const layer = () =>
|
||||
}),
|
||||
)
|
||||
yield* outputDone.open
|
||||
}),
|
||||
}).pipe(Effect.catch(() => outputDone.open)),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
@@ -317,8 +317,8 @@ const layer = () =>
|
||||
|
||||
const finish = (status: Info["status"], exit?: number, beforeWait = Effect.void) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.info.status !== "running") return
|
||||
command.info = produce(command.info, (draft) => {
|
||||
if (session.info.status !== "running") return
|
||||
session.info = produce(session.info, (draft) => {
|
||||
draft.status = status
|
||||
if (exit !== undefined) draft.exit = exit
|
||||
draft.time.completed = Date.now()
|
||||
@@ -326,10 +326,10 @@ const layer = () =>
|
||||
yield* beforeWait
|
||||
yield* outputDone.await
|
||||
// Resolve waiters with the terminal Info before any retention eviction, so an evicted
|
||||
// command still reports success rather than the removal NotFoundError. This runs before
|
||||
// session still reports success rather than the removal NotFoundError. This runs before
|
||||
// the timeout-fiber interrupt below, which on the timeout path would otherwise cancel
|
||||
// this very fiber (finish is invoked by the timeout fiber) before waiters are resolved.
|
||||
yield* Deferred.succeed(command.done, command.info)
|
||||
yield* Deferred.succeed(session.done, session.info)
|
||||
yield* bus.publish(Shell.Event.Exited, {
|
||||
id,
|
||||
...(exit !== undefined ? { exit } : {}),
|
||||
@@ -339,28 +339,29 @@ const layer = () =>
|
||||
while (exitOrder.length > EXITED_LIMIT) {
|
||||
const oldest = exitOrder[0]
|
||||
if (!oldest) break
|
||||
yield* removeCommand(oldest)
|
||||
yield* removeSession(Shell.ID.make(oldest))
|
||||
}
|
||||
// Cancel a pending timeout once the command exits on its own. Interrupting last avoids
|
||||
// aborting finish when finish itself runs on the timeout fiber.
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
})
|
||||
|
||||
command.timeout = (duration) =>
|
||||
session.timeout = (duration) =>
|
||||
Effect.gen(function* () {
|
||||
if (command.timeoutFiber) yield* Fiber.interrupt(command.timeoutFiber)
|
||||
command.timeoutFiber = undefined
|
||||
if (duration === 0 || command.info.status !== "running") return
|
||||
command.timeoutFiber = runFork(
|
||||
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
|
||||
session.timeoutFiber = undefined
|
||||
if (duration === 0 || session.info.status !== "running") return
|
||||
session.timeoutFiber = runFork(
|
||||
Effect.sleep(Duration.millis(duration)).pipe(
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* command.timeout(invocation.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -370,16 +371,16 @@ const layer = () =>
|
||||
)
|
||||
|
||||
yield* bus.publish(Shell.Event.Created, { info })
|
||||
yield* Deferred.succeed(ready, command)
|
||||
yield* Deferred.succeed(ready, session)
|
||||
// Hold the handle's scope open until the command terminates; closing it earlier would
|
||||
// release (kill) the process before its exit is observed.
|
||||
yield* Deferred.await(command.done).pipe(Effect.catch(() => Effect.void))
|
||||
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
|
||||
}),
|
||||
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
|
||||
)
|
||||
|
||||
const command = yield* Deferred.await(ready)
|
||||
return command.info
|
||||
const session = yield* Deferred.await(ready)
|
||||
return session.info
|
||||
})
|
||||
|
||||
return Service.of({ create, list, get, wait, timeout, output, remove })
|
||||
|
||||
@@ -7,7 +7,6 @@ import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
import { lazy } from "../util/lazy.js"
|
||||
import { Wildcard } from "../util/wildcard.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
@@ -356,7 +355,10 @@ function resolve(asset: string) {
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = lazy(initialize)
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
|
||||
@@ -133,34 +133,36 @@ const layer = Layer.effect(
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
source: repo.source,
|
||||
input: {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
return files.filter((file) => !ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
|
||||
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
|
||||
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- Built-in tool plugins live in `tool/plugin`.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
|
||||
|
||||
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
|
||||
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ export const layer = Layer.effect(
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as EditTool from "./edit.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as GlobTool from "./glob.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as GrepTool from "./grep.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
@@ -65,7 +65,7 @@ type Prepared =
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as QuestionTool from "./question.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ReadTool from "./read.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -169,7 +169,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
|
||||
] as const
|
||||
|
||||
if (output.type === "list-page") {
|
||||
const start = offset || 1
|
||||
const start = offset ?? 1
|
||||
const content = [
|
||||
output.entries.length === 0
|
||||
? `Read directory ${path}, 0 entries`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ShellTool from "./shell.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
@@ -280,8 +280,7 @@ export const Plugin = {
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
// CodeMode children share a tool-call ID, but each shell must own its job.
|
||||
id: info.id,
|
||||
id: context.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
@@ -296,7 +295,7 @@ export const Plugin = {
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
|
||||
@@ -305,7 +304,7 @@ export const Plugin = {
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* Skill.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
@@ -52,7 +52,7 @@ export const description = [
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebFetchTool from "./webfetch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchTool from "./websearch.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
@@ -26,7 +26,7 @@ const Output = Schema.Struct({
|
||||
})
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as WriteTool from "./write.js"
|
||||
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -45,7 +45,7 @@ export const toModelContent = (output: Output) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -5,7 +5,9 @@ export abstract class NamedError extends Error {
|
||||
abstract toObject(): { name: string; data: unknown }
|
||||
|
||||
static hasName(error: unknown, name: string): boolean {
|
||||
return typeof error === "object" && error !== null && "name" in error && error.name === name
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
||||
)
|
||||
}
|
||||
|
||||
static create<Name extends string, Fields extends Schema.Struct.Fields>(
|
||||
|
||||
@@ -375,100 +375,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes file data across AI SDK prompt parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("opaque-provider"))
|
||||
const bytes = new Uint8Array([0, 1, 2, 3])
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: bytes, filename: "bytes.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
|
||||
{
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "data:image/png;charset=utf-8;base64,AQID",
|
||||
filename: "inline.png",
|
||||
},
|
||||
{ type: "media", mediaType: "image/png", data: "https://example.com/image.png" },
|
||||
{ type: "media", mediaType: "image/png", data: "s3://bucket/image.png" },
|
||||
]),
|
||||
Message.assistant({
|
||||
type: "media",
|
||||
mediaType: "application/pdf",
|
||||
data: "http://example.com/document.pdf",
|
||||
filename: "document.pdf",
|
||||
}),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "screenshot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,BAUG", mime: "image/png", name: "tool.png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "file", mediaType: "image/png", data: bytes, filename: "bytes.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "AAAA", filename: "base64.png" },
|
||||
{ type: "file", mediaType: "image/png", data: "AQID", filename: "inline.png" },
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
data: new URL("https://example.com/image.png"),
|
||||
filename: undefined,
|
||||
},
|
||||
{ type: "file", mediaType: "image/png", data: "s3://bucket/image.png", filename: undefined },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "application/pdf",
|
||||
data: new URL("http://example.com/document.pdf"),
|
||||
filename: "document.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "screenshot",
|
||||
output: { type: "text", value: "Media attached in the following user message." },
|
||||
providerOptions: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "file", mediaType: "image/png", data: "BAUG", filename: "tool.png" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes user and tool media through the real Mistral provider", () =>
|
||||
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
@@ -508,14 +415,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Inspect the attachments." },
|
||||
{ type: "media", mediaType: "image/png", data: new Uint8Array([0, 1, 2, 3]) },
|
||||
{ type: "media", mediaType: "image/png", data: "AQID" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/png;base64,BAUG" },
|
||||
{ type: "media", mediaType: "image/png", data: "http://example.com/image.png" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "https://example.com/document.pdf" },
|
||||
]),
|
||||
Message.user("Inspect the screenshot."),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
||||
Message.tool({
|
||||
type: "tool-result",
|
||||
@@ -526,12 +426,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
value: [
|
||||
{ type: "text", text: "Screenshot captured" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "https://example.com/tool-document.pdf",
|
||||
mime: "application/pdf",
|
||||
name: "tool-document.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -540,17 +434,7 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
).pipe(Effect.provide(client))
|
||||
|
||||
expect(body?.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Inspect the attachments." },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AAECAw==" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AQID" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,BAUG" },
|
||||
{ type: "image_url", image_url: "http://example.com/image.png" },
|
||||
{ type: "document_url", document_url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
@@ -573,7 +457,6 @@ it.effect("normalizes user and tool media through the real Mistral provider", ()
|
||||
content: [
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
|
||||
{ type: "document_url", document_url: "https://example.com/tool-document.pdf" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -85,8 +85,8 @@ describe("Bus Session routing", () => {
|
||||
projectID: Project.ID.global,
|
||||
})
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, same, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, same, done])
|
||||
expect(moved.location).toEqual(a)
|
||||
}),
|
||||
)
|
||||
@@ -130,8 +130,8 @@ describe("Bus Session routing", () => {
|
||||
)
|
||||
const after = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: child })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect((yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(first)).map((event) => event.id)).toEqual([eventID, after.id, done.id])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
}),
|
||||
)
|
||||
}),
|
||||
@@ -158,17 +158,19 @@ describe("Bus Session routing", () => {
|
||||
const explicit = yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: id }, { location: b })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
|
||||
expect(yield* Fiber.join(first)).toEqual([renamed, text, broadcast, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([broadcast, explicit, done])
|
||||
expect(yield* Fiber.join(workspace)).toEqual([broadcast, done])
|
||||
expect(yield* Fiber.join(global)).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([renamed, text, broadcast, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([broadcast, explicit, done])
|
||||
expect(Array.from(yield* Fiber.join(workspace))).toEqual([broadcast, done])
|
||||
expect(Array.from(yield* Fiber.join(global))).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(listened).toEqual([renamed, text, broadcast, explicit, done])
|
||||
expect(renamed).not.toHaveProperty("location")
|
||||
expect(text).not.toHaveProperty("location")
|
||||
expect(JSON.parse(JSON.stringify(renamed))).not.toHaveProperty("location")
|
||||
const history = yield* bus.log({ aggregateID: id }).pipe(Stream.runCollect)
|
||||
expect(
|
||||
history.filter((event): event is Event.Payload => !Bus.isSynced(event)).every((event) => !event.location),
|
||||
Array.from(history)
|
||||
.filter((event): event is Event.Payload => !Bus.isSynced(event))
|
||||
.every((event) => !event.location),
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -195,8 +197,8 @@ describe("Bus Session routing", () => {
|
||||
yield* bus.publish(SessionEvent.Moved, { sessionID: id, location: b, projectID: Project.ID.global })
|
||||
const expected = yield* bus.publish(SessionEvent.Renamed, { sessionID: id, title: "destination" })
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(typed)).toEqual([expected])
|
||||
expect(yield* Fiber.join(multiple)).toEqual([expected, done])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([expected])
|
||||
expect(Array.from(yield* Fiber.join(multiple))).toEqual([expected, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -224,8 +226,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
|
||||
expect(yield* Fiber.join(first)).toEqual([created, before, moved, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([moved, after, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([created, before, moved, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([moved, after, done])
|
||||
expect(moved).not.toHaveProperty("location")
|
||||
}),
|
||||
)
|
||||
@@ -243,9 +245,9 @@ describe("Bus Session routing", () => {
|
||||
|
||||
const database = yield* Database.Service
|
||||
expect(yield* database.db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).toBeUndefined()
|
||||
expect(yield* Fiber.join(first)).toEqual([deleted, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(yield* Fiber.join(global)).toEqual([deleted, missing, done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([deleted, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(global))).toEqual([deleted, missing, done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -264,8 +266,8 @@ describe("Bus Session routing", () => {
|
||||
])
|
||||
const done = yield* bus.publish(Done, {})
|
||||
yield* Deferred.succeed(gate, undefined)
|
||||
expect(yield* Fiber.join(first)).toEqual([events[0], events[1], done])
|
||||
expect(yield* Fiber.join(second)).toEqual([events[1], events[2], events[3], done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([events[0], events[1], done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([events[1], events[2], events[3], done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -293,8 +295,8 @@ describe("Bus Session routing", () => {
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(Exit.isFailure(single)).toBe(true)
|
||||
expect(Exit.isFailure(batch)).toBe(true)
|
||||
expect(yield* Fiber.join(first)).toEqual([before, after, done])
|
||||
expect(yield* Fiber.join(second)).toEqual([done])
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([before, after, done])
|
||||
expect(Array.from(yield* Fiber.join(second))).toEqual([done])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -325,8 +327,8 @@ describe("Bus Session routing", () => {
|
||||
{ publish: true },
|
||||
)
|
||||
const done = yield* bus.publish(Done, {})
|
||||
expect(yield* Fiber.join(first)).toEqual([done])
|
||||
const received = yield* Fiber.join(second)
|
||||
expect(Array.from(yield* Fiber.join(first))).toEqual([done])
|
||||
const received = Array.from(yield* Fiber.join(second))
|
||||
expect(received.map((event) => event.id)).toEqual([after.id, replayID, done.id])
|
||||
expect(received[1]).not.toHaveProperty("location")
|
||||
}),
|
||||
|
||||
@@ -123,7 +123,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(Message, { text: "hello" })
|
||||
yield* bus.publish(CountMessage, { count: 2 })
|
||||
|
||||
const received = (yield* Fiber.join(fiber)).map((event) =>
|
||||
const received = Array.from(yield* Fiber.join(fiber)).map((event) =>
|
||||
event.type === "test.message" ? event.data.text : event.data.count,
|
||||
)
|
||||
expect(received).toEqual(["hello", 2])
|
||||
@@ -136,7 +136,7 @@ describe("Bus", () => {
|
||||
const fiber = yield* bus.subscribe(Message).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* bus.publish(Message, { text: "hello" })
|
||||
const received = yield* Fiber.join(fiber)
|
||||
const received = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
expect(received).toEqual([event])
|
||||
expect(event.type).toBe("test.message")
|
||||
@@ -212,8 +212,8 @@ describe("Bus", () => {
|
||||
yield* Effect.yieldNow
|
||||
const event = yield* bus.publish(Message, { text: "hello" })
|
||||
|
||||
expect(yield* Fiber.join(typed)).toEqual([event])
|
||||
expect(yield* Fiber.join(wildcard)).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(typed))).toEqual([event])
|
||||
expect(Array.from(yield* Fiber.join(wildcard))).toEqual([event])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -602,7 +602,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "two"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[1, durableData(aggregateID, "one")],
|
||||
[2, durableData(aggregateID, "two")],
|
||||
])
|
||||
@@ -618,7 +618,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, durableData(aggregateID, "zero")],
|
||||
[1, durableData(aggregateID, "one")],
|
||||
])
|
||||
@@ -653,7 +653,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "during handoff"))
|
||||
yield* Deferred.succeed(continueRead, undefined)
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
|
||||
[0, durableData(aggregateID, "during handoff")],
|
||||
])
|
||||
}).pipe(Effect.provide(eventLayer))
|
||||
@@ -672,7 +672,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, String(index)))
|
||||
}
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual(
|
||||
Array.from({ length: count }, (_, index) => [index, durableData(aggregateID, String(index))]),
|
||||
)
|
||||
}),
|
||||
@@ -688,7 +688,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(Message, { text: "live only" })
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "durable"))
|
||||
|
||||
expect((yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
expect(Array.from(yield* Fiber.join(fiber)).map((event) => event.type)).toEqual([DurableMessage.type])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1268,7 +1268,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
@@ -1284,9 +1284,9 @@ describe("Bus", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const aggregateID = Session.ID.create()
|
||||
|
||||
const empty = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const empty = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "zero"))
|
||||
const drained = yield* Stream.runCollect(bus.log({ aggregateID, after: 0 }))
|
||||
const drained = Array.from(yield* Stream.runCollect(bus.log({ aggregateID, after: 0 })))
|
||||
|
||||
expect(empty).toEqual([{ type: "log.synced", aggregateID }])
|
||||
expect(empty[0]).not.toHaveProperty("seq")
|
||||
@@ -1306,7 +1306,7 @@ describe("Bus", () => {
|
||||
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
|
||||
const items = yield* Fiber.join(fiber)
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
|
||||
@@ -1330,7 +1330,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "three"))
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "four"))
|
||||
|
||||
const items = yield* Stream.runCollect(bus.log({ aggregateID }))
|
||||
const items = Array.from(yield* Stream.runCollect(bus.log({ aggregateID })))
|
||||
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item.type : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
@@ -1378,7 +1378,7 @@ describe("Bus", () => {
|
||||
yield* bus.publish(DurableMessage, durableData(aggregateID, "one"))
|
||||
yield* Deferred.succeed(releaseRead, undefined)
|
||||
|
||||
const items = yield* Fiber.join(fiber)
|
||||
const items = Array.from(yield* Fiber.join(fiber))
|
||||
expect(items.map((item) => (Bus.isSynced(item) ? item : item.durable?.seq))).toEqual([
|
||||
Event.Seq.make(0),
|
||||
{ type: "log.synced", aggregateID, seq: Event.Seq.make(0) },
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
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",
|
||||
})
|
||||
}),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -330,7 +330,10 @@ describe("ConfigAgentPlugin.Plugin", () => {
|
||||
)
|
||||
|
||||
it.live("loads legacy file-based agents from config directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -404,7 +407,10 @@ Use native v2 fields.`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds agents when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, testCase.source)
|
||||
@@ -439,7 +445,10 @@ Use native v2 fields.`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
@@ -476,7 +485,10 @@ Use native v2 fields.`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside agent source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "agents")
|
||||
|
||||
@@ -54,7 +54,10 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -163,7 +166,10 @@ Review files`,
|
||||
|
||||
for (const testCase of sourceCases()) {
|
||||
it.effect(`rebuilds commands when a source file is ${testCase.name}`, () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -206,7 +212,10 @@ Review files`,
|
||||
}
|
||||
|
||||
it.effect("coalesces updates inside the debounce window into one rebuild", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
@@ -245,7 +254,10 @@ Review files`,
|
||||
)
|
||||
|
||||
it.effect("ignores updates outside command source directories", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const directory = path.join(tmp.path, "commands")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
|
||||
import { FastCheck } from "effect/testing"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
@@ -77,7 +77,10 @@ const provider = {
|
||||
|
||||
describe("Config", () => {
|
||||
it.live("excludes home-level claude and agents directories when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(global, "home")
|
||||
@@ -117,7 +120,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("excludes global config reached through the project walk when global is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
// The location sits BENEATH the global config dir, so the upward walk
|
||||
// reaches the global opencode.json as a direct file.
|
||||
@@ -150,7 +156,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads explicit file and content overrides in priority order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -185,7 +194,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("skips project configuration when project discovery is disabled", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const project = path.join(tmp.path, "project")
|
||||
@@ -213,7 +225,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("reloads external config and publishes directory updates", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -246,7 +261,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("exposes filesystem updates under config roots through changes", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -278,7 +296,10 @@ describe("Config", () => {
|
||||
// watch being torn down, making recreation invisible) only reproduces with
|
||||
// path-faithful event delivery.
|
||||
it.live("keeps watching a deleted config file so recreating it reloads", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -348,24 +369,26 @@ describe("Config", () => {
|
||||
}).pipe(Effect.provide(Config.testLayer())),
|
||||
)
|
||||
|
||||
test("returns the latest defined scalar from priority-ordered documents", () => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
|
||||
Effect.sync(() => {
|
||||
const entries = [
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5") }),
|
||||
}),
|
||||
new Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
|
||||
new AgentsDirectory({ type: "agents", path: AbsolutePath.make("/agents") }),
|
||||
new Document({ type: "document", info: new Info({}) }),
|
||||
new Document({
|
||||
type: "document",
|
||||
info: new Info({ model: selection("openrouter/openai/gpt-5.5") }),
|
||||
}),
|
||||
]
|
||||
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
})
|
||||
expect(Config.latest(entries, "model")).toEqual(selection("openrouter/openai/gpt-5.5"))
|
||||
expect(Config.latest(entries, "default_agent")).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tolerates unavailable authenticated wellknown config and reloads it later", () =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -557,241 +580,268 @@ describe("Config", () => {
|
||||
).pipe(Effect.provide(Logger.layer([logger])))
|
||||
})
|
||||
|
||||
test("migrates arbitrary v1 configuration into valid v2 configuration", () => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}, 30_000)
|
||||
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
FastCheck.assert(
|
||||
FastCheck.property(Schema.toArbitrary(ConfigV1.Info)(FastCheck), (info) => {
|
||||
const parsed = Schema.decodeUnknownSync(ConfigV1.Info)(
|
||||
Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(
|
||||
Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown))(info),
|
||||
),
|
||||
)
|
||||
Schema.decodeUnknownSync(Info)(ConfigMigrateV1.migrate(parsed), { errors: "all" })
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 experimental subagent depth", () => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
})
|
||||
it.effect("migrates the v1 experimental subagent depth", () =>
|
||||
Effect.sync(() => {
|
||||
expect(ConfigMigrateV1.migrate({ experimental: { subagent_depth: 2 } }).experimental?.subagent_depth).toBe(2)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates the v1 small model to the title agent", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
})
|
||||
it.effect("migrates the v1 small model to the title agent", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
small_model: "anthropic/claude-haiku-4-5",
|
||||
agent: { title: { prompt: "Custom title prompt" } },
|
||||
}).agents?.title,
|
||||
).toEqual({
|
||||
model: { providerID: "anthropic", model: "claude-haiku-4-5" },
|
||||
system: "Custom title prompt",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider lists to policies", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
})
|
||||
it.effect("migrates v1 provider lists to policies", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
enabled_providers: ["anthropic", "openai"],
|
||||
disabled_providers: ["openai"],
|
||||
}).experimental?.policies,
|
||||
).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "anthropic", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "allow" },
|
||||
{ action: "provider.use", resource: "openai", effect: "deny" },
|
||||
])
|
||||
expect(ConfigMigrateV1.migrate({ enabled_providers: [] }).experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates v1 provider setup options into AISDK settings", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
it.effect("migrates v1 provider setup options into AISDK settings", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
bedrock: {
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
models: { claude: { provider: { npm: "@ai-sdk/anthropic" } } },
|
||||
options: {
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
region: "us-east-1",
|
||||
profile: "dev",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.bedrock).toMatchObject({
|
||||
package: Provider.aisdk("@ai-sdk/amazon-bedrock"),
|
||||
models: { claude: { package: Provider.aisdk("@ai-sdk/anthropic") } },
|
||||
settings: { region: "us-east-1", profile: "dev" },
|
||||
headers: { "x-test": "1" },
|
||||
body: { trace: true },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("renames old provider IDs while migrating v1 configuration", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
command: {
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
it.effect("renames old provider IDs while migrating v1 configuration", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
model: "azure-cognitive-services/deployment",
|
||||
enabled_providers: ["google-vertex-anthropic"],
|
||||
disabled_providers: ["azure-cognitive-services"],
|
||||
agent: {
|
||||
reviewer: { model: "google-vertex-anthropic/claude-sonnet" },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores old provider IDs when the current provider ID is configured", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
})
|
||||
|
||||
test("preserves the built-in package for v1 Vertex Anthropic custom models", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
})
|
||||
|
||||
test("migrates v1 interleaved fields to compatibility", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 command configuration", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
review: { template: "Review", model: "azure-cognitive-services/deployment" },
|
||||
},
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/azure",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
models: { deployment: {} },
|
||||
},
|
||||
"google-vertex-anthropic": {
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
options: { project: "test-project", location: "us-central1" },
|
||||
models: { "claude-sonnet": {} },
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("normalizes renamed permission actions when migrating v1 permissions", () => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
expect(migrated.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.agents?.reviewer?.model).toEqual({ providerID: "google-vertex", model: "claude-sonnet" })
|
||||
expect(migrated.commands?.review?.model).toEqual({ providerID: "azure", model: "deployment" })
|
||||
expect(migrated.experimental?.policies).toEqual([
|
||||
{ action: "provider.use", resource: "*", effect: "deny" },
|
||||
{ action: "provider.use", resource: "google-vertex", effect: "allow" },
|
||||
{ action: "provider.use", resource: "azure", effect: "deny" },
|
||||
])
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/azure"),
|
||||
models: { deployment: {} },
|
||||
})
|
||||
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]).toMatchObject({
|
||||
settings: { project: "test-project", location: "us-central1" },
|
||||
models: {
|
||||
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
})
|
||||
})
|
||||
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
|
||||
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the generated base URL for v1 Azure OpenAI-compatible providers", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"azure-cognitive-services": {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: ["AZURE_COGNITIVE_SERVICES_RESOURCE_NAME", "AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure).toMatchObject({
|
||||
env: ["AZURE_COGNITIVE_SERVICES_API_KEY"],
|
||||
package: Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: {
|
||||
baseURL: "https://${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}.cognitiveservices.azure.com/openai",
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores old provider IDs when the current provider ID is configured", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
azure: { models: { current: {} } },
|
||||
"azure-cognitive-services": { models: { legacy: {} } },
|
||||
"google-vertex": { models: { gemini: {} } },
|
||||
"google-vertex-anthropic": { models: { claude: {} } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.azure?.models).toEqual({ current: expect.anything() })
|
||||
expect(migrated.providers?.["google-vertex"]?.models).toEqual({ gemini: expect.anything() })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves the built-in package for v1 Vertex Anthropic custom models", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
"google-vertex-anthropic": {
|
||||
models: { claude: {} },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.["google-vertex"]?.package).toBeUndefined()
|
||||
expect(migrated.providers?.["google-vertex"]?.models?.claude?.package).toBe(
|
||||
Provider.aisdk("@ai-sdk/google-vertex/anthropic"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 interleaved fields to compatibility", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
object: { interleaved: { field: "vendor_reasoning" } },
|
||||
string: { interleaved: "reasoning_text" },
|
||||
boolean: { interleaved: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.object?.compatibility).toEqual({
|
||||
reasoningField: "vendor_reasoning",
|
||||
})
|
||||
expect(migrated.providers?.custom?.models?.string?.compatibility).toEqual({ reasoningField: "reasoning_text" })
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 command configuration", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
command: {
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: "anthropic/claude",
|
||||
variant: "high",
|
||||
subtask: true,
|
||||
},
|
||||
},
|
||||
}).commands,
|
||||
).toEqual({
|
||||
review: {
|
||||
template: "Review changes",
|
||||
description: "Review code",
|
||||
agent: "reviewer",
|
||||
model: { providerID: "anthropic", model: "claude", variant: "high" },
|
||||
subtask: true,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes renamed permission actions when migrating v1 permissions", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
ConfigMigrateV1.migrate({
|
||||
permission: {
|
||||
task: "ask",
|
||||
bash: { "git status": "allow", "*": "deny" },
|
||||
write: "deny",
|
||||
read: "allow",
|
||||
},
|
||||
}).permissions,
|
||||
).toEqual([
|
||||
{ action: "subagent", resource: "*", effect: "ask" },
|
||||
{ action: "shell", resource: "git status", effect: "allow" },
|
||||
{ action: "shell", resource: "*", effect: "deny" },
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns an empty configuration when directory files do not exist", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
@@ -806,7 +856,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("deduplicates global ecosystem directories found during upward discovery", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const global = path.join(tmp.path, "global")
|
||||
@@ -835,7 +888,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not watch ecosystem config roots", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -863,7 +919,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads opencode JSON and JSONC files from lowest to highest priority", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -974,7 +1033,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("does not load legacy config.json files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -993,7 +1055,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("accepts $schema metadata without writing it into config files", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
@@ -1017,7 +1082,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads supported scalar and resource configuration", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1203,7 +1271,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates the deprecated reference key into references", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1236,7 +1307,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("migrates v1 configuration when a v1-only key is present", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1408,7 +1482,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("ignores an invalid file while loading valid config values", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
@@ -1434,7 +1511,10 @@ describe("Config", () => {
|
||||
)
|
||||
|
||||
it.live("loads global and ancestor configuration across the project boundary", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const root = path.join(tmp.path, "repo")
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { ConfigFile } from "@opencode-ai/core/config/file"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { withTempDir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
|
||||
const it = testEffect(LayerNode.compile(FSUtil.node))
|
||||
|
||||
describe("ConfigFile", () => {
|
||||
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = path.join(tmp.path, "global", "opencode.jsonc")
|
||||
const target = path.join(tmp.path, "project", "custom.jsonc")
|
||||
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
|
||||
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
|
||||
yield* fs.writeWithDirs(target, text)
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
|
||||
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
|
||||
"shell": "{file:missing-shell.txt}",
|
||||
"skills": { "paths": ["./skills"] },
|
||||
"agent": { "review": { "model": "acme/reasoner" } },
|
||||
"username": "before"
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
|
||||
expect(draft.shell).toBe("{file:missing-shell.txt}")
|
||||
expect(draft.skills).toEqual({ paths: ["./skills"] })
|
||||
draft.username = "after"
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"agent": {
|
||||
"review": { "description": "before", "hidden": true },
|
||||
// Keep the other definition.
|
||||
"build": { "description": "unchanged" }
|
||||
},
|
||||
"snapshot": true
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
const agent: unknown = draft.agent
|
||||
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
|
||||
agent.review.description = "after"
|
||||
agent.review.color = "blue"
|
||||
delete agent.review.hidden
|
||||
delete draft.snapshot
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
|
||||
})
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches array elements without rewriting untouched comments", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"plugins": [
|
||||
// Keep the first plugin.
|
||||
"first",
|
||||
"second",
|
||||
// Keep the third plugin.
|
||||
"third",
|
||||
"fourth"
|
||||
]
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins[1] = "updated"
|
||||
})
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
|
||||
|
||||
const shortened = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 3)
|
||||
})
|
||||
expect(shortened.plugins).toEqual(["first"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
|
||||
|
||||
const extended = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.push("added", "last")
|
||||
})
|
||||
expect(extended.plugins).toEqual(["first", "added", "last"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves adjacent comments when deleting properties and array elements", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"shell": "remove",
|
||||
// Keep the model explanation.
|
||||
"model": "acme/reasoner",
|
||||
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
|
||||
"skills": [/* Keep the source explanation. */ "remove",],
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
delete draft.shell
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 1)
|
||||
draft.skills = []
|
||||
})
|
||||
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
;["__proto__", "constructor", "toString"].forEach((key) => {
|
||||
delete draft[key]
|
||||
})
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves and edits object-valued __proto__ source keys", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
|
||||
const entry: unknown = draft["__proto__"]
|
||||
if (!isRecord(entry)) throw new Error("Missing fixture entry")
|
||||
entry.value = "after"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "first", "shell": "second" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "after"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rereads the selected file for consecutive edits without a watcher", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "shell": "first" }')
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "second"
|
||||
})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("second")
|
||||
draft.username = "added"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
|
||||
|
||||
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("external")
|
||||
draft.snapshots = false
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent read-modify-write calls", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "count": 0 }')
|
||||
const increment = ConfigFile.update(target, (draft) => {
|
||||
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
|
||||
draft.count++
|
||||
})
|
||||
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
|
||||
|
||||
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not rewrite no-op or structurally equal edits", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{\r\n "plugins": ["first"]\r\n}'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const before = yield* fs.stat(target)
|
||||
yield* ConfigFile.update(target, () => {})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.plugins = ["first"]
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
|
||||
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "recovered"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores callback return values instead of replacing the document", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, "{}")
|
||||
|
||||
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
|
||||
expect(yield* fs.readFileString(target)).toBe("{}")
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
|
||||
expect(updated).toEqual({ shell: "updated" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects non-JSON mutations before writing", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.invalid = Number.NaN
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
;["", "{", "[]", "null"].forEach((text) => {
|
||||
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, () => {
|
||||
throw new Error("Callback must not run")
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Invalid config file: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("reports a missing target without creating it", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "missing.json")
|
||||
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to read config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.exists(target)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports write failures without replacing the target", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* fs.makeDirectory(target + ".tmp")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to write config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -2,15 +2,13 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
@@ -20,7 +18,7 @@ import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Effect, Fiber, Layer, Logger, Schedule, Stream } from "effect"
|
||||
import { Effect, Fiber, Logger, Stream } from "effect"
|
||||
import { Database } from "../../src/database/database"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
@@ -37,40 +35,6 @@ const staticIt = testEffect(
|
||||
[Global.node, tempGlobalLayer],
|
||||
]),
|
||||
)
|
||||
const refreshNpm = makeGlobalNode({
|
||||
service: Npm.Service,
|
||||
layer: Layer.effect(
|
||||
Npm.Service,
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const installed = { directory, entrypoint: pathToFileURL(path.join(directory, "index.js")).href }
|
||||
return Npm.Service.of({
|
||||
add: (_pkg, options) =>
|
||||
options?.refresh
|
||||
? Effect.gen(function* () {
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-requested"), ""))
|
||||
yield* waitForFile(path.join(directory, "refresh-release")).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "refresh-finished"), ""))
|
||||
return installed
|
||||
})
|
||||
: Effect.succeed(installed),
|
||||
resolve: () => Effect.succeed(installed),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}),
|
||||
),
|
||||
deps: [Global.node],
|
||||
})
|
||||
const refreshIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Npm.node, refreshNpm],
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
describe("PluginSupervisor config", () => {
|
||||
it.live("applies selectors in order", () =>
|
||||
@@ -87,6 +51,7 @@ describe("PluginSupervisor config", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("allows the built-in Plan agent to be disabled", () =>
|
||||
withLocation(
|
||||
{ agents: { plan: { disabled: true } } },
|
||||
@@ -305,7 +270,7 @@ describe("PluginSupervisor config", () => {
|
||||
staticIt.live("uses only internal and SDK plugins when the static source is wired", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "static-sdk", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{ plugins: ["-*", path.join(import.meta.dir, "../plugin/fixtures/config-promise-plugin.ts")] },
|
||||
Effect.gen(function* () {
|
||||
@@ -415,7 +380,7 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("loads user plugins before internal post plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "sdk-order", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
{
|
||||
plugins: [
|
||||
@@ -466,8 +431,8 @@ describe("PluginSupervisor config", () => {
|
||||
it.live("unblocks flush when plugin activation fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* SdkPlugins.Service
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* sdk.register(EffectPlugin.define({ id: "duplicate-id", effect: () => Effect.void }))
|
||||
yield* withLocation(
|
||||
undefined,
|
||||
Effect.gen(function* () {
|
||||
@@ -476,47 +441,6 @@ describe("PluginSupervisor config", () => {
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
refreshIt.live("refreshes active package plugins after setup without blocking flush", () =>
|
||||
Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
const directory = path.join(global.tmp, "background-refresh-plugin")
|
||||
const activated = path.join(directory, "activated")
|
||||
const release = path.join(directory, "release")
|
||||
const refreshed = path.join(directory, "refresh-requested")
|
||||
const refreshRelease = path.join(directory, "refresh-release")
|
||||
const refreshFinished = path.join(directory, "refresh-finished")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(directory, "index.js"),
|
||||
`export default {
|
||||
id: "background-refresh-plugin",
|
||||
async setup() {
|
||||
await Bun.write(${JSON.stringify(activated)}, "")
|
||||
while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10)
|
||||
},
|
||||
}`,
|
||||
)
|
||||
})
|
||||
|
||||
yield* withLocation(
|
||||
{ plugins: ["background-refresh-plugin"] },
|
||||
Effect.gen(function* () {
|
||||
yield* waitForFile(activated)
|
||||
yield* Effect.sleep("100 millis")
|
||||
expect(yield* Effect.promise(() => Bun.file(refreshed).exists())).toBeFalse()
|
||||
yield* Effect.promise(() => Bun.write(release, ""))
|
||||
yield* waitForFile(refreshed)
|
||||
yield* ready().pipe(Effect.timeout("2 seconds"))
|
||||
yield* Effect.promise(() => Bun.write(refreshRelease, ""))
|
||||
yield* waitForFile(refreshFinished)
|
||||
const plugins = yield* Plugin.Service
|
||||
expect((yield* plugins.list()).map((plugin) => String(plugin.id))).toContain("background-refresh-plugin")
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const ready = Effect.fnUntraced(function* () {
|
||||
@@ -524,20 +448,16 @@ const ready = Effect.fnUntraced(function* () {
|
||||
yield* supervisor.flush
|
||||
})
|
||||
|
||||
const waitForFile = (file: string) =>
|
||||
Effect.promise(() => Bun.file(file).exists()).pipe(
|
||||
Effect.filterOrFail((exists) => exists),
|
||||
Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
|
||||
Effect.timeout("2 seconds"),
|
||||
)
|
||||
|
||||
function withLocation<A, E, R>(
|
||||
config: unknown,
|
||||
effect: Effect.Effect<A, E, R>,
|
||||
fixtures = false,
|
||||
prepare?: (directory: string) => Promise<void>,
|
||||
) {
|
||||
return Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.tap((tmp) =>
|
||||
Effect.promise(async () => {
|
||||
await prepare?.(tmp.path)
|
||||
|
||||
@@ -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 } from "effect"
|
||||
import { Effect, Schema, Stream } 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,7 +12,6 @@ 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"
|
||||
|
||||
@@ -29,6 +28,27 @@ 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", () => {
|
||||
|
||||
@@ -34,41 +34,6 @@ const decode = Schema.decodeUnknownSync(Info)
|
||||
const document = path.join(import.meta.dir, "opencode.json")
|
||||
|
||||
describe("config plugin reloads", () => {
|
||||
it.effect("preserves reference precedence and insertion order across documents", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
const references = yield* Reference.Service
|
||||
const host = yield* PluginHost.make(plugins)
|
||||
yield* references.transform((draft) =>
|
||||
draft.add(
|
||||
"external",
|
||||
Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/references/external") }),
|
||||
),
|
||||
)
|
||||
yield* ConfigReferencePlugin.Plugin.effect(host)
|
||||
|
||||
const result = yield* references.list()
|
||||
expect(result.map((reference) => reference.name)).toEqual(["external", "shared", "first", "second"])
|
||||
expect(result.find((reference) => reference.name === "shared")?.path).toBe(
|
||||
AbsolutePath.make(path.resolve("/config/second/shared")),
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
referenceConfig("/config/first/opencode.json", {
|
||||
shared: "./shared",
|
||||
first: "./first",
|
||||
}),
|
||||
referenceConfig("/config/second/opencode.json", {
|
||||
shared: "./shared",
|
||||
second: "./second",
|
||||
}),
|
||||
]),
|
||||
),
|
||||
Effect.provideService(Global.Service, Global.Service.of(Global.make())),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reloads config-backed domains without reloading external plugins", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
@@ -137,14 +102,6 @@ function config(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
function referenceConfig(file: string, references: Record<string, string>) {
|
||||
return new Document({
|
||||
type: "document",
|
||||
path: AbsolutePath.make(file),
|
||||
info: decode({ references }),
|
||||
})
|
||||
}
|
||||
|
||||
function title(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,18 @@ function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.mkUint8Array(stream).pipe(Effect.map((bytes) => new TextDecoder("utf-8").decode(bytes).trim()))
|
||||
return Stream.runCollect(stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const total = chunks.reduce((acc, x) => acc + x.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(out).trim()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Option, Ref, Scheduler } from "effect"
|
||||
import { StateMachine } from "@opencode-ai/core/effect/state-machine"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
describe("StateMachine", () => {
|
||||
it.effect("runs invoked operations through pure transitions", () => {
|
||||
type Event = { readonly _tag: "Completed"; readonly value: number }
|
||||
type Operation = { readonly _tag: "Work" }
|
||||
const definition = StateMachine.define<"running", Event, Operation, never, number>({
|
||||
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
|
||||
transition: (state, event) => {
|
||||
expect(state).toBe("running")
|
||||
expect(event._tag).toBe("InvocationExited")
|
||||
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done(-1)
|
||||
return StateMachine.done(event.exit.value.value)
|
||||
},
|
||||
})
|
||||
return StateMachine.run(definition, () => Effect.succeed({ _tag: "Completed", value: 42 })).pipe(
|
||||
Effect.map((output) => expect(output).toBe(42)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves the operation Cause", () => {
|
||||
type Operation = { readonly _tag: "Work" }
|
||||
const definition = StateMachine.define<"running", never, Operation, string, Cause.Cause<string>>({
|
||||
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
|
||||
transition: (_, event) => {
|
||||
if (event._tag === "InvocationExited" && Exit.isFailure(event.exit)) return StateMachine.done(event.exit.cause)
|
||||
throw new Error("Expected the invocation to fail")
|
||||
},
|
||||
})
|
||||
return StateMachine.run(definition, () => Effect.fail("boom")).pipe(
|
||||
Effect.map((cause) => {
|
||||
expect(Option.getOrUndefined(Cause.findErrorOption(cause))).toBe("boom")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("settles owned work before propagating interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const finalized = yield* Deferred.make<void>()
|
||||
type State = "running" | "stopping"
|
||||
type Event = { readonly _tag: "Cancel" }
|
||||
type Operation = { readonly _tag: "Work" }
|
||||
const definition = StateMachine.define<State, Event, Operation, never, "cancelled">({
|
||||
initial: StateMachine.next("running", StateMachine.invoke("work", { _tag: "Work" })),
|
||||
interruption: { _tag: "Cancel" } as const,
|
||||
transition: (state, event) => {
|
||||
if (event._tag === "Input") {
|
||||
expect(state).toBe("running")
|
||||
return StateMachine.next("stopping" as const, StateMachine.stop("work"))
|
||||
}
|
||||
expect(state).toBe("stopping")
|
||||
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
|
||||
expect(Exit.hasInterrupts(event.exit)).toBe(true)
|
||||
return StateMachine.done("cancelled" as const)
|
||||
},
|
||||
})
|
||||
const machine = yield* StateMachine.run(definition, () =>
|
||||
Effect.never.pipe(Effect.ensuring(Deferred.succeed(finalized, undefined))),
|
||||
).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
yield* Effect.yieldNow
|
||||
yield* Fiber.interrupt(machine)
|
||||
const exit = yield* Fiber.await(machine)
|
||||
expect(Exit.hasInterrupts(exit)).toBe(true)
|
||||
expect(yield* Deferred.isDone(finalized)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("runs cleanup invocations after interruption", () =>
|
||||
Effect.gen(function* () {
|
||||
const workStarted = yield* Deferred.make<void>()
|
||||
const cleanupRan = yield* Deferred.make<void>()
|
||||
type State = "running" | "stopping" | "cleaning"
|
||||
type Event = { readonly _tag: "Cancel" } | { readonly _tag: "WorkDone" } | { readonly _tag: "CleanupDone" }
|
||||
type Operation = { readonly _tag: "Work" } | { readonly _tag: "Cleanup" }
|
||||
const definition = StateMachine.define<State, Event, Operation, never, void>({
|
||||
initial: StateMachine.next("running", StateMachine.invoke("phase", { _tag: "Work" })),
|
||||
interruption: { _tag: "Cancel" },
|
||||
transition: (state, event) => {
|
||||
if (event._tag === "Input")
|
||||
return StateMachine.next("stopping", StateMachine.stopAndJoin("interruption", ["phase"]))
|
||||
if (state === "stopping") {
|
||||
if (event._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
|
||||
expect(event.id).toBe("interruption")
|
||||
expect(event.exits).toMatchObject([{ id: "phase", operation: { _tag: "Work" } }])
|
||||
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
|
||||
return StateMachine.next("cleaning", StateMachine.invoke("cleanup", { _tag: "Cleanup" }))
|
||||
}
|
||||
if (state === "cleaning") return StateMachine.done(undefined)
|
||||
throw new Error("Unexpected state machine transition")
|
||||
},
|
||||
})
|
||||
const machine = yield* StateMachine.run(definition, (operation) => {
|
||||
if (operation._tag === "Cleanup")
|
||||
return Deferred.succeed(cleanupRan, undefined).pipe(Effect.as({ _tag: "CleanupDone" } as const))
|
||||
return Deferred.succeed(workStarted, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.as({ _tag: "WorkDone" } as const),
|
||||
)
|
||||
}).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
yield* Deferred.await(workStarted)
|
||||
yield* Fiber.interrupt(machine)
|
||||
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
|
||||
expect(yield* Deferred.isDone(cleanupRan)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("stops invocations together and joins cross-dependent finalizers", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
|
||||
const finalizing = { left: yield* Deferred.make<void>(), right: yield* Deferred.make<void>() }
|
||||
const finalized = yield* Ref.make<ReadonlyArray<string>>([])
|
||||
type State = "running" | "stopping" | "verifying"
|
||||
type Event = "ready" | "verified"
|
||||
type Operation = "left" | "right" | "trigger" | "verify"
|
||||
const definition = StateMachine.define<State, Event, Operation, never, boolean>({
|
||||
initial: StateMachine.next(
|
||||
"running",
|
||||
StateMachine.invoke<Operation>("left", "left"),
|
||||
StateMachine.invoke<Operation>("right", "right"),
|
||||
StateMachine.invoke<Operation>("trigger", "trigger"),
|
||||
),
|
||||
transition: (state, event) => {
|
||||
if (event._tag === "InvocationExited" && event.operation === "trigger")
|
||||
return StateMachine.next("stopping", StateMachine.stopAndJoin("workers", ["left", "right"]))
|
||||
if (event._tag === "InvocationsStopped") {
|
||||
expect(state).toBe("stopping")
|
||||
expect(event.id).toBe("workers")
|
||||
expect(event.exits).toMatchObject([
|
||||
{ _tag: "InvocationExited", id: "left", generation: 1, operation: "left" },
|
||||
{ _tag: "InvocationExited", id: "right", generation: 2, operation: "right" },
|
||||
])
|
||||
expect(event.exits.every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
|
||||
return StateMachine.next("verifying", StateMachine.invoke("verify", "verify"))
|
||||
}
|
||||
if (event._tag === "InvocationExited" && event.operation === "verify") {
|
||||
expect(state).toBe("verifying")
|
||||
expect(event.exit).toEqual(Exit.succeed("verified"))
|
||||
return StateMachine.done(true)
|
||||
}
|
||||
throw new Error("Unexpected state machine transition")
|
||||
},
|
||||
})
|
||||
const output = yield* StateMachine.run(definition, (operation) => {
|
||||
if (operation === "trigger")
|
||||
return Deferred.await(started.left).pipe(Effect.andThen(Deferred.await(started.right)), Effect.as("ready"))
|
||||
if (operation === "verify")
|
||||
return Ref.get(finalized).pipe(
|
||||
Effect.map((value) => {
|
||||
expect(value.toSorted()).toEqual(["left", "right"])
|
||||
return "verified" as const
|
||||
}),
|
||||
)
|
||||
return Deferred.succeed(started[operation], undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.ensuring(
|
||||
Deferred.succeed(finalizing[operation], undefined).pipe(
|
||||
Effect.andThen(Deferred.await(finalizing[operation === "left" ? "right" : "left"])),
|
||||
Effect.andThen(Ref.update(finalized, (value) => [...value, operation])),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
expect(output).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("aggregates queued and never-started exits once without affecting reused keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const completed = yield* Deferred.make<Fiber.Fiber<unknown, unknown>>()
|
||||
const releaseCompleted = yield* Deferred.make<void>()
|
||||
const gateStarted = yield* Deferred.make<void>()
|
||||
const childStarted = yield* Deferred.make<void>()
|
||||
type Event = "completed" | "triggered" | "replaced"
|
||||
type Operation = "complete" | "gate" | "trigger" | "never-started" | "replacement"
|
||||
type Seen = ReadonlyArray<StateMachine.RuntimeEvent<Event, Operation, never>>
|
||||
const definition = StateMachine.define<Seen, Event, Operation, never, Seen>({
|
||||
initial: StateMachine.next(
|
||||
[],
|
||||
StateMachine.invoke<Operation>("completed", "complete"),
|
||||
StateMachine.invoke<Operation>("gate", "gate"),
|
||||
StateMachine.invoke<Operation>("trigger", "trigger"),
|
||||
),
|
||||
transition: (state, event) => {
|
||||
const seen = [...state, event]
|
||||
if (event._tag === "InvocationExited" && event.operation === "trigger")
|
||||
return StateMachine.next(
|
||||
seen,
|
||||
StateMachine.stop("gate"),
|
||||
StateMachine.invoke<Operation>("child", "never-started"),
|
||||
StateMachine.stopAndJoin("batch", ["completed", "gate", "child"]),
|
||||
StateMachine.invoke<Operation>("completed", "replacement"),
|
||||
StateMachine.invoke<Operation>("child", "replacement"),
|
||||
)
|
||||
return seen.length === 4 ? StateMachine.done(seen) : StateMachine.next(seen)
|
||||
},
|
||||
})
|
||||
const seen = yield* StateMachine.run(definition, (operation) => {
|
||||
if (operation === "complete")
|
||||
return Effect.withFiber((fiber) => Deferred.succeed(completed, fiber)).pipe(
|
||||
Effect.andThen(Deferred.await(releaseCompleted)),
|
||||
Effect.as("completed"),
|
||||
)
|
||||
if (operation === "gate")
|
||||
return Deferred.succeed(gateStarted, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
// Hold the command loop until the completed child's exit is queued.
|
||||
Effect.ensuring(
|
||||
Deferred.succeed(releaseCompleted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(completed)),
|
||||
Effect.flatMap(Fiber.await),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (operation === "trigger")
|
||||
return Deferred.await(completed).pipe(Effect.andThen(Deferred.await(gateStarted)), Effect.as("triggered"))
|
||||
if (operation === "never-started")
|
||||
return Deferred.succeed(childStarted, undefined).pipe(Effect.andThen(Effect.never))
|
||||
return Effect.succeed("replaced")
|
||||
}).pipe(
|
||||
// Keep the adjacent invoke/stop commands in one scheduler slice.
|
||||
Effect.provideService(Scheduler.PreventSchedulerYield, true),
|
||||
)
|
||||
|
||||
expect(seen.map((event) => (event._tag === "InvocationExited" ? event.operation : event._tag))).toEqual([
|
||||
"trigger",
|
||||
"InvocationsStopped",
|
||||
"replacement",
|
||||
"replacement",
|
||||
])
|
||||
const stopped = seen[1]
|
||||
if (stopped._tag !== "InvocationsStopped") throw new Error("Expected the aggregate stop result")
|
||||
expect(stopped.id).toBe("batch")
|
||||
expect(stopped.exits).toMatchObject([
|
||||
{
|
||||
_tag: "InvocationExited",
|
||||
id: "completed",
|
||||
generation: 1,
|
||||
operation: "complete",
|
||||
exit: Exit.succeed("completed"),
|
||||
},
|
||||
{ _tag: "InvocationExited", id: "gate", generation: 2, operation: "gate" },
|
||||
{ _tag: "InvocationExited", id: "child", generation: 4, operation: "never-started" },
|
||||
])
|
||||
expect(stopped.exits.slice(1).every((invocation) => Exit.hasInterrupts(invocation.exit))).toBe(true)
|
||||
expect(seen.slice(2)).toMatchObject([
|
||||
{ _tag: "InvocationExited", id: "completed", generation: 5, exit: Exit.succeed("replaced") },
|
||||
{ _tag: "InvocationExited", id: "child", generation: 6, exit: Exit.succeed("replaced") },
|
||||
])
|
||||
expect(yield* Deferred.isDone(childStarted)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits an empty aggregate for an empty stop batch", () => {
|
||||
const definition = StateMachine.define<"stopping", never, never, never, boolean>({
|
||||
initial: StateMachine.next("stopping", StateMachine.stopAndJoin("empty", [])),
|
||||
transition: (_, event) => {
|
||||
expect(event).toEqual({ _tag: "InvocationsStopped", id: "empty", exits: [] })
|
||||
return StateMachine.done(true)
|
||||
},
|
||||
})
|
||||
return StateMachine.run(definition, () => Effect.die("Unexpected operation")).pipe(
|
||||
Effect.map((output) => expect(output).toBe(true)),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("awaits a never-started finalizer without interrupting it", () =>
|
||||
Effect.gen(function* () {
|
||||
const finalized = yield* Ref.make(0)
|
||||
type Operation = "work" | "finalize"
|
||||
const definition = StateMachine.define<"stopping", "finalized", Operation, never, boolean>({
|
||||
initial: StateMachine.next(
|
||||
"stopping",
|
||||
StateMachine.invoke<Operation>("work", "work"),
|
||||
StateMachine.invoke<Operation>("finalizer", "finalize"),
|
||||
StateMachine.stopAndJoin("batch", ["work"], ["finalizer"]),
|
||||
),
|
||||
transition: (_, event) => {
|
||||
if (event._tag !== "InvocationsStopped") throw new Error("Expected only the joined batch")
|
||||
expect(event.exits).toHaveLength(2)
|
||||
expect(event.exits[0].id).toBe("work")
|
||||
expect(Exit.hasInterrupts(event.exits[0].exit)).toBe(true)
|
||||
expect(event.exits[1]).toMatchObject({ id: "finalizer", exit: Exit.succeed("finalized") })
|
||||
return StateMachine.done(true)
|
||||
},
|
||||
})
|
||||
expect(
|
||||
yield* StateMachine.run(definition, (operation) =>
|
||||
operation === "work"
|
||||
? Effect.never
|
||||
: Ref.update(finalized, (count) => count + 1).pipe(Effect.as("finalized" as const)),
|
||||
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true)),
|
||||
).toBe(true)
|
||||
expect(yield* Ref.get(finalized)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defects when a stop batch contains an unknown invocation", () =>
|
||||
Effect.gen(function* () {
|
||||
const definition = StateMachine.define<"stopping", never, "work", never, never>({
|
||||
initial: StateMachine.next(
|
||||
"stopping",
|
||||
StateMachine.invoke("known", "work"),
|
||||
StateMachine.stopAndJoin("batch", ["known", "unknown"]),
|
||||
),
|
||||
transition: () => {
|
||||
throw new Error("Unexpected state machine transition")
|
||||
},
|
||||
})
|
||||
const exit = yield* StateMachine.run(definition, () => Effect.never).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) throw new Error("Expected an unknown invocation defect")
|
||||
expect(Cause.hasDies(exit.cause)).toBe(true)
|
||||
expect(Cause.prettyErrors(exit.cause).map((error) => error.message)).toEqual([
|
||||
"Unknown state machine invocation in StopAndJoin",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("observes an individual exit when a deferred child is stopped before starting", () =>
|
||||
Effect.gen(function* () {
|
||||
const started = yield* Deferred.make<void>()
|
||||
const definition = StateMachine.define<"stopping", never, "work", never, boolean>({
|
||||
initial: StateMachine.next("stopping", StateMachine.invoke("work", "work"), StateMachine.stop("work")),
|
||||
transition: (_, event) => {
|
||||
if (event._tag !== "InvocationExited") throw new Error("Expected the invocation to stop")
|
||||
expect(event.id).toBe("work")
|
||||
expect(Exit.hasInterrupts(event.exit)).toBe(true)
|
||||
return StateMachine.done(true)
|
||||
},
|
||||
})
|
||||
const output = yield* StateMachine.run(definition, () =>
|
||||
Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
|
||||
).pipe(Effect.provideService(Scheduler.PreventSchedulerYield, true))
|
||||
expect(output).toBe(true)
|
||||
expect(yield* Deferred.isDone(started)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for replaced invocation cleanup and ignores its stale exit", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstStarted = yield* Deferred.make<void>()
|
||||
const releaseTrigger = yield* Deferred.make<void>()
|
||||
const events = yield* Ref.make<ReadonlyArray<string>>([])
|
||||
type State = "first" | "second"
|
||||
type Event = { readonly _tag: "Triggered" } | { readonly _tag: "SecondDone" }
|
||||
type Operation = { readonly _tag: "First" } | { readonly _tag: "Trigger" } | { readonly _tag: "Second" }
|
||||
const definition = StateMachine.define<State, Event, Operation, never, string>({
|
||||
initial: StateMachine.next(
|
||||
"first",
|
||||
StateMachine.invoke<Operation>("work", { _tag: "First" }),
|
||||
StateMachine.invoke<Operation>("trigger", { _tag: "Trigger" }),
|
||||
),
|
||||
transition: (state, event) => {
|
||||
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
|
||||
if (event.operation._tag === "Trigger") {
|
||||
return StateMachine.next("second" as const, StateMachine.invoke("work", { _tag: "Second" } as const))
|
||||
}
|
||||
if (state === "second") return StateMachine.done(event.exit.value._tag)
|
||||
return StateMachine.next(state)
|
||||
},
|
||||
})
|
||||
const output = yield* StateMachine.run(definition, (operation) => {
|
||||
if (operation._tag === "Trigger")
|
||||
return Deferred.await(releaseTrigger).pipe(Effect.as({ _tag: "Triggered" } as const))
|
||||
if (operation._tag === "Second") {
|
||||
return Ref.update(events, (value) => [...value, "second started"]).pipe(
|
||||
Effect.as({ _tag: "SecondDone" } as const),
|
||||
)
|
||||
}
|
||||
return Deferred.succeed(firstStarted, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.ensuring(Ref.update(events, (value) => [...value, "first finalized"])),
|
||||
)
|
||||
}).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
yield* Deferred.await(firstStarted)
|
||||
yield* Deferred.succeed(releaseTrigger, undefined)
|
||||
expect(yield* Fiber.join(output)).toBe("SecondDone")
|
||||
expect(yield* Ref.get(events)).toEqual(["first finalized", "second started"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not start the next invocation when interruption is pending at the transition boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const releaseFirst = yield* Deferred.make<void>()
|
||||
const secondStarted = yield* Deferred.make<void>()
|
||||
type State = "first" | "second"
|
||||
type Event = { readonly _tag: "FirstDone" } | { readonly _tag: "SecondDone" }
|
||||
type Operation = { readonly _tag: "First" } | { readonly _tag: "Second" }
|
||||
let machine: Fiber.Fiber<string> | undefined
|
||||
const definition = StateMachine.define<State, Event, Operation, never, string>({
|
||||
initial: StateMachine.next("first", StateMachine.invoke("work", { _tag: "First" })),
|
||||
transition: (state, event) => {
|
||||
if (event._tag !== "InvocationExited" || Exit.isFailure(event.exit)) return StateMachine.done("unexpected")
|
||||
if (state === "second") return StateMachine.done("completed")
|
||||
machine?.interruptUnsafe(123)
|
||||
return StateMachine.next("second", StateMachine.invoke("work", { _tag: "Second" }))
|
||||
},
|
||||
})
|
||||
machine = yield* StateMachine.run(definition, (operation) =>
|
||||
operation._tag === "First"
|
||||
? Deferred.await(releaseFirst).pipe(Effect.as({ _tag: "FirstDone" } as const))
|
||||
: Deferred.succeed(secondStarted, undefined).pipe(Effect.as({ _tag: "SecondDone" } as const)),
|
||||
).pipe(Effect.forkChild({ startImmediately: true }))
|
||||
|
||||
yield* Deferred.succeed(releaseFirst, undefined)
|
||||
expect(Exit.hasInterrupts(yield* Fiber.await(machine))).toBe(true)
|
||||
expect(yield* Deferred.isDone(secondStarted)).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, spyOn } from "bun:test"
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
@@ -14,8 +14,6 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { location } from "../fixture/location"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void) =>
|
||||
Layer.succeed(
|
||||
@@ -34,13 +32,14 @@ const ripgrepStub = (entry: string, onFind: (input: Ripgrep.FindInput) => void)
|
||||
)
|
||||
|
||||
describe("FileSystemSearch", () => {
|
||||
it.live("honors wildcard directory rules from .gitignore", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-fff-ignore-")))).path
|
||||
yield* Effect.promise(() => mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, ".gitignore"), "**/target/\n"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored"))
|
||||
expect(Bun.spawnSync(["git", "init", "-q"], { cwd: directory }).exitCode).toBe(0)
|
||||
test("honors wildcard directory rules from .gitignore", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-fff-ignore-"))
|
||||
try {
|
||||
await mkdir(path.join(directory, "rust/target/debug/deps"), { recursive: true })
|
||||
await Bun.write(path.join(directory, ".gitignore"), "**/target/\n")
|
||||
await Bun.write(path.join(directory, "rust/target/debug/deps/ignored.rs"), "ignored")
|
||||
const git = Bun.spawnSync(["git", "init", "-q"], { cwd: directory })
|
||||
expect(git.exitCode).toBe(0)
|
||||
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
|
||||
const layer = FileSystemSearch.fffLayer.pipe(
|
||||
@@ -55,22 +54,26 @@ describe("FileSystemSearch", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "target" })
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
const entries = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
return yield* search.find({ query: "target" })
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-search-workspace-"))))
|
||||
.path
|
||||
expect(entries.every((entry) => !entry.path.startsWith("rust/target/"))).toBe(true)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("selects the ripgrep layer for workspace-backed locations even when vcs would pick fff", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "opencode-search-workspace-"))
|
||||
try {
|
||||
// A local file that only an fff index of the server directory could surface.
|
||||
// The fff-vs-ripgrep discrimination only bites where Fff.available() is
|
||||
// true; elsewhere the layer choice already falls back to ripgrep.
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory, "server-local.ts"), "server local"))
|
||||
await Bun.write(path.join(directory, "server-local.ts"), "server local")
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(directory),
|
||||
@@ -89,35 +92,37 @@ describe("FileSystemSearch", () => {
|
||||
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
const entries = yield* search.find({ query: "ts", type: "file" })
|
||||
expect(observed?.cwd).toBe(directory)
|
||||
expect(entries.map((entry) => entry.path)).toEqual([RelativePath.make("remote.ts")])
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.live("bounds a home scan even when home is detected as a repository", () =>
|
||||
Effect.gen(function* () {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: home },
|
||||
{ vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } },
|
||||
),
|
||||
),
|
||||
test("bounds a home scan even when home is detected as a repository", async () => {
|
||||
let observed: Ripgrep.FindInput | undefined
|
||||
const home = AbsolutePath.make(os.homedir())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: home }, { vcs: { type: "git", store: AbsolutePath.make(path.join(home, ".git")) } }),
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
yield* Effect.gen(function* () {
|
||||
),
|
||||
],
|
||||
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
|
||||
])
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* Effect.sleep("10 millis")
|
||||
expect(observed).toBeUndefined()
|
||||
@@ -127,52 +132,52 @@ describe("FileSystemSearch", () => {
|
||||
expect((yield* search.find({ query: "src", type: "directory" }))[0]?.path).toBe(
|
||||
RelativePath.make(`src${path.sep}`),
|
||||
)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("refreshes a stale ripgrep index atomically without blocking search", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const started = yield* Deferred.make<void>()
|
||||
const release = yield* Deferred.make<void>()
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
test("refreshes a stale ripgrep index atomically without blocking search", async () => {
|
||||
let scans = 0
|
||||
const started = Effect.runSync(Deferred.make<void>())
|
||||
const release = Effect.runSync(Deferred.make<void>())
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
if (scans > 1) {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
yield* Deferred.await(release)
|
||||
}
|
||||
const entry = FileSystem.Entry.make({
|
||||
path: RelativePath.make(scans === 1 ? "src/old.ts" : "src/new.ts"),
|
||||
type: "file",
|
||||
})
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "old", type: "file" })
|
||||
expect((yield* search.find({ query: "old", type: "file" }))[0]?.path).toBe(RelativePath.make("src/old.ts"))
|
||||
@@ -191,53 +196,47 @@ describe("FileSystemSearch", () => {
|
||||
}).pipe(Effect.repeat({ until: (entries) => entries.length > 0 }))
|
||||
expect(refreshed[0]?.path).toBe(RelativePath.make("src/new.ts"))
|
||||
expect(scans).toBe(2)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("reuses location-owned fuzzy targets across index refreshes", () =>
|
||||
Effect.gen(function* () {
|
||||
let scans = 0
|
||||
const second = yield* Deferred.make<void>()
|
||||
const prepare = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "prepare")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const cleanup = yield* Effect.acquireRelease(
|
||||
Effect.sync(() => spyOn(fuzzysort, "cleanup")),
|
||||
(value) => Effect.sync(() => value.mockRestore()),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
test("reuses location-owned fuzzy targets across index refreshes", async () => {
|
||||
let scans = 0
|
||||
const second = Effect.runSync(Deferred.make<void>())
|
||||
const prepare = spyOn(fuzzysort, "prepare")
|
||||
const cleanup = spyOn(fuzzysort, "cleanup")
|
||||
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
),
|
||||
],
|
||||
[
|
||||
Ripgrep.node,
|
||||
Layer.succeed(
|
||||
Ripgrep.Service,
|
||||
Ripgrep.Service.of({
|
||||
find: (input) =>
|
||||
Effect.gen(function* () {
|
||||
scans++
|
||||
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
|
||||
if (input.onEntry) yield* input.onEntry(entry)
|
||||
if (scans > 1) yield* Deferred.succeed(second, undefined)
|
||||
return [entry]
|
||||
}),
|
||||
glob: () => Effect.succeed([]),
|
||||
grep: () => Effect.succeed([]),
|
||||
}),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const search = yield* FileSystemSearch.Service
|
||||
yield* search.find({ query: "index", type: "file" })
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
@@ -247,7 +246,9 @@ describe("FileSystemSearch", () => {
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(2)
|
||||
expect(cleanup).toHaveBeenCalledTimes(3)
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
|
||||
)
|
||||
prepare.mockRestore()
|
||||
cleanup.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
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)
|
||||
|
||||
@@ -32,30 +29,6 @@ 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")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
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}`)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? { prompts: [{ name: "second", description: "Second prompt" }] }
|
||||
: {
|
||||
prompts: [
|
||||
{
|
||||
name: "first",
|
||||
description: "First prompt",
|
||||
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
|
||||
}),
|
||||
)
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -6,7 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -75,6 +75,30 @@ 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* () {
|
||||
@@ -85,7 +109,9 @@ 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 })))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
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 } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } 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 = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
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 = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
@@ -32,7 +32,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -61,7 +61,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts from Uint8Array", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -90,7 +90,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle URL-based images", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -117,7 +117,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple text parts without flattening", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -141,7 +141,7 @@ describe("user messages", () => {
|
||||
|
||||
describe("assistant messages", () => {
|
||||
test("should convert assistant text messages", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
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 = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -195,7 +195,7 @@ describe("assistant messages", () => {
|
||||
})
|
||||
|
||||
test("should concatenate multiple text parts", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -211,7 +211,7 @@ describe("assistant messages", () => {
|
||||
|
||||
describe("tool calls", () => {
|
||||
test("should stringify arguments to tool calls", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -262,7 +262,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text output type in tool results", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -286,7 +286,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple tool results as separate messages", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -320,7 +320,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text plus multiple tool calls", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -373,7 +373,7 @@ describe("tool calls", () => {
|
||||
|
||||
describe("reasoning (copilot-specific)", () => {
|
||||
test("should omit reasoning_text without reasoning_opaque", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -395,7 +395,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from providerOptions", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -423,7 +423,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from text part providerOptions", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -450,7 +450,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should handle reasoning-only assistant message", () => {
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
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 = convertToOpenAICompatibleChatMessages([
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
|
||||
@@ -186,7 +186,7 @@ describe("Integration", () => {
|
||||
value: Credential.Key.make({ type: "key", key: "secret", configuration: { accountId: "account" } }),
|
||||
}),
|
||||
])
|
||||
expect((yield* Fiber.join(created)).map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
expect(Array.from(yield* Fiber.join(created), (event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { credentialID: stored[0]?.id, integrationID } },
|
||||
])
|
||||
|
||||
@@ -49,29 +49,6 @@ export const environmentConformance = <E>(
|
||||
}),
|
||||
)
|
||||
|
||||
check("observes filesystem state when an operation executes", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/deferred.txt`
|
||||
const source = `${harness.root}/source.txt`
|
||||
const destination = `${harness.root}/destination.txt`
|
||||
const read = harness.files.read(target)
|
||||
const stat = harness.files.stat(target)
|
||||
const list = harness.files.list(harness.root)
|
||||
const move = harness.files.move(source, destination)
|
||||
|
||||
yield* harness.files.write(target, bytes("first"))
|
||||
yield* harness.files.write(source, bytes("moved"))
|
||||
expect(text((yield* read).bytes)).toBe("first")
|
||||
expect((yield* stat).size).toBe(5)
|
||||
expect(yield* list).toContainEqual({ name: "deferred.txt", type: "file" })
|
||||
yield* move
|
||||
expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
|
||||
|
||||
yield* harness.files.write(target, bytes("second"))
|
||||
expect(text((yield* read).bytes)).toBe("second")
|
||||
}),
|
||||
)
|
||||
|
||||
check("reports missing paths", (harness) =>
|
||||
Effect.gen(function* () {
|
||||
const target = `${harness.root}/missing`
|
||||
|
||||
@@ -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 } from "./lib/tool"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Reference } from "../src/reference"
|
||||
@@ -701,9 +701,25 @@ 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),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { 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"
|
||||
@@ -28,96 +27,6 @@ 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>()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user