mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-21 20:50:09 +08:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 570cb13c19 | |||
| da57b27277 | |||
| 42a3fec594 | |||
| d15034264b | |||
| 1ce3c7e580 | |||
| 07f27c4eca | |||
| 196893cfeb | |||
| f6992059be | |||
| 732f949a65 | |||
| cd3b12c579 | |||
| 0d6232ffef | |||
| 6da20f0efe | |||
| 8e25e83e5a | |||
| fe188f8722 | |||
| e4bc8b765b | |||
| d28b6e9ac2 | |||
| ac3cd1b183 | |||
| c601d3b021 | |||
| 9bc2165e5c | |||
| 374d317412 | |||
| e50c89834e | |||
| f367c202d9 | |||
| 9d33d83bb4 | |||
| 8f1eff50aa | |||
| e7918e25fd | |||
| 1eff84615c | |||
| f1fd6ba3c0 | |||
| 39daec9bec | |||
| 484f5faf8d | |||
| 9a227d186f | |||
| fc27061838 | |||
| d71cc3be77 | |||
| 503e680672 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Correct directory page headings when the read offset is zero.
|
||||
@@ -1000,33 +1000,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
// Reasoning is one response-wide channel: it stays open alongside text and
|
||||
// refusal output so late reasoning deltas and details join the same block,
|
||||
// and `finishEvents` closes it once with the complete metadata.
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
if (delta?.refusal) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
reasoningField,
|
||||
reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
),
|
||||
)
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
}
|
||||
if (delta?.refusal) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal)
|
||||
|
||||
// Compatible providers may omit indexes. Prefer durable identity, then use
|
||||
// batch position for parallel deltas or the latest call for sparse chunks.
|
||||
@@ -1132,10 +1111,12 @@ const finishEvents = Effect.fn("OpenAIChat.finishEvents")(function* (state: Pars
|
||||
state.finishReason.normalized === "stop" && hasToolCalls ? "tool-calls" : state.finishReason.normalized,
|
||||
}
|
||||
: { normalized: hasToolCalls ? ("tool-calls" as const) : ("stop" as const) }
|
||||
// Snapshot details at publish time so the emitted event never observes later
|
||||
// mutation of the accumulated `reasoningDetails` array.
|
||||
const metadata = reasoningMetadata(
|
||||
state.providerMetadataKey,
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
LanguageModel,
|
||||
ToolCallPart,
|
||||
@@ -1149,7 +1150,7 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves scalar reasoning after content starts", () =>
|
||||
it.effect("preserves scalar reasoning after content starts in one lifecycle", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1166,8 +1167,35 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detailscalar")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(2)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps one reasoning lifecycle across many content chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
...Array.from({ length: 25 }, (_, index) => deltaChunk({ content: `chunk-${index} ` })),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe(Array.from({ length: 25 }, (_, index) => `chunk-${index} `).join(""))
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
@@ -1213,7 +1241,18 @@ describe("OpenAI Chat route", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
// Snapshot reasoning-end metadata as each event is published so the
|
||||
// assertion cannot pass through later mutation of a shared array.
|
||||
const publishedEndMetadata: unknown[] = []
|
||||
const response = yield* LLMClient.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (LLMEvent.is.reasoningEnd(event))
|
||||
publishedEndMetadata.push(decodeJson(encodeJson(event.providerMetadata)))
|
||||
}),
|
||||
),
|
||||
Stream.runFold(LLMResponse.empty, LLMResponse.reduce),
|
||||
Effect.map((state) => LLMResponse.complete(state)!),
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
@@ -1234,10 +1273,12 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
expect(publishedEndMetadata).toEqual([{ openai: { reasoningField: "reasoning", reasoningDetails: merged } }])
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningStart)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
// Reasoning stays open alongside text and closes once during finalization.
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeGreaterThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +80,8 @@ export function createSessionRequestModel() {
|
||||
if (message.type !== "synthetic") return []
|
||||
if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string")
|
||||
return [message.metadata.childID]
|
||||
if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string")
|
||||
return [message.metadata.jobID]
|
||||
if (message.metadata?.source === "shell")
|
||||
return [message.metadata.shellID, message.metadata.jobID].filter((id): id is string => typeof id === "string")
|
||||
return []
|
||||
}),
|
||||
)
|
||||
@@ -121,6 +121,7 @@ export function createSessionRequestModel() {
|
||||
if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return []
|
||||
if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return []
|
||||
const shellID = part.state.metadata.shellID
|
||||
if (typeof shellID === "string" && completed.has(shellID)) return []
|
||||
const command = part.state.input.command
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionInboxInfo,
|
||||
SessionInboxCompaction,
|
||||
ShellInfo,
|
||||
SkillInfo,
|
||||
VcsInfo,
|
||||
@@ -284,12 +285,11 @@ export function createData(config: CreateDataInput) {
|
||||
setStore("session", "pending", sessionID, index, { ...item, delivery })
|
||||
}
|
||||
|
||||
// Inbox IDs of optimistic prompt admissions still awaiting their durable
|
||||
// echo. This is the one deliberate piece of in-flight bookkeeping in this
|
||||
// layer: it exists so a rejection only rolls back rows the server never
|
||||
// acknowledged, and so a concurrent pending re-fetch cannot wipe a row the
|
||||
// server does not know about yet. Entries clear on the enqueued echo or on
|
||||
// rollback — not on POST success, which typically precedes the echo.
|
||||
// Inbox IDs of optimistic admissions awaiting acknowledgement, so rejection
|
||||
// only rolls back unacknowledged rows and a pending re-fetch cannot wipe a
|
||||
// row the server does not know about yet. Prompts clear on their durable
|
||||
// echo, positive pending read, or rollback; compactions also reconcile the
|
||||
// POST's canonical ID.
|
||||
const outbox = new Set<string>()
|
||||
|
||||
// Session IDs of optimistic create admissions still awaiting acknowledgement
|
||||
@@ -303,11 +303,13 @@ export function createData(config: CreateDataInput) {
|
||||
// to exist server-side instead of failing with "not found".
|
||||
const creating = new Map<string, Promise<unknown>>()
|
||||
|
||||
// Per-session send chain: prompts must be admitted in submission order,
|
||||
// and HTTP gives no ordering across concurrent POSTs. Each prompt waits
|
||||
// for the previous prompt's POST (settled, so one failure does not block
|
||||
// the next) before sending its own.
|
||||
// Per-session send chain: prompts and compactions must be admitted in
|
||||
// submission order. Each waits for the previous POST to settle, so one
|
||||
// failure does not block the next.
|
||||
const sending = new Map<string, Promise<unknown>>()
|
||||
const messageLoads = new Map<string, Promise<unknown>>()
|
||||
const compacting = new Map<string, { id: string; observed: Set<string>; request: Promise<SessionInboxCompaction> }>()
|
||||
onCleanup(() => compacting.clear())
|
||||
|
||||
// Register `promise` under `key` until it settles. A later registration
|
||||
// replaces an earlier one; settlement only clears its own entry.
|
||||
@@ -319,9 +321,24 @@ export function createData(config: CreateDataInput) {
|
||||
void promise.then(settle, settle)
|
||||
}
|
||||
|
||||
// Capture creation before settlement clears its entry, so dependent RPCs still see a failed create.
|
||||
function sendAdmission<Value>(sessionID: string, send: () => Promise<Value>, gate?: Promise<unknown>) {
|
||||
const created = creating.get(sessionID)
|
||||
const previous = sending.get(sessionID)
|
||||
const request = Promise.resolve()
|
||||
.then(() => Promise.all([gate, created, previous]))
|
||||
.then(send)
|
||||
track(
|
||||
sending,
|
||||
sessionID,
|
||||
request.catch(() => undefined),
|
||||
)
|
||||
return request
|
||||
}
|
||||
|
||||
// Upsert an admitted inbox item into pending, input, and (for user and
|
||||
// synthetic items) the visible transcript. Used by the inbox.enqueued
|
||||
// handler and by optimistic prompt admission; the upsert is what reconciles
|
||||
// handler and by optimistic admission; the upsert is what reconciles
|
||||
// the durable echo with an optimistic placeholder — the durable payload and
|
||||
// times replace the client's guess.
|
||||
function admitLocal(item: SessionInboxInfo) {
|
||||
@@ -334,6 +351,7 @@ export function createData(config: CreateDataInput) {
|
||||
item.sessionID,
|
||||
at < 0 ? [...pending, item] : pending.map((entry, index) => (index === at ? item : entry)),
|
||||
)
|
||||
if (item.type === "compaction") return
|
||||
const input = store.session.input[item.sessionID] ?? []
|
||||
if (!input.includes(item.id)) setStore("session", "input", item.sessionID, [...input, item.id])
|
||||
materializeInboxMessage(item)
|
||||
@@ -668,6 +686,7 @@ export function createData(config: CreateDataInput) {
|
||||
draft.push(existing)
|
||||
message.reindex(draft, index, position)
|
||||
})
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.delivery.changed":
|
||||
@@ -675,6 +694,7 @@ export function createData(config: CreateDataInput) {
|
||||
return
|
||||
case "session.inbox.cancelled": {
|
||||
retractLocal(event.data.sessionID, event.data.inboxID)
|
||||
compacting.get(event.data.sessionID)?.observed.add(event.data.inboxID)
|
||||
return
|
||||
}
|
||||
case "session.inbox.enqueued": {
|
||||
@@ -685,6 +705,12 @@ export function createData(config: CreateDataInput) {
|
||||
timeCreated: event.created,
|
||||
...event.data.item,
|
||||
})
|
||||
if (event.data.item.type === "compaction") {
|
||||
const active = compacting.get(event.data.sessionID)
|
||||
active?.observed.add(event.data.inboxID)
|
||||
if (active && active.id !== event.data.inboxID && outbox.delete(active.id))
|
||||
removePending(event.data.sessionID, active.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
case "session.instructions.updated":
|
||||
@@ -983,6 +1009,7 @@ export function createData(config: CreateDataInput) {
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
@@ -1080,6 +1107,7 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
message.append(draft, index, failed)
|
||||
})
|
||||
if (event.data.inputID) compacting.get(event.data.sessionID)?.observed.add(event.data.inputID)
|
||||
return
|
||||
case "permission.asked":
|
||||
if (store.session.permission[event.data.sessionID]?.some((request) => request.id === event.data.id)) return
|
||||
@@ -1266,12 +1294,17 @@ export function createData(config: CreateDataInput) {
|
||||
sync(sessionID: string) {
|
||||
return sync.run(`session.pending:${sessionID}`, async () => {
|
||||
const pending = await api().session.inbox.list({ sessionID })
|
||||
// A positive read acknowledges admission even when its SSE echo is delayed.
|
||||
pending.forEach((item) => outbox.delete(item.id))
|
||||
// Compactions also coalesce by Session, not just by the proposed ID.
|
||||
if (pending.some((item) => item.type === "compaction"))
|
||||
store.session.pending[sessionID]
|
||||
?.filter((item) => item.type === "compaction")
|
||||
.forEach((item) => outbox.delete(item.id))
|
||||
// Keep optimistic rows still awaiting their echo: this fetch may
|
||||
// have raced ahead of an in-flight admission the server does not
|
||||
// know about yet.
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter(
|
||||
(item) => outbox.has(item.id) && !pending.some((row) => row.id === item.id),
|
||||
)
|
||||
const inflight = (store.session.pending[sessionID] ?? []).filter((item) => outbox.has(item.id))
|
||||
const merged = inflight.length === 0 ? pending : [...pending, ...inflight]
|
||||
batch(() => {
|
||||
setStore("session", "pending", sessionID, reconcile(merged))
|
||||
@@ -1345,13 +1378,56 @@ export function createData(config: CreateDataInput) {
|
||||
if (fresh) track(creating, id, request)
|
||||
return { id, request }
|
||||
},
|
||||
compact(input: { sessionID: string; model?: ModelRef }) {
|
||||
const active = compacting.get(input.sessionID)
|
||||
if (active) return active.request
|
||||
// A known pending control ID may be consumed while setup waits. Propose
|
||||
// a fresh ID and let the server coalesce, without duplicating its row.
|
||||
const id = SessionMessage.ID.create()
|
||||
if (!store.session.pending[input.sessionID]?.some((item) => item.type === "compaction")) {
|
||||
outbox.add(id)
|
||||
admitLocal({
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
}
|
||||
// Compaction admission can coalesce onto a different ID. Retire the
|
||||
// speculative row on an echo, and remember consumed IDs until the POST
|
||||
// settles so its older response cannot resurrect a queued row.
|
||||
const observed = new Set<string>()
|
||||
const request = sendAdmission(input.sessionID, async () => {
|
||||
if (input.model) await api().session.switchModel({ sessionID: input.sessionID, model: input.model })
|
||||
return api().session.compact({ sessionID: input.sessionID, id })
|
||||
})
|
||||
.then((item) => {
|
||||
batch(() => {
|
||||
outbox.delete(id)
|
||||
if (item.id !== id) removePending(input.sessionID, id)
|
||||
if (!observed.has(item.id) && !messageIndex.get(input.sessionID)?.has(item.id)) admitLocal(item)
|
||||
})
|
||||
return item
|
||||
})
|
||||
.catch((error) => {
|
||||
if (outbox.delete(id)) removePending(input.sessionID, id)
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
if (compacting.get(input.sessionID)?.request === request) compacting.delete(input.sessionID)
|
||||
})
|
||||
compacting.set(input.sessionID, { id, observed, request })
|
||||
return request
|
||||
},
|
||||
// Optimistic prompt admission: render the prompt immediately under a
|
||||
// client-minted ID, send it, and let the durable inbox.enqueued echo
|
||||
// upsert that same ID with the server's payload. Server admission is
|
||||
// idempotent per ID, so retrying with the identical payload cannot
|
||||
// double-admit.
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown> }) {
|
||||
const { gate, ...request } = input
|
||||
prompt(input: SessionPromptInput & { gate?: Promise<unknown>; prepare?: () => Promise<unknown> }) {
|
||||
const { gate, prepare, ...request } = input
|
||||
const id = request.id ?? SessionMessage.ID.create()
|
||||
// A retry may reuse an ID that is already rendered — and possibly
|
||||
// already durable. Admit optimistically only for new IDs so a failed
|
||||
@@ -1377,25 +1453,15 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
})
|
||||
}
|
||||
// Wrapped so even a synchronous client failure reaches the rollback.
|
||||
// The POST additionally waits for the caller's gate, for any
|
||||
// in-flight optimistic create of this session, and for the previous
|
||||
// prompt's POST: the row renders now, the send happens once the
|
||||
// session exists server-side and earlier prompts are admitted.
|
||||
const previous = sending.get(request.sessionID)
|
||||
const send = Promise.resolve()
|
||||
.then(() => Promise.all([gate, creating.get(request.sessionID), previous]))
|
||||
.then(() => api().session.prompt({ ...request, id }))
|
||||
track(
|
||||
sending,
|
||||
return sendAdmission(
|
||||
request.sessionID,
|
||||
send.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
)
|
||||
return send.catch((error) => {
|
||||
// Roll back only rows this call admitted and the echo has not
|
||||
async () => {
|
||||
await prepare?.()
|
||||
return api().session.prompt({ ...request, id })
|
||||
},
|
||||
gate,
|
||||
).catch((error) => {
|
||||
// Roll back only rows this call admitted and the server has not
|
||||
// acknowledged: anything else is server state.
|
||||
if (fresh && outbox.delete(id)) retractLocal(request.sessionID, id)
|
||||
throw error
|
||||
@@ -1464,20 +1530,70 @@ export function createData(config: CreateDataInput) {
|
||||
loading(sessionID: string) {
|
||||
return store.session.messageLoading[sessionID] ?? false
|
||||
},
|
||||
async loadMore(sessionID: string) {
|
||||
async loadMore(
|
||||
sessionID: string,
|
||||
options?: {
|
||||
all?: boolean
|
||||
signal?: AbortSignal
|
||||
/** Runs synchronously inside the store-publication batch. */
|
||||
beforePublish?: () => void
|
||||
},
|
||||
) {
|
||||
const signal = options?.signal
|
||||
if (signal?.aborted) return
|
||||
while (messageLoads.has(sessionID)) {
|
||||
const published = await (() => {
|
||||
const pending = messageLoads.get(sessionID)
|
||||
if (!signal) return pending
|
||||
const aborted = Promise.withResolvers<void>()
|
||||
const cancel = () => aborted.resolve()
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
return Promise.race([pending, aborted.promise])
|
||||
.catch((error) => {
|
||||
if (!signal.aborted) throw error
|
||||
})
|
||||
.finally(() => signal.removeEventListener("abort", cancel))
|
||||
})()
|
||||
if ((!options?.all && published) || signal?.aborted) return
|
||||
}
|
||||
const cursor = store.session.messageCursor[sessionID]
|
||||
if (!cursor || store.session.messageLoading[sessionID]) return
|
||||
if (!cursor || signal?.aborted) return
|
||||
setStore("session", "messageLoading", sessionID, true)
|
||||
const response = await api()
|
||||
.message.list({ sessionID, limit: messagePageLimit, cursor })
|
||||
const request = (async () => {
|
||||
const fetched: SessionMessageInfo[] = []
|
||||
let next: string | undefined = cursor
|
||||
do {
|
||||
const response = await api().message.list(
|
||||
{
|
||||
sessionID,
|
||||
limit: options?.all ? 200 : messagePageLimit,
|
||||
cursor: next,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
if (signal?.aborted) return
|
||||
fetched.push(...response.data)
|
||||
next = response.cursor.next ?? undefined
|
||||
if (!options?.all) break
|
||||
} while (next)
|
||||
// A jump through history publishes once, not once per page of offscreen messages.
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...fetched.reverse().filter((item) => !ids.has(item.id)), ...existing]
|
||||
batch(() => {
|
||||
options?.beforePublish?.()
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, next)
|
||||
})
|
||||
return true
|
||||
})()
|
||||
.catch((error) => {
|
||||
if (!signal?.aborted) throw error
|
||||
})
|
||||
.finally(() => setStore("session", "messageLoading", sessionID, false))
|
||||
const older = response.data.toReversed()
|
||||
const existing = store.session.message[sessionID] ?? []
|
||||
const ids = new Set(existing.map((item) => item.id))
|
||||
const messages = [...older.filter((item) => !ids.has(item.id)), ...existing]
|
||||
messageIndex.set(sessionID, new Map(messages.map((item, position) => [item.id, position])))
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
setStore("session", "messageCursor", sessionID, response.cursor.next ?? undefined)
|
||||
track(messageLoads, sessionID, request)
|
||||
await request
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionInboxCompaction, type SessionInboxInfo } from "../src/promise"
|
||||
|
||||
test("admits compaction before model setup and serializes the following prompt", async () => {
|
||||
using fixture = setup()
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0]
|
||||
expect(proposed).toMatchObject({ type: "compaction", sessionID })
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(fixture.data.session.message.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.status(sessionID)).toBe("idle")
|
||||
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
expect(fixture.calls).toEqual(["model"])
|
||||
fixture.model.resolve()
|
||||
await wait(() => fixture.calls.length === 2)
|
||||
expect(fixture.calls).toEqual(["model", "compact"])
|
||||
fixture.response.resolve(Response.json({ data: item(proposed.id) }))
|
||||
await Promise.all([compact, prompt])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "prompt"])
|
||||
expect(fixture.proposals).toEqual([proposed.id])
|
||||
})
|
||||
|
||||
test("coalesces duplicate gestures until the admission request settles", async () => {
|
||||
using fixture = setup()
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.data.session.compact({ sessionID })).toBe(first)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toHaveLength(1)
|
||||
await wait(() => fixture.calls.length === 1)
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await first
|
||||
expect(fixture.calls).toEqual(["compact"])
|
||||
const next = fixture.data.session.compact({ sessionID })
|
||||
expect(next).not.toBe(first)
|
||||
await next
|
||||
expect(fixture.calls).toEqual(["compact", "compact"])
|
||||
expect(new Set(fixture.proposals).size).toBe(2)
|
||||
expect(fixture.proposals).not.toContain("msg_canonical")
|
||||
})
|
||||
|
||||
test("substitutes the canonical response ID and reconciles its later echo", async () => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const proposed = fixture.data.session.pending.list(sessionID)[0].id
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.id)).toEqual([proposed])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical")])
|
||||
fixture.enqueue("msg_canonical", 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_canonical", 20)])
|
||||
expect(fixture.data.session.input.list(sessionID)).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical"])("adopts the %s echo before the response without duplicating it", async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
fixture.enqueue(id, 20)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
fixture.response.resolve(Response.json({ data: item(id) }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id, 20)])
|
||||
})
|
||||
|
||||
test.each(["started", "cancelled", "failed"])(
|
||||
"does not resurrect a canonical item already %s before the response",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
fixture.enqueue("msg_canonical")
|
||||
if (kind === "started")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_canonical", reason: "manual" },
|
||||
})
|
||||
if (kind === "cancelled")
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: "msg_canonical" } })
|
||||
if (kind === "failed")
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.failed",
|
||||
data: {
|
||||
sessionID,
|
||||
inputID: "msg_canonical",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Cancelled" },
|
||||
},
|
||||
})
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await request
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
if (kind === "started") {
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" },
|
||||
})
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary" },
|
||||
])
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["model", "compact"])("rolls back a rejected %s RPC and releases the following prompt", async (rpc) => {
|
||||
using fixture = setup()
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const prompt = fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
if (rpc === "model") fixture.model.reject(new Error("Model setup failed"))
|
||||
if (rpc === "compact") {
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(new Response("Admission failed", { status: 500 }))
|
||||
}
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
await prompt
|
||||
expect(fixture.data.session.pending.list(sessionID).map((row) => row.type)).toEqual(["user"])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
})
|
||||
|
||||
test.each(["proposed", "canonical", "existing"])(
|
||||
"preserves acknowledged %s compaction after an HTTP error",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
if (kind === "existing") fixture.enqueue("msg_canonical")
|
||||
const request = fixture.data.session.compact({ sessionID })
|
||||
const failed = request.catch((error: unknown) => error)
|
||||
const id = kind === "proposed" ? fixture.data.session.pending.list(sessionID)[0].id : "msg_canonical"
|
||||
if (kind !== "existing") fixture.enqueue(id)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(id)])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
},
|
||||
)
|
||||
|
||||
test("uses a fresh control ID when the known pending compaction starts during model setup", async () => {
|
||||
const proposed = Promise.withResolvers<string>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
const body = await request.json()
|
||||
proposed.resolve(body.id)
|
||||
if (body.id === "msg_existing") return Response.json({ message: "Control ID already consumed" }, { status: 409 })
|
||||
return Response.json({ data: item(body.id) })
|
||||
})
|
||||
fixture.enqueue("msg_existing")
|
||||
const request = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item("msg_existing")])
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.started",
|
||||
data: { sessionID, inputID: "msg_existing", reason: "manual" },
|
||||
})
|
||||
fixture.model.resolve()
|
||||
expect(await proposed.promise).not.toBe("msg_existing")
|
||||
expect(await result).toEqual(item(await proposed.promise))
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([item(await proposed.promise)])
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ id: "msg_existing", type: "compaction", status: "running" },
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["compaction", "canonical compaction", "user"])(
|
||||
"preserves a fetched durable %s when SSE is delayed and HTTP fails",
|
||||
async (type) => {
|
||||
using fixture = setup(async (request) => {
|
||||
if (request.url.endsWith("/prompt")) return fixture.response.promise
|
||||
return undefined
|
||||
})
|
||||
const request =
|
||||
type === "user"
|
||||
? fixture.data.session.prompt({ sessionID, text: "Follow up" })
|
||||
: fixture.data.session.compact({ sessionID })
|
||||
const result = request.catch((error: unknown) => error)
|
||||
const id = type === "canonical compaction" ? "msg_canonical" : fixture.data.session.pending.list(sessionID)[0].id
|
||||
const durable: SessionInboxInfo =
|
||||
type === "user" ? { ...item(id, 20), type: "user", payload: { text: "Follow up" } } : item(id, 20)
|
||||
fixture.pending.push(durable)
|
||||
await fixture.data.session.pending.sync(sessionID)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
fixture.response.resolve(new Response("Lost response", { status: 500 }))
|
||||
expect(await result).toBeInstanceOf(Error)
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([durable])
|
||||
if (type === "user")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ id, type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("keeps one event listener and removes it when the data owner is disposed during a gate", async () => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID })
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.dispose()
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
gate.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([first, compact])
|
||||
expect(fixture.listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
test("routes concurrent compaction observations by session through one listener", async () => {
|
||||
const firstResponse = Promise.withResolvers<Response>()
|
||||
const secondResponse = Promise.withResolvers<Response>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/compact")) return undefined
|
||||
return request.url.includes(`/session/${sessionID}/`) ? firstResponse.promise : secondResponse.promise
|
||||
})
|
||||
const first = fixture.data.session.compact({ sessionID })
|
||||
const second = fixture.data.session.compact({ sessionID: "ses_other" })
|
||||
const firstID = fixture.data.session.pending.list(sessionID)[0].id
|
||||
const secondID = fixture.data.session.pending.list("ses_other")[0].id
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
fixture.emit({ ...event, type: "session.inbox.cancelled", data: { sessionID, inboxID: firstID } })
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other").map((row) => row.id)).toEqual([secondID])
|
||||
|
||||
firstResponse.resolve(Response.json({ data: item(firstID) }))
|
||||
secondResponse.resolve(Response.json({ data: { ...item(secondID), sessionID: "ses_other" } }))
|
||||
await Promise.all([first, second])
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.data.session.pending.list("ses_other")).toEqual([{ ...item(secondID), sessionID: "ses_other" }])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
})
|
||||
|
||||
test.each(["gate", "prepare"])(
|
||||
"a preceding prompt's failed %s does not block compaction or following model preparation",
|
||||
async (kind) => {
|
||||
using fixture = setup()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const first = fixture.data.session
|
||||
.prompt({
|
||||
sessionID,
|
||||
id: "msg_first",
|
||||
text: "First",
|
||||
gate: kind === "gate" ? gate.promise : undefined,
|
||||
prepare: () => {
|
||||
prepared.push("first")
|
||||
return gate.promise
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "first" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: () => {
|
||||
prepared.push("following")
|
||||
return fixture.api.session.switchModel({ sessionID, model: { providerID: "demo", id: "second" } })
|
||||
},
|
||||
})
|
||||
if (kind === "prepare") await wait(() => prepared.includes("first"))
|
||||
gate.reject(new Error("Preparation failed"))
|
||||
expect(await first).toBeInstanceOf(Error)
|
||||
await wait(() => fixture.calls.includes("model"))
|
||||
expect(prepared).toEqual(kind === "prepare" ? ["first"] : [])
|
||||
fixture.model.resolve()
|
||||
fixture.response.resolve(Response.json({ data: item("msg_canonical") }))
|
||||
await Promise.all([compact, following])
|
||||
expect(fixture.calls).toEqual(["model", "compact", "model", "prompt"])
|
||||
expect(prepared.at(-1)).toBe("following")
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "user", text: "Follow up" }])
|
||||
},
|
||||
)
|
||||
|
||||
test("creation failure rejects gated prompt, compaction, and following preparation without sending their RPCs", async () => {
|
||||
const creation = Promise.withResolvers<Response>()
|
||||
const requested = Promise.withResolvers<void>()
|
||||
using fixture = setup(async (request) => {
|
||||
if (!request.url.endsWith("/api/session")) return undefined
|
||||
requested.resolve()
|
||||
return creation.promise
|
||||
})
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const prepared: string[] = []
|
||||
const created = fixture.data.session.create({ id: sessionID })
|
||||
const first = fixture.data.session.prompt({ sessionID, text: "First", gate: gate.promise })
|
||||
const compact = fixture.data.session.compact({ sessionID, model: { providerID: "demo", id: "model" } })
|
||||
const following = fixture.data.session.prompt({
|
||||
sessionID,
|
||||
text: "Follow up",
|
||||
prepare: async () => {
|
||||
prepared.push("following")
|
||||
},
|
||||
})
|
||||
const results = Promise.allSettled([created.request, first, compact, following])
|
||||
await requested.promise
|
||||
creation.resolve(new Response("Creation failed", { status: 500 }))
|
||||
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected", "rejected", "rejected"])
|
||||
expect(fixture.calls).toEqual([])
|
||||
expect(prepared).toEqual([])
|
||||
expect(fixture.data.session.get(sessionID)).toBeUndefined()
|
||||
expect(fixture.data.session.pending.list(sessionID)).toEqual([])
|
||||
expect(fixture.listeners.size).toBe(1)
|
||||
gate.resolve()
|
||||
})
|
||||
|
||||
const sessionID = "ses_compact"
|
||||
const event = { id: "evt_compact", created: 10, durable: { aggregateID: sessionID, seq: 1, version: 1 } }
|
||||
const item = (id: string, timeCreated = 10): SessionInboxCompaction => ({
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated,
|
||||
type: "compaction",
|
||||
delivery: "steer",
|
||||
payload: {},
|
||||
})
|
||||
|
||||
function setup(override?: (request: Request) => Promise<Response | undefined>) {
|
||||
const model = Promise.withResolvers<void>()
|
||||
const response = Promise.withResolvers<Response>()
|
||||
const calls: string[] = []
|
||||
const proposals: string[] = []
|
||||
const pending: SessionInboxInfo[] = []
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
const overridden = await override?.(request)
|
||||
if (overridden) return overridden
|
||||
const rpc = new URL(request.url).pathname.split("/").at(-1)
|
||||
if (rpc === "inbox") return Response.json({ data: pending })
|
||||
if (rpc === "model") {
|
||||
calls.push(rpc)
|
||||
await model.promise
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (rpc === "compact") {
|
||||
calls.push(rpc)
|
||||
proposals.push((await request.json()).id)
|
||||
return (await response.promise).clone()
|
||||
}
|
||||
if (rpc === "prompt") {
|
||||
calls.push(rpc)
|
||||
return Response.json({
|
||||
data: { ...item((await request.json()).id), type: "user", payload: { text: "Follow up" } },
|
||||
})
|
||||
}
|
||||
throw new Error(`Unexpected request: ${request.url}`)
|
||||
},
|
||||
})
|
||||
const root = createRoot((dispose) => ({
|
||||
data: createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: {
|
||||
on: () => () => {},
|
||||
listen(handler) {
|
||||
listeners.add(handler)
|
||||
return () => listeners.delete(handler)
|
||||
},
|
||||
},
|
||||
}),
|
||||
dispose,
|
||||
}))
|
||||
const emit = (details: OpenCodeEvent) => listeners.forEach((listener) => listener({ name: details.type, details }))
|
||||
return {
|
||||
data: root.data,
|
||||
api,
|
||||
dispose: root.dispose,
|
||||
[Symbol.dispose]: root.dispose,
|
||||
model,
|
||||
response,
|
||||
calls,
|
||||
proposals,
|
||||
pending,
|
||||
listeners,
|
||||
emit,
|
||||
enqueue(id: string, created = 10) {
|
||||
emit({
|
||||
...event,
|
||||
created,
|
||||
type: "session.inbox.enqueued",
|
||||
data: { sessionID, inboxID: id, item: { type: "compaction", delivery: "steer", payload: {} } },
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function wait(predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (predicate()) return
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error("Timed out waiting for request")
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { getEventListeners } from "node:events"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createData, type CreateDataInput } from "../src/solid"
|
||||
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
|
||||
@@ -414,6 +415,120 @@ test("loads bounded message pages", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test.each(["success", "failure", "cancel", "cancel-retry", "cancel-page", "join-cancel", "join-failure"])(
|
||||
"bulk history (%s)",
|
||||
async (mode) => {
|
||||
const messages = [1, 2, 3].map((index) => ({
|
||||
id: `msg_${index}`,
|
||||
type: "user",
|
||||
text: `Message ${index}`,
|
||||
time: { created: index },
|
||||
}))
|
||||
const release = Promise.withResolvers<void>()
|
||||
const controller = new AbortController()
|
||||
const requests: URL[] = []
|
||||
const publications: string[][] = []
|
||||
const api = OpenCode.make({
|
||||
baseUrl: "http://opencode.local",
|
||||
fetch: async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
requests.push(url)
|
||||
const cursor = url.searchParams.get("cursor")
|
||||
if (!cursor) return Response.json({ data: [messages[2]], cursor: { next: "recent" } })
|
||||
if (cursor === "recent") {
|
||||
if (mode.startsWith("join")) await release.promise
|
||||
if (mode === "join-failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [messages[2], messages[1]], cursor: { next: "oldest" } })
|
||||
}
|
||||
if (cursor === "oldest") return Response.json({ data: [messages[0]], cursor: { next: "empty" } })
|
||||
expect(init?.signal).toBe(requests.length === 4 ? controller.signal : undefined)
|
||||
await release.promise
|
||||
if (mode === "failure") return Response.json({ message: "offline" }, { status: 503 })
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
},
|
||||
})
|
||||
const setup = createRoot((dispose) => {
|
||||
const data = createData({
|
||||
api: () => api,
|
||||
directory: "/project",
|
||||
event: { on: () => () => {}, listen: () => () => {} },
|
||||
})
|
||||
return { data, dispose }
|
||||
})
|
||||
|
||||
try {
|
||||
await setup.data.session.message.sync("ses_refresh")
|
||||
const newest = setup.data.session.message.get("ses_refresh", "msg_3")
|
||||
const load = setup.data.session.message.loadMore(
|
||||
"ses_refresh",
|
||||
mode.startsWith("join")
|
||||
? undefined
|
||||
: {
|
||||
all: true,
|
||||
signal: controller.signal,
|
||||
beforePublish: () => {
|
||||
publications.push(setup.data.session.message.list("ses_refresh").map((message) => message.id))
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
},
|
||||
},
|
||||
)
|
||||
const joined = setup.data.session.message.loadMore("ses_refresh", { all: true, signal: controller.signal })
|
||||
const settled = Promise.allSettled([load, joined])
|
||||
if (mode.startsWith("join")) {
|
||||
await wait(() => requests.length === 2)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(1)
|
||||
controller.abort()
|
||||
let cancelled = false
|
||||
void joined.then(() => {
|
||||
cancelled = true
|
||||
})
|
||||
await wait(() => cancelled)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "join-failure" ? ["rejected", "fulfilled"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
mode === "join-failure" ? ["msg_3"] : ["msg_2", "msg_3"],
|
||||
)
|
||||
return
|
||||
}
|
||||
await wait(() => requests.length === 4)
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(true)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(["msg_3"])
|
||||
expect(requests.slice(1).map((url) => url.searchParams.get("limit"))).toEqual(["200", "200", "200"])
|
||||
if (mode.startsWith("cancel")) controller.abort()
|
||||
const retry =
|
||||
mode === "cancel-retry" || mode === "cancel-page"
|
||||
? setup.data.session.message.loadMore("ses_refresh", mode === "cancel-retry" ? { all: true } : undefined)
|
||||
: undefined
|
||||
release.resolve()
|
||||
expect((await settled).map((result) => result.status)).toEqual(
|
||||
mode === "failure" ? ["rejected", "rejected"] : ["fulfilled", "fulfilled"],
|
||||
)
|
||||
await retry
|
||||
const success = mode === "success" || mode === "cancel-retry"
|
||||
expect(setup.data.session.message.loading("ses_refresh")).toBe(false)
|
||||
expect(setup.data.session.message.more("ses_refresh")).toBe(!success)
|
||||
expect(setup.data.session.message.list("ses_refresh").map((message) => message.id)).toEqual(
|
||||
success ? ["msg_1", "msg_2", "msg_3"] : mode === "cancel-page" ? ["msg_2", "msg_3"] : ["msg_3"],
|
||||
)
|
||||
expect(setup.data.session.message.get("ses_refresh", "msg_3")).toBe(newest)
|
||||
expect(requests).toHaveLength(mode === "cancel-retry" ? 7 : mode === "cancel-page" ? 5 : 4)
|
||||
if (mode === "cancel-page") expect(requests.at(-1)?.searchParams.get("limit")).toBe("20")
|
||||
expect(publications).toEqual(mode === "success" ? [["msg_3"]] : [])
|
||||
expect(getEventListeners(controller.signal, "abort")).toHaveLength(0)
|
||||
} finally {
|
||||
release.resolve()
|
||||
setup.dispose()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
test("preserves assistant content replacement events across an active message read", async () => {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
@@ -84,10 +84,7 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(
|
||||
input.settings,
|
||||
isStringRecord(input.settings.labels) ? { labels: input.settings.labels } : {},
|
||||
),
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
@@ -296,7 +293,7 @@ function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Readonly<Record<string, unknown>> = {}) {
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
@@ -311,7 +308,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>, extra: Re
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
...extra,
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
@@ -345,28 +341,21 @@ function mapOpenRouter(
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) =>
|
||||
![
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
].includes(key),
|
||||
),
|
||||
)
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
return mapProviderOptions(settings, [
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
])
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
|
||||
+147
-153
@@ -294,162 +294,156 @@ export function configured(options?: Options) {
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const durable = definition.durable
|
||||
if (durable) {
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string") {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
if (!durable) return yield* Effect.void
|
||||
const aggregateID = (event.data as Record<string, unknown>)[durable.aggregate]
|
||||
if (typeof aggregateID !== "string")
|
||||
return yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Expected string aggregate field ${durable.aggregate}`,
|
||||
}),
|
||||
)
|
||||
if (input && input.aggregateID !== aggregateID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Aggregate mismatch: expected ${input.aggregateID}, got ${aggregateID}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const list = projectors.get(versionedType(definition.type, durable.version)) ?? []
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const committed = yield* db
|
||||
.transaction(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* db
|
||||
.select({ seq: EventSequenceTable.seq, ownerID: EventSequenceTable.owner_id })
|
||||
.from(EventSequenceTable)
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const latest = row?.seq ?? -1
|
||||
const encoded = Schema.encodeUnknownSync(definition.data)(event.data) as Record<string, unknown>
|
||||
if (input?.strictOwner && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay owner mismatch for aggregate ${aggregateID}: expected ${row.ownerID}, got ${input.ownerID ?? "none"}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && input.seq <= latest) {
|
||||
if (!persist) return
|
||||
const stored = yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(and(eq(EventTable.aggregate_id, aggregateID), eq(EventTable.seq, input.seq)))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (
|
||||
stored?.id === event.id &&
|
||||
stored.type === versionedType(definition.type, durable.version) &&
|
||||
stored.created === (event.created ?? 0) &&
|
||||
isDeepStrictEqual(stored.data, encoded)
|
||||
) {
|
||||
if (input.ownerID && row?.ownerID == null) {
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.update(EventSequenceTable)
|
||||
.set({ owner_id: input.ownerID })
|
||||
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Replay diverged at aggregate ${aggregateID} sequence ${input.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (input && row?.ownerID && row.ownerID !== input.ownerID) {
|
||||
return
|
||||
}
|
||||
const seq = input?.seq ?? latest + 1
|
||||
if (input && seq !== latest + 1) {
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Sequence mismatch for aggregate ${aggregateID}: expected ${latest + 1}, got ${seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (persist) {
|
||||
const stored = yield* db
|
||||
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.id, event.id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (stored)
|
||||
yield* Effect.die(
|
||||
new InvalidDurableEventError({
|
||||
type: event.type,
|
||||
message: `Event ${event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Event.Payload
|
||||
const route = yield* prepareRoutes([committed])
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
if (commit) yield* commit(seq)
|
||||
yield* db
|
||||
.insert(EventSequenceTable)
|
||||
.values([{ aggregate_id: aggregateID, seq, owner_id: input?.ownerID }])
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: {
|
||||
seq: sql`max(${EventSequenceTable.seq}, ${seq})`,
|
||||
...(input?.ownerID && row?.ownerID == null ? { owner_id: input.ownerID } : {}),
|
||||
},
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
if (persist)
|
||||
yield* db
|
||||
.insert(EventTable)
|
||||
.values([
|
||||
{
|
||||
id: event.id,
|
||||
aggregate_id: aggregateID,
|
||||
seq,
|
||||
created: event.created ?? 0,
|
||||
type: versionedType(definition.type, durable.version),
|
||||
data: encoded,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { aggregateID, seq, event: committed, route }
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (committed) {
|
||||
committed.route()
|
||||
yield* Effect.forEach(
|
||||
pubsub.durable.get(committed.aggregateID) ?? [],
|
||||
(wake) => PubSub.publish(wake, undefined),
|
||||
{ discard: true },
|
||||
)
|
||||
}
|
||||
return committed
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
export * as ConfigFile from "./file.js"
|
||||
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import {
|
||||
applyEdits,
|
||||
createScanner,
|
||||
findNodeAtLocation,
|
||||
modify,
|
||||
parseTree,
|
||||
type Node,
|
||||
type ParseError,
|
||||
} from "jsonc-parser"
|
||||
|
||||
export class UpdateError extends Schema.TaggedError<UpdateError>()("ConfigFile.UpdateError", {
|
||||
message: Schema.String,
|
||||
cause: Schema.optional(Schema.Defect()),
|
||||
}) {}
|
||||
|
||||
const isJson = Schema.is(Schema.MutableJson)
|
||||
const isDocument = (value: unknown): value is Schema.MutableJsonObject => isRecord(value) && isJson(value)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
/**
|
||||
* Edits an existing JSON(C) file using raw source values, not resolved Config.Info.
|
||||
* The synchronous callback mutates a source clone; its return value is ignored.
|
||||
* Validates JSON only; normalization and substitution remain the reader's job.
|
||||
* Does not discover files, start watchers, or refresh Config state.
|
||||
* Read-modify-write calls are serialized within this process.
|
||||
*/
|
||||
export const update = Effect.fn("ConfigFile.update")(
|
||||
function* (
|
||||
filepath: string,
|
||||
mutate: (draft: Schema.MutableJsonObject) => void,
|
||||
): Effect.fn.Return<Schema.JsonObject, UpdateError, FSUtil.Service> {
|
||||
const fs = yield* FSUtil.Service
|
||||
const text = yield* fs
|
||||
.readFileString(filepath)
|
||||
.pipe(Effect.mapError((cause) => new UpdateError({ message: `Failed to read config: ${filepath}`, cause })))
|
||||
const errors: ParseError[] = []
|
||||
const current = parseSource(text, errors)
|
||||
if (errors.length || !isDocument(current))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Invalid config file: ${filepath}` }))
|
||||
|
||||
const next = yield* Effect.try({
|
||||
try: () => {
|
||||
const draft = structuredClone(current)
|
||||
mutate(draft)
|
||||
return draft
|
||||
},
|
||||
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
|
||||
})
|
||||
if (!isDocument(next))
|
||||
return yield* Effect.fail(new UpdateError({ message: `Config update must produce a JSON object: ${filepath}` }))
|
||||
|
||||
const edits = changes(current, next)
|
||||
if (!edits.length) return next
|
||||
const updated = yield* Effect.try({
|
||||
try: () => edits.reduce(patch, text),
|
||||
catch: (cause) => new UpdateError({ message: `Failed to patch config: ${filepath}`, cause }),
|
||||
})
|
||||
// Duplicate keys can make parse choose the last value while modify edits the first.
|
||||
const written = parseSource(updated, errors)
|
||||
if (errors.length || !isDeepStrictEqual(written, next))
|
||||
return yield* Effect.fail(
|
||||
new UpdateError({ message: `Config patch does not match the requested update: ${filepath}` }),
|
||||
)
|
||||
const temporary = filepath + ".tmp"
|
||||
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
|
||||
Effect.andThen(fs.rename(temporary, filepath)),
|
||||
Effect.mapError((cause) => new UpdateError({ message: `Failed to write config: ${filepath}`, cause })),
|
||||
)
|
||||
return next
|
||||
},
|
||||
(effect) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
|
||||
|
||||
function parseSource(text: string, errors: ParseError[]) {
|
||||
const root = parseTree(text, errors, { allowTrailingComma: true })
|
||||
if (!root || errors.length) return undefined
|
||||
// parse() assigns onto {}, invoking the __proto__ setter instead of retaining
|
||||
// an own JSON key. Construct object entries from the AST without those setters.
|
||||
const value = (node: Node): unknown => {
|
||||
if (node.type === "array") return (node.children ?? []).map(value)
|
||||
if (node.type === "object")
|
||||
return Object.fromEntries(
|
||||
(node.children ?? []).map((property) => {
|
||||
const child = property.children?.[1]
|
||||
return [property.children?.[0]?.value, child && value(child)]
|
||||
}),
|
||||
)
|
||||
return node.value
|
||||
}
|
||||
return value(root)
|
||||
}
|
||||
|
||||
function patch(text: string, edit: Edit) {
|
||||
if (edit.value !== undefined)
|
||||
return applyEdits(
|
||||
text,
|
||||
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
|
||||
const tree = parseTree(text)
|
||||
const node = tree && findNodeAtLocation(tree, edit.path)
|
||||
if (!node) return text
|
||||
// jsonc-parser removes adjacent comments along with the separator. Remove only
|
||||
// the property/element itself and one comma, leaving surrounding comments intact.
|
||||
const target = node.parent?.type === "property" ? node.parent : node
|
||||
const siblings = target.parent?.children ?? []
|
||||
const previous = siblings[siblings.indexOf(target) - 1]
|
||||
const scanner = createScanner(text, true)
|
||||
scanner.setPosition(target.offset + target.length)
|
||||
scanner.scan()
|
||||
const following = text[scanner.getTokenOffset()] === ","
|
||||
if (!following && previous) {
|
||||
scanner.setPosition(previous.offset + previous.length)
|
||||
scanner.scan()
|
||||
}
|
||||
return applyEdits(text, [
|
||||
{ offset: target.offset, length: target.length, content: "" },
|
||||
...(following || previous ? [{ offset: scanner.getTokenOffset(), length: 1, content: "" }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
|
||||
if (isDeepStrictEqual(before, after)) return []
|
||||
if (Array.isArray(before) && Array.isArray(after)) {
|
||||
return [
|
||||
...after.flatMap((value, index) => changes(before[index], value, [...path, index])),
|
||||
// Remove from the end so earlier deletions cannot shift later paths.
|
||||
...before
|
||||
.slice(after.length)
|
||||
.map((_, index) => ({ path: [...path, after.length + index], value: undefined }))
|
||||
.toReversed(),
|
||||
]
|
||||
}
|
||||
if (isRecord(before) && isRecord(after)) {
|
||||
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
|
||||
if (!Object.hasOwn(after, key)) return [{ path: [...path, key], value: undefined }]
|
||||
if (!Object.hasOwn(before, key)) return [{ path: [...path, key], value: after[key] }]
|
||||
return changes(before[key], after[key], [...path, key])
|
||||
})
|
||||
}
|
||||
return [{ path, value: after }]
|
||||
}
|
||||
@@ -87,7 +87,6 @@ export const Plugin = define({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
config,
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
@@ -152,7 +151,6 @@ function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly config: Config.Interface
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
|
||||
@@ -212,7 +212,7 @@ const nativeLayer = (config: Config) =>
|
||||
: Layer.effect(
|
||||
Sqlite.Native,
|
||||
Effect.die(
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
|
||||
"workerd sqlite cannot open a database from a path; use Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })))",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -275,22 +275,22 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
if (paired.has(item.row.id)) return []
|
||||
const owned = byMessage.get(item.row.id)?.map((part) => part.value) ?? []
|
||||
if (item.value.role === "user") {
|
||||
const compaction = owned.find((part) => part.type === "compaction")
|
||||
if (compaction?.type === "compaction") {
|
||||
const compaction = owned.find((part): part is SessionV1.CompactionPart => part.type === "compaction")
|
||||
if (compaction) {
|
||||
const pairedSummary = messages.find(
|
||||
(candidate) =>
|
||||
(candidate): candidate is (typeof messages)[number] & { value: SessionV1.Assistant } =>
|
||||
candidate.value.role === "assistant" &&
|
||||
candidate.value.parentID === item.row.id &&
|
||||
candidate.value.summary,
|
||||
candidate.value.summary === true,
|
||||
)
|
||||
if (!pairedSummary || pairedSummary.value.role !== "assistant") return []
|
||||
if (!pairedSummary) return []
|
||||
paired.add(pairedSummary.row.id)
|
||||
if (pairedSummary.value.error || pairedSummary.value.time.completed === undefined) return []
|
||||
const summary = pairedSummary
|
||||
const summaryText = (byMessage.get(summary.row.id) ?? [])
|
||||
.map((part) => part.value)
|
||||
.filter((part) => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.filter((part): part is SessionV1.TextPart => part.type === "text" && part.text.length > 0)
|
||||
.map((part) => part.text)
|
||||
.join("\n\n")
|
||||
const tailIndex = compaction.tail_start_id
|
||||
? messages.findIndex((candidate) => candidate.row.id === compaction.tail_start_id)
|
||||
@@ -313,16 +313,14 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
]
|
||||
}
|
||||
const subtasks = owned.filter((part) => part.type === "subtask")
|
||||
const visible = owned.filter((part) => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part) => part.type === "file")
|
||||
const agents = owned.filter((part) => part.type === "agent")
|
||||
const visible = owned.filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored)
|
||||
const files = owned.filter((part): part is SessionV1.FilePart => part.type === "file")
|
||||
const agents = owned.filter((part): part is SessionV1.AgentPart => part.type === "agent")
|
||||
if (subtasks.length > 0 && visible.length === 0 && files.length === 0 && agents.length === 0) return []
|
||||
const ordinary = visible.filter((part) => part.type === "text" && !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.type === "text" && part.synthetic)
|
||||
const attachments = files.flatMap((part) => (part.type === "file" ? migrateFile(part) : []))
|
||||
const unavailable = files.flatMap((part) =>
|
||||
part.type === "file" && !part.url.startsWith("data:") ? [unavailableFile(part)] : [],
|
||||
)
|
||||
const ordinary = visible.filter((part) => !part.synthetic)
|
||||
const synthetic = visible.filter((part) => part.synthetic)
|
||||
const attachments = files.flatMap((part) => migrateFile(part))
|
||||
const unavailable = files.flatMap((part) => (!part.url.startsWith("data:") ? [unavailableFile(part)] : []))
|
||||
const text = owned
|
||||
.flatMap((part) => {
|
||||
if (part.type === "text" && !part.ignored && !part.synthetic) return [part.text]
|
||||
@@ -330,16 +328,12 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
return []
|
||||
})
|
||||
.join("\n\n")
|
||||
const agentAttachments = agents.map((part) =>
|
||||
part.type === "agent"
|
||||
? {
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}
|
||||
: { name: "" },
|
||||
)
|
||||
const agentAttachments = agents.map((part) => ({
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
}))
|
||||
if (
|
||||
ordinary.length === 0 &&
|
||||
unavailable.length === 0 &&
|
||||
@@ -351,7 +345,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: item.row.id,
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -369,7 +363,7 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
row(item.row, {
|
||||
id: syntheticID(item.row.id, used),
|
||||
type: "synthetic",
|
||||
text: synthetic.map((part) => (part.type === "text" ? part.text : "")).join("\n\n"),
|
||||
text: synthetic.map((part) => part.text).join("\n\n"),
|
||||
time: { created: item.row.time_created },
|
||||
}),
|
||||
]
|
||||
@@ -443,7 +437,6 @@ export function transformSession(input: TransformInput): TransformResult {
|
||||
})
|
||||
.map((item, seq) => ({ ...item, seq }))
|
||||
const assistants = messages
|
||||
.filter((item) => item.value.role === "assistant")
|
||||
.map((item) => item.value)
|
||||
.filter((item): item is SessionV1.Assistant => item.role === "assistant")
|
||||
const latestUser = messages.findLast((item) => {
|
||||
|
||||
@@ -62,9 +62,8 @@ export const syncTextBom = Effect.fn("FileMutation.syncTextBom")(function* (
|
||||
const transactionLocks = KeyedMutex.makeUnsafe<string>()
|
||||
|
||||
/**
|
||||
* Serialize file changes by absolute target. Conditional writes compare and
|
||||
* write under the same process-local lock so cooperating OpenCode mutations do
|
||||
* not overwrite changes made from the same stale content.
|
||||
* Mutation locking is process-local and serializes cooperating OpenCode
|
||||
* changes; external writes can still race.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -129,7 +128,6 @@ export const node = makeLocationNode({ service: Service, layer, deps: [Environme
|
||||
/**
|
||||
* Deferred until the corresponding integrations exist.
|
||||
*/
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after snapshot design exists.
|
||||
// TODO: Notify LSP and collect diagnostics after LSP runtime exists.
|
||||
|
||||
@@ -135,7 +135,7 @@ export const fffLayer = Layer.effect(
|
||||
find: () => Effect.succeed([]),
|
||||
})
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()))
|
||||
return Service.of({
|
||||
find: (input) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -213,7 +213,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.scope) {
|
||||
yield* Scope.close(result.scope, Exit.void).pipe(Effect.forkIn(state.scope, { startImmediately: true }))
|
||||
}
|
||||
@@ -346,8 +346,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), backgrounded: job.backgrounded }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.backgrounded)
|
||||
yield* Deferred.succeed(result.backgrounded, result.info).pipe(Effect.ignore)
|
||||
if (result.info && result.backgrounded) yield* Deferred.succeed(result.backgrounded, result.info)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -396,7 +395,7 @@ export const make = Effect.gen(function* () {
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info).pipe(Effect.ignore)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -10,17 +10,14 @@ import {
|
||||
CallToolResultSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type Implementation,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
ListPromptsResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
PromptListChangedNotificationSchema,
|
||||
PromptSchema,
|
||||
ResourceListChangedNotificationSchema,
|
||||
type LoggingMessageNotification,
|
||||
LoggingMessageNotificationSchema,
|
||||
@@ -41,10 +38,6 @@ const toError = (error: unknown) => (error instanceof Error ? error : new Error(
|
||||
const TolerantListToolsResult = ListToolsResultSchema.extend({
|
||||
tools: ToolSchema.omit({ outputSchema: true }).array(),
|
||||
})
|
||||
const TolerantListPromptsResult = ListPromptsResultSchema.extend({
|
||||
prompts: PromptSchema.array(),
|
||||
})
|
||||
|
||||
export class NeedsAuthError extends Schema.TaggedError<NeedsAuthError>()("MCP.NeedsAuthError", {
|
||||
server: Schema.String,
|
||||
}) {
|
||||
@@ -163,6 +156,7 @@ export interface Connection {
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: string
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
@@ -301,12 +295,8 @@ export const connect = Effect.fnUntraced(function* (
|
||||
const prompts = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
paginate(
|
||||
async (cursor) => {
|
||||
const params = cursor === undefined ? undefined : { cursor }
|
||||
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
|
||||
timeout: catalogTimeout,
|
||||
})
|
||||
},
|
||||
(cursor) =>
|
||||
client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout: catalogTimeout }),
|
||||
(result) => result.prompts,
|
||||
),
|
||||
catch: toError,
|
||||
@@ -396,11 +386,7 @@ export const connect = Effect.fnUntraced(function* (
|
||||
prompt: (input) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.request(
|
||||
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
|
||||
GetPromptResultSchema,
|
||||
{ signal, timeout: executionTimeout },
|
||||
),
|
||||
client.getPrompt({ name: input.name, arguments: input.args ?? {} }, { signal, timeout: executionTimeout }),
|
||||
catch: toError,
|
||||
}).pipe(
|
||||
Effect.map((result) => ({
|
||||
@@ -411,7 +397,11 @@ export const connect = Effect.fnUntraced(function* (
|
||||
Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
client.callTool(
|
||||
{ name: input.name, arguments: input.args ?? {} },
|
||||
{
|
||||
name: input.name,
|
||||
arguments: input.args ?? {},
|
||||
...(input.sessionID === undefined ? {} : { _meta: { sessionID: input.sessionID } }),
|
||||
},
|
||||
CallToolResultSchema,
|
||||
// Keep progress tokens available while enforcing a hard wall-clock execution timeout.
|
||||
{ signal, timeout: executionTimeout, onprogress: () => {} },
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: string
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
@@ -473,11 +474,11 @@ export const layer = (options?: Options) =>
|
||||
Effect.gen(function* () {
|
||||
entry.status = { status: "failed", error: "Connection closed" }
|
||||
yield* stopServer(name, entry)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}),
|
||||
),
|
||||
)
|
||||
connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore)))
|
||||
connection.onLog((message) => fork(serverLog(name, message)))
|
||||
connection.onToolsChanged(() =>
|
||||
live(
|
||||
refreshTools(name, entry, connection).pipe(
|
||||
@@ -512,7 +513,7 @@ export const layer = (options?: Options) =>
|
||||
// Announce the handshake so connect() and credential reconnects don't show a stale
|
||||
// disabled/failed status for the duration of the connection attempt.
|
||||
entry.status = { status: "pending" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.scope = scope
|
||||
const authProvider = yield* connectProvider(entry)
|
||||
@@ -543,9 +544,9 @@ export const layer = (options?: Options) =>
|
||||
// Announce the new tool set so the tool registry registers it. A server that finishes connecting
|
||||
// after the initial registration sweep and emits no list-changed notification would otherwise
|
||||
// stay invisible to the model.
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
whenLive(name, entry, result.value.connection)(refreshPrompts(name, entry, result.value.connection))
|
||||
return
|
||||
}
|
||||
@@ -557,7 +558,7 @@ export const layer = (options?: Options) =>
|
||||
? { status: "needs_auth" }
|
||||
: { status: "failed", error: error instanceof Error ? error.message : String(error) }
|
||||
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}).pipe(Effect.ensuring(entry.startup.open))
|
||||
|
||||
const stopServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -568,9 +569,9 @@ export const layer = (options?: Options) =>
|
||||
entry.tools = undefined
|
||||
entry.prompts = undefined
|
||||
yield* Scope.close(scope, Exit.void)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(PromptsChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.ToolsChanged, { server: name })
|
||||
yield* bus.publish(McpEvent.ResourcesChanged, { server: name })
|
||||
yield* bus.publish(PromptsChanged, { server: name })
|
||||
})
|
||||
|
||||
const disposeServer = Effect.fnUntraced(function* (name: ServerName, entry: ServerEntry) {
|
||||
@@ -592,7 +593,7 @@ export const layer = (options?: Options) =>
|
||||
yield* register(name, entry)
|
||||
if (serverConfig.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
return
|
||||
}
|
||||
yield* startServer(name, entry)
|
||||
@@ -608,7 +609,7 @@ export const layer = (options?: Options) =>
|
||||
yield* disposeServer(name, entry)
|
||||
// Credentials are keyed by name + URL and intentionally survive removal for a later re-add.
|
||||
entries.delete(name)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
})
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
@@ -631,7 +632,7 @@ export const layer = (options?: Options) =>
|
||||
if (entry.config.disabled) {
|
||||
entry.status = { status: "disabled" }
|
||||
entry.startup.openUnsafe()
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
continue
|
||||
}
|
||||
fork(startServer(name, entry).pipe(locks.withLock(name)))
|
||||
@@ -673,7 +674,6 @@ export const layer = (options?: Options) =>
|
||||
bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => owned.has(event.data.integrationID)),
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
Effect.ignore,
|
||||
),
|
||||
)
|
||||
const state = State.create<Data, Draft>({
|
||||
@@ -738,7 +738,7 @@ export const layer = (options?: Options) =>
|
||||
const target = yield* requireServer(name)
|
||||
yield* stopServer(name, target.entry)
|
||||
target.entry.status = { status: "disabled" }
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
|
||||
yield* bus.publish(McpEvent.StatusChanged, { server: name })
|
||||
}).pipe(locks.withLock(name))
|
||||
}),
|
||||
remove: Effect.fn("MCP.remove")(function* (server) {
|
||||
@@ -763,7 +763,7 @@ export const layer = (options?: Options) =>
|
||||
message: "MCP server is not connected",
|
||||
})
|
||||
const result = yield* target.entry.client
|
||||
.callTool({ name: input.name, args: input.args })
|
||||
.callTool({ name: input.name, args: input.args, sessionID: input.sessionID })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolCallError({ server: target.name, tool: input.name, message: error.message }),
|
||||
|
||||
@@ -213,20 +213,11 @@ export const authorize = (input: {
|
||||
return toCredential({ methodID: input.methodID, serverUrl: input.config.url, tokens, client })
|
||||
})
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
yield* Effect.tryPromise({
|
||||
try: () => auth(oauthProvider, { serverUrl: input.config.url, scope: oauth?.scope }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
|
||||
// The provider may already hold valid tokens (e.g. a re-auth), in which case there is no browser step.
|
||||
if (result === "AUTHORIZED") {
|
||||
return {
|
||||
url: input.config.url,
|
||||
instructions: `Connected to ${input.name}.`,
|
||||
mode: "auto" as const,
|
||||
callback: finalize,
|
||||
}
|
||||
}
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const layer = Layer.effect(
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).pipe(Effect.ignore)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
@@ -142,7 +142,7 @@ const layer = Layer.effect(
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void).pipe(Effect.ignore), {
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
|
||||
@@ -98,7 +98,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
list: (input) => {
|
||||
const ref = locationRef(input)
|
||||
if (ref && !isCurrentLocation(ref)) return runtime.location.agent.list(ref)
|
||||
return agents.list().pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
return response(agents.list())
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
type MantleSDK = {
|
||||
languageModel: (modelID: string) => LanguageModelV3
|
||||
chat: (modelID: string) => LanguageModelV3
|
||||
responses: (modelID: string) => LanguageModelV3
|
||||
}
|
||||
|
||||
@@ -58,10 +58,8 @@ export const CloudflareAIGatewayPlugin = define({
|
||||
const config = gatewayConfig(evt.options)
|
||||
if (!config) return
|
||||
const metadata = gatewayMetadata(evt.options)
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider")).pipe(Effect.orDie)
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified")).pipe(
|
||||
Effect.orDie,
|
||||
)
|
||||
const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
|
||||
const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
|
||||
const gateway = createAiGateway({
|
||||
accountId: config.accountId,
|
||||
gateway: config.gatewayId,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
|
||||
export const DynamicProviderPlugin = define({
|
||||
id: "opencode.provider.dynamic",
|
||||
@@ -13,18 +12,7 @@ export const DynamicProviderPlugin = define({
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.sdk) return
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) throw new Error(`Package ${evt.package} has no import entrypoint`)
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
).pipe(Effect.orDie)) as Record<string, (options: any) => any>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) throw new Error(`Package ${evt.package} has no provider factory export`)
|
||||
|
||||
evt.sdk = mod[match](evt.options)
|
||||
evt.sdk = ((yield* loadSDKFactory(npm, evt.package)) as (options: any) => any)(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -40,7 +40,7 @@ export const GitLabPlugin = define({
|
||||
typeof evt.options.featureFlags === "object" && evt.options.featureFlags ? evt.options.featureFlags : {}
|
||||
const id = evt.model.modelID ?? evt.model.id
|
||||
if (id.startsWith("duo-workflow-")) {
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider")).pipe(Effect.orDie)
|
||||
const gitlab = yield* Effect.promise(() => import("gitlab-ai-provider"))
|
||||
const workflowRef =
|
||||
typeof evt.model.settings?.workflowRef === "string" ? evt.model.settings.workflowRef : undefined
|
||||
const workflowDefinition =
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
import { loadSDKFactory } from "./sdk-factory.js"
|
||||
|
||||
export const SapAICorePlugin = define({
|
||||
id: "opencode.provider.sap.ai.core",
|
||||
@@ -18,17 +17,7 @@ export const SapAICorePlugin = define({
|
||||
(typeof evt.options.serviceKey === "string" ? evt.options.serviceKey : undefined)
|
||||
if (serviceKey && !process.env.AICORE_SERVICE_KEY) process.env.AICORE_SERVICE_KEY = serviceKey
|
||||
|
||||
const installedPath = evt.package.startsWith("file://")
|
||||
? evt.package
|
||||
: (yield* npm.add(evt.package).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${evt.package} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${evt.package} has no provider factory export`))
|
||||
const factory = mod[match]
|
||||
const factory = yield* loadSDKFactory(npm, evt.package)
|
||||
if (typeof factory !== "function")
|
||||
return yield* Effect.die(new Error(`Package ${evt.package} provider factory export is not callable`))
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { importModule } from "@opencode-ai/util/runtime-import"
|
||||
|
||||
export const loadSDKFactory = Effect.fnUntraced(function* (npm: Npm.Interface, packageName: string) {
|
||||
const installedPath = packageName.startsWith("file://")
|
||||
? packageName
|
||||
: (yield* npm.add(packageName).pipe(Effect.orDie)).entrypoint
|
||||
if (!installedPath) return yield* Effect.die(new Error(`Package ${packageName} has no import entrypoint`))
|
||||
|
||||
const mod = (yield* Effect.promise(() =>
|
||||
importModule(installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href),
|
||||
)) as Record<string, unknown>
|
||||
const match = Object.keys(mod).find((name) => name.startsWith("create"))
|
||||
if (!match) return yield* Effect.die(new Error(`Package ${packageName} has no provider factory export`))
|
||||
return mod[match]
|
||||
})
|
||||
@@ -50,7 +50,7 @@ export const Plugin = define({
|
||||
const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDiagnostics")(function* (
|
||||
app: Context["app"],
|
||||
) {
|
||||
const plugins = yield* configuredPlugins().pipe(Effect.orElseSucceed(() => ["Unavailable: failed to inspect config"]))
|
||||
const plugins = yield* configuredPlugins()
|
||||
return [
|
||||
ReportContent,
|
||||
"",
|
||||
|
||||
@@ -137,11 +137,10 @@ const layer = Layer.effect(
|
||||
strategy: project.vcs.type === "git" ? "git" : undefined,
|
||||
})
|
||||
// A missing directory row means this directory's resolution is a new durable
|
||||
// fact (copy.ts registers copy directories directly; those never strand
|
||||
// sessions and never announce). The row insert commits atomically with the
|
||||
// event, so a crash between checks retries on the next resolve instead of
|
||||
// stranding the announcement. The in-flight set keeps concurrent resolves
|
||||
// from publishing the same fact twice.
|
||||
// fact. The row insert commits atomically with the event, so a crash between
|
||||
// checks retries on the next resolve instead of stranding the announcement.
|
||||
// The in-flight set keeps concurrent resolves from publishing the same fact
|
||||
// twice.
|
||||
for (const item of directories) {
|
||||
const key = item.projectID + "\u0000" + item.directory
|
||||
if (announcing.has(key)) continue
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "eff
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
import { Workspace } from "./workspace.js"
|
||||
import { Model } from "./model.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Location } from "./location.js"
|
||||
import { SessionMessage } from "./session/message.js"
|
||||
import { Base64, FileAttachment, Prompt } from "@opencode-ai/schema/prompt"
|
||||
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable, SessionTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js"
|
||||
import { Agent } from "./agent.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
|
||||
@@ -204,7 +204,6 @@ export const layer = (options?: Options) =>
|
||||
yield* jobs.background(background.id)
|
||||
yield* jobs.wait({ id: background.id }).pipe(
|
||||
Effect.flatMap((result) => (result.info ? notify(result.info) : Effect.void)),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { DateTime, Schema } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Location } from "../location.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Project } from "../project.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AbsolutePath, RelativePath } from "../schema.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionTable } from "./sql.js"
|
||||
import type { SessionTable } from "./sql.js"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
|
||||
@@ -42,8 +42,8 @@ export const commit = Effect.fn("InstructionState.commit")(function* (
|
||||
observation: Observation,
|
||||
) {
|
||||
if (!observation.initial && Object.keys(observation.delta).length === 0) return
|
||||
// The rendered text is frozen into the durable event: replaying it later would
|
||||
// require the Location-scoped registry that produced it.
|
||||
// The rendered text is frozen into the durable event because re-rendering it
|
||||
// later would require the original Location-scoped instruction sources.
|
||||
const text = observation.initial ? "" : yield* renderUpdateText(db, instructions, observation)
|
||||
yield* bus.publish(
|
||||
SessionEvent.InstructionsUpdated,
|
||||
|
||||
@@ -4,21 +4,17 @@ import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
|
||||
export interface Adapter {
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined, never, never>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined, never, never>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"], never, never>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getAssistant: (
|
||||
messageID: SessionMessage.ID,
|
||||
) => Effect.Effect<SessionMessage.Assistant | undefined, never, never>
|
||||
readonly getShell: (
|
||||
shellID: SessionMessage.Shell["shellID"],
|
||||
) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void, never, never>
|
||||
readonly getAgent: () => Effect.Effect<SessionMessage.AgentSelected["agent"] | undefined>
|
||||
readonly getModel: () => Effect.Effect<SessionMessage.ModelSelected["model"] | undefined>
|
||||
readonly getLocation: () => Effect.Effect<SessionMessage.LocationSwitched["previous"]>
|
||||
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
|
||||
readonly getShell: (shellID: SessionMessage.Shell["shellID"]) => Effect.Effect<SessionMessage.Shell | undefined>
|
||||
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
|
||||
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
|
||||
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
|
||||
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
|
||||
readonly appendMessage: (message: SessionMessage.Info) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type DraftAssistant = WritableDraft<SessionMessage.Assistant>
|
||||
@@ -38,16 +34,14 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
|
||||
const created = DateTime.makeUnsafe(event.created)
|
||||
|
||||
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
|
||||
assistant?.content.findLast(
|
||||
(item): item is DraftTool => item.type === "tool" && (id === undefined || item.id === id),
|
||||
)
|
||||
const latestTool = (assistant: DraftAssistant, id: string) =>
|
||||
assistant.content.findLast((item): item is DraftTool => item.type === "tool" && item.id === id)
|
||||
|
||||
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||
const latestText = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftText => item.type === "text")
|
||||
|
||||
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
const latestReasoning = (assistant: DraftAssistant) =>
|
||||
assistant.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
|
||||
|
||||
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -6,13 +6,13 @@ import path from "path"
|
||||
import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
@@ -26,8 +26,10 @@ import type { SessionSchema } from "./schema.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
|
||||
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
|
||||
type MessageEvent = Exclude<
|
||||
SessionEvent.DurableEvent,
|
||||
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
|
||||
>
|
||||
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
|
||||
@@ -53,16 +55,16 @@ const forkTitle = (value?: string) => {
|
||||
return `${value} (fork #1)`
|
||||
}
|
||||
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage, sign = 1) {
|
||||
function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usage) {
|
||||
return db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
cost: sql`${SessionTable.cost} + ${value.cost * sign}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input * sign}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output * sign}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning * sign}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read * sign}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write * sign}`,
|
||||
cost: sql`${SessionTable.cost} + ${value.cost}`,
|
||||
tokens_input: sql`${SessionTable.tokens_input} + ${value.tokens.input}`,
|
||||
tokens_output: sql`${SessionTable.tokens_output} + ${value.tokens.output}`,
|
||||
tokens_reasoning: sql`${SessionTable.tokens_reasoning} + ${value.tokens.reasoning}`,
|
||||
tokens_cache_read: sql`${SessionTable.tokens_cache_read} + ${value.tokens.cache.read}`,
|
||||
tokens_cache_write: sql`${SessionTable.tokens_cache_write} + ${value.tokens.cache.write}`,
|
||||
time_updated: sql`${SessionTable.time_updated}`,
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
@@ -73,7 +75,7 @@ function applyUsage(db: DatabaseService, sessionID: SessionSchema.ID, value: Usa
|
||||
const publishSessionUsage = Effect.fn("SessionProjector.publishUsage")(function* (
|
||||
db: DatabaseService,
|
||||
bus: Bus.Interface,
|
||||
sessionID: (typeof SessionEvent.Step.Ended.Type)["data"]["sessionID"],
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
const row = yield* db
|
||||
.select({
|
||||
|
||||
@@ -143,7 +143,7 @@ const layer = Layer.effect(
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID).pipe(Effect.ignore), {
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
})
|
||||
if (promoted > 0) step = 1
|
||||
|
||||
@@ -221,10 +221,15 @@ export const make = Effect.gen(function* () {
|
||||
})
|
||||
}
|
||||
|
||||
// After durable output, recovery continues instead of replaying: the
|
||||
// partial assistant message is already persisted history. Any failure
|
||||
// the pre-output gate would retry is continued here, plus interrupted
|
||||
// streams, whose read failures may carry delivery states the retry
|
||||
// policy rejects for full resends.
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
isInterruptedStream(llmFailure) &&
|
||||
(isInterruptedStream(llmFailure) || SessionRunnerRetry.isRetryable(llmFailure)) &&
|
||||
record.outputStarted &&
|
||||
tools.declines.length === 0 &&
|
||||
!tools.interrupted
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ProjectTable } from "../project/sql.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import type { SessionInbox } from "./inbox.js"
|
||||
import type { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { PermissionV1 } from "../v1/permission.js"
|
||||
import { Project } from "../project.js"
|
||||
import type { PermissionV1 } from "@opencode-ai/schema/permission-v1"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
import { Workspace } from "../workspace.js"
|
||||
import type { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import type { Instruction } from "@opencode-ai/schema/instruction"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
@@ -255,7 +255,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
Effect.tap((rows) =>
|
||||
Effect.sync(() => {
|
||||
rows.forEach((row) => {
|
||||
addToolStatus(toolTotals, row.status, 1)
|
||||
addToolStatus(toolTotals, row.status)
|
||||
if (!row.name) return
|
||||
const tool = tools.get(row.name) ?? {
|
||||
name: row.name,
|
||||
@@ -266,7 +266,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
durations: [],
|
||||
}
|
||||
tools.set(row.name, tool)
|
||||
addToolStatus(tool, row.status, 1)
|
||||
addToolStatus(tool, row.status)
|
||||
if (row.duration !== null) tool.durations.push(row.duration)
|
||||
})
|
||||
}),
|
||||
@@ -385,18 +385,17 @@ function tokenTotal(tokens: Tokens) {
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
count: number,
|
||||
) {
|
||||
target.calls += count
|
||||
target.calls++
|
||||
if (status === "completed") {
|
||||
target.succeeded += count
|
||||
target.succeeded++
|
||||
return
|
||||
}
|
||||
if (status === "error") {
|
||||
target.failed += count
|
||||
target.failed++
|
||||
return
|
||||
}
|
||||
target.unfinished += count
|
||||
target.unfinished++
|
||||
}
|
||||
|
||||
function makeDateKey(timezone = "UTC") {
|
||||
|
||||
@@ -3,7 +3,7 @@ export * as SessionUsage from "./usage.js"
|
||||
import type { Usage } from "@opencode-ai/ai"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import type { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import type { Model } from "../model.js"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
|
||||
const finite = (value: number) => (Number.isFinite(value) ? value : 0)
|
||||
const safe = (value: number | undefined) => Math.max(0, finite(value ?? 0))
|
||||
|
||||
@@ -305,7 +305,7 @@ const layer = () =>
|
||||
}),
|
||||
)
|
||||
yield* outputDone.open
|
||||
}).pipe(Effect.catch(() => outputDone.open)),
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(
|
||||
() =>
|
||||
@@ -356,7 +356,6 @@ const layer = () =>
|
||||
Effect.flatMap(() =>
|
||||
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
|
||||
),
|
||||
Effect.catch(() => Effect.void),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
import { lazy } from "../util/lazy.js"
|
||||
import { Wildcard } from "../util/wildcard.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
@@ -355,10 +356,7 @@ function resolve(asset: string) {
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
const load = lazy(initialize)
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
|
||||
@@ -133,36 +133,34 @@ const layer = Layer.effect(
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
source: repo.source,
|
||||
input: {
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
},
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
return files.filter((file) => !ignored.has(file))
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
const files = yield* git.tree.files(comparison.input).pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: comparison.source, paths: files })
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
return yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? files).filter((file) => !ignored.has(file)),
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
- Plugin authors get schema-derived input types at the `ToolDraft.add` boundary through `Tool`.
|
||||
- The heterogeneous Core registry deliberately erases registered definitions to `Tool.Info`. Use `any` at this internal boundary; do not replace it with `unknown`, JSON-value plumbing, casts, or compiled wrapper types solely to preserve type safety after registration.
|
||||
- Executors return model content and metadata alongside declared machine output. Shipped built-ins and plugin tools use the same runtime shape after registration.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and applies generic output bounding.
|
||||
- `src/tool.ts` stores canonical Location registrations, derives LLM definitions, executes tools, and normalizes model content and images.
|
||||
- Built-in tool plugins live in `tool/plugin`.
|
||||
|
||||
Do not add a second executable entry type, registry-owned executor, authorization callback, output-path callback, or legacy normalization path.
|
||||
@@ -53,9 +53,9 @@ Tool filtering is catalog visibility, not execution authorization. A call still
|
||||
|
||||
## Output
|
||||
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary.
|
||||
Built-ins return complete tool responses. `Tool.Snapshot.execute` is the local execution boundary. Generic output bounding is applied by the Session runner after execution.
|
||||
|
||||
Producer capture limits remain local to producers. For example, Bash keeps `AppProcess.maxOutputBytes` and accurately reports stdout/stderr capture loss.
|
||||
Producer capture remains local to producers. Shell stores combined process output in its backing file and returns a bounded tail with the full-output path when truncated.
|
||||
|
||||
## Current Gaps
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export const layer = Layer.effect(
|
||||
server: tool.server,
|
||||
name: tool.name,
|
||||
args: (input ?? {}) as Record<string, unknown>,
|
||||
sessionID: context.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.catchTags({
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as EditTool from "./edit.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -108,7 +108,7 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.edit",
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as GlobTool from "./glob.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -41,7 +41,7 @@ export const toModelContent = (entries: EncodedOutput, truncated = false) => {
|
||||
/** Glob leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.glob",
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("GlobTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as GrepTool from "./grep.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import path from "path"
|
||||
@@ -57,7 +57,7 @@ export const toModelContent = (matches: EncodedOutput, truncated = false) => {
|
||||
/** Grep leaf that defaults its filesystem root to the active Location. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.grep",
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("GrepTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const location = yield* Location.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
@@ -65,7 +65,7 @@ type Prepared =
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: Context) {
|
||||
const environment = yield* Environment.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as QuestionTool from "./question.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
@@ -47,7 +47,7 @@ export const toModelContent = (questions: ReadonlyArray<Question.Prompt>, answer
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.question",
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: Context) {
|
||||
const forms = yield* Form.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as ReadTool from "./read.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { basename, dirname, join } from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -29,7 +29,7 @@ const Output = Schema.Union([ReadToolFileSystem.FileContent, ReadToolFileSystem.
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.read",
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: Context) {
|
||||
const reader = yield* ReadToolFileSystem.Service
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const permission = yield* Permission.Service
|
||||
@@ -169,7 +169,7 @@ export const toModelContent = (path: string, offset: number | undefined, output:
|
||||
] as const
|
||||
|
||||
if (output.type === "list-page") {
|
||||
const start = offset ?? 1
|
||||
const start = offset || 1
|
||||
const content = [
|
||||
output.entries.length === 0
|
||||
? `Read directory ${path}, 0 entries`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ShellTool from "./shell.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Environment } from "../../environment/index.js"
|
||||
@@ -102,7 +102,7 @@ const backgroundResult = (shellID: string, file: string) => ({
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: Context) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const environment = yield* Environment.Service
|
||||
@@ -280,7 +280,8 @@ export const Plugin = {
|
||||
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
|
||||
)
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.id,
|
||||
// CodeMode children share a tool-call ID, but each shell must own its job.
|
||||
id: info.id,
|
||||
type: name,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
@@ -295,7 +296,7 @@ export const Plugin = {
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
|
||||
@@ -304,7 +305,7 @@ export const Plugin = {
|
||||
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.id, info.id, info.command, settled)
|
||||
yield* notifyWhenDone(context.sessionID, job.id, info.id, info.command, settled)
|
||||
return backgroundResult(info.id, info.file)
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SkillTool from "./skill.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -31,7 +31,7 @@ const unableToLoad = (name: string, error?: unknown) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.skill",
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("SkillTool.Plugin")(function* (ctx: Context) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const skills = yield* Skill.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Scope } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
@@ -52,7 +52,7 @@ export const description = [
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.subagent",
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: Context) {
|
||||
const runtime = yield* PluginRuntime.Service
|
||||
const agents = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebFetchTool from "./webfetch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import { HttpClient, type HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
@@ -102,7 +102,7 @@ const convert = (content: string, contentType: string, format: Format) => {
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.webfetch",
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("WebFetchTool.Plugin")(function* (ctx: Context) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as WebSearchTool from "./websearch.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
@@ -26,7 +26,7 @@ const Output = Schema.Struct({
|
||||
})
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.websearch",
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: Context) {
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const websearch = yield* WebSearch.Service
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
export * as WriteTool from "./write.js"
|
||||
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import type { Context } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
@@ -45,7 +45,7 @@ export const toModelContent = (output: Output) =>
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.write",
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: Context) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const fileMutation = yield* FileMutation.Service
|
||||
const environment = yield* Environment.Service
|
||||
|
||||
@@ -5,9 +5,7 @@ export abstract class NamedError extends Error {
|
||||
abstract toObject(): { name: string; data: unknown }
|
||||
|
||||
static hasName(error: unknown, name: string): boolean {
|
||||
return (
|
||||
typeof error === "object" && error !== null && "name" in error && (error as Record<string, unknown>).name === name
|
||||
)
|
||||
return typeof error === "object" && error !== null && "name" in error && error.name === name
|
||||
}
|
||||
|
||||
static create<Name extends string, Fields extends Schema.Struct.Fields>(
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { isRecord } from "@opencode-ai/ai/utils/record"
|
||||
import { ConfigFile } from "@opencode-ai/core/config/file"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { withTempDir } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// No Config, Location, Watcher, Credential, or WellKnown services are provided.
|
||||
const it = testEffect(LayerNode.compile(FSUtil.node))
|
||||
|
||||
describe("ConfigFile", () => {
|
||||
it.live("edits the explicit target and preserves comments and unrelated fields", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = path.join(tmp.path, "global", "opencode.jsonc")
|
||||
const target = path.join(tmp.path, "project", "custom.jsonc")
|
||||
const text = '{\n // Keep this comment.\n "shell": "project",\n "custom": { "value": 1 },\n}\n'
|
||||
yield* fs.writeWithDirs(global, '{ "shell": "global" }')
|
||||
yield* fs.writeWithDirs(target, text)
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "updated"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "updated", custom: { value: 1 } })
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"project"', '"updated"'))
|
||||
expect(yield* fs.readFileString(global)).toBe('{ "shell": "global" }')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves raw substitutions, model shorthand, and legacy shapes unresolved", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"model": "{env:OPENCODE_TEST_CONFIG_MODEL}",
|
||||
"shell": "{file:missing-shell.txt}",
|
||||
"skills": { "paths": ["./skills"] },
|
||||
"agent": { "review": { "model": "acme/reasoner" } },
|
||||
"username": "before"
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.model).toBe("{env:OPENCODE_TEST_CONFIG_MODEL}")
|
||||
expect(draft.shell).toBe("{file:missing-shell.txt}")
|
||||
expect(draft.skills).toEqual({ paths: ["./skills"] })
|
||||
draft.username = "after"
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"before"', '"after"'))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches nested source fields and deletes legacy keys without migrating them", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"agent": {
|
||||
"review": { "description": "before", "hidden": true },
|
||||
// Keep the other definition.
|
||||
"build": { "description": "unchanged" }
|
||||
},
|
||||
"snapshot": true
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
const agent: unknown = draft.agent
|
||||
if (!isRecord(agent) || !isRecord(agent.review)) throw new Error("Missing fixture agent")
|
||||
agent.review.description = "after"
|
||||
agent.review.color = "blue"
|
||||
delete agent.review.hidden
|
||||
delete draft.snapshot
|
||||
})
|
||||
|
||||
expect(updated).toEqual({
|
||||
agent: { review: { description: "after", color: "blue" }, build: { description: "unchanged" } },
|
||||
})
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the other definition.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("patches array elements without rewriting untouched comments", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
const text = `{
|
||||
"plugins": [
|
||||
// Keep the first plugin.
|
||||
"first",
|
||||
"second",
|
||||
// Keep the third plugin.
|
||||
"third",
|
||||
"fourth"
|
||||
]
|
||||
}
|
||||
`
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins[1] = "updated"
|
||||
})
|
||||
expect(yield* fs.readFileString(target)).toBe(text.replace('"second"', '"updated"'))
|
||||
|
||||
const shortened = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 3)
|
||||
})
|
||||
expect(shortened.plugins).toEqual(["first"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(shortened)
|
||||
|
||||
const extended = yield* ConfigFile.update(target, (draft) => {
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.push("added", "last")
|
||||
})
|
||||
expect(extended.plugins).toEqual(["first", "added", "last"])
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(extended)
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the first plugin.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves adjacent comments when deleting properties and array elements", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.jsonc")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
`{
|
||||
"shell": "remove",
|
||||
// Keep the model explanation.
|
||||
"model": "acme/reasoner",
|
||||
"plugins": ["first", "second", /* Keep the plugin explanation. */ "third"],
|
||||
"skills": [/* Keep the source explanation. */ "remove",],
|
||||
}
|
||||
`,
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
delete draft.shell
|
||||
if (!Array.isArray(draft.plugins)) throw new Error("Missing fixture plugins")
|
||||
draft.plugins.splice(1, 1)
|
||||
draft.skills = []
|
||||
})
|
||||
|
||||
expect(parse(yield* fs.readFileString(target))).toEqual(updated)
|
||||
expect(updated).toEqual({ model: "acme/reasoner", plugins: ["first", "third"], skills: [] })
|
||||
expect(yield* fs.readFileString(target)).toContain("// Keep the model explanation.")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the plugin explanation. */")
|
||||
expect(yield* fs.readFileString(target)).toContain("/* Keep the source explanation. */")
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("deletes own JSON keys that also exist on Object.prototype", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(
|
||||
target,
|
||||
'{ "\\u005f_proto__": "remove", "constructor": "remove", "toString": "remove", "shell": "keep" }',
|
||||
)
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
;["__proto__", "constructor", "toString"].forEach((key) => {
|
||||
delete draft[key]
|
||||
})
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("preserves and edits object-valued __proto__ source keys", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "__proto__": { "value": "before" }, "shell": "keep" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(Object.hasOwn(draft, "__proto__")).toBe(true)
|
||||
const entry: unknown = draft["__proto__"]
|
||||
if (!isRecord(entry)) throw new Error("Missing fixture entry")
|
||||
entry.value = "after"
|
||||
})
|
||||
|
||||
expect(updated).toEqual({ ["__proto__"]: { value: "after" }, shell: "keep" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(Object.getPrototypeOf(updated)).toBe(Object.prototype)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects a duplicate-key patch that would not change the effective value", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "first", "shell": "second" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "after"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config patch does not match the requested update: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rereads the selected file for consecutive edits without a watcher", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "shell": "first" }')
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "second"
|
||||
})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("second")
|
||||
draft.username = "added"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "second", username: "added" })
|
||||
|
||||
yield* fs.writeFileString(target, '{ "shell": "external", "username": "added" }')
|
||||
const updated = yield* ConfigFile.update(target, (draft) => {
|
||||
expect(draft.shell).toBe("external")
|
||||
draft.snapshots = false
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
expect(updated).toEqual({ shell: "external", username: "added", snapshots: false })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("serializes concurrent read-modify-write calls", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, '{ "count": 0 }')
|
||||
const increment = ConfigFile.update(target, (draft) => {
|
||||
if (typeof draft.count !== "number") throw new Error("Missing fixture count")
|
||||
draft.count++
|
||||
})
|
||||
yield* Effect.all([increment, increment, increment], { concurrency: "unbounded" })
|
||||
|
||||
expect(yield* fs.readJson(target)).toEqual({ count: 3 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not rewrite no-op or structurally equal edits", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{\r\n "plugins": ["first"]\r\n}'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const before = yield* fs.stat(target)
|
||||
yield* ConfigFile.update(target, () => {})
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.plugins = ["first"]
|
||||
})
|
||||
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect((yield* fs.stat(target)).ino).toEqual(before.ino)
|
||||
expect((yield* fs.stat(target)).mtime).toEqual(before.mtime)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("leaves the file unchanged when a callback throws and permits a later edit", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const cause = new Error("Rejected config update")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
throw cause
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe("Config update failed")
|
||||
expect(error.cause).toBe(cause)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
|
||||
yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "recovered"
|
||||
})
|
||||
expect(yield* fs.readJson(target)).toEqual({ shell: "recovered" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("ignores callback return values instead of replacing the document", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, "{}")
|
||||
|
||||
expect(yield* ConfigFile.update(target, () => new Date(0))).toEqual({})
|
||||
expect(yield* fs.readFileString(target)).toBe("{}")
|
||||
|
||||
const updated = yield* ConfigFile.update(target, (draft) => (draft.shell = "updated"))
|
||||
expect(updated).toEqual({ shell: "updated" })
|
||||
expect(yield* fs.readJson(target)).toEqual(updated)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("rejects non-JSON mutations before writing", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.invalid = Number.NaN
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Config update must produce a JSON object: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
;["", "{", "[]", "null"].forEach((text) => {
|
||||
it.live(`rejects invalid or non-object source ${JSON.stringify(text)}`, () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
yield* fs.writeFileString(target, text)
|
||||
const error = yield* ConfigFile.update(target, () => {
|
||||
throw new Error("Callback must not run")
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Invalid config file: ${target}`)
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
expect(yield* fs.exists(target + ".tmp")).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("reports a missing target without creating it", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "missing.json")
|
||||
const error = yield* ConfigFile.update(target, () => {}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to read config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.exists(target)).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("reports write failures without replacing the target", () =>
|
||||
withTempDir((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
const target = path.join(tmp.path, "opencode.json")
|
||||
const text = '{ "shell": "before" }'
|
||||
yield* fs.writeFileString(target, text)
|
||||
yield* fs.makeDirectory(target + ".tmp")
|
||||
const error = yield* ConfigFile.update(target, (draft) => {
|
||||
draft.shell = "discarded"
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ConfigFile.UpdateError)
|
||||
expect(error.message).toBe(`Failed to write config: ${target}`)
|
||||
expect(error.cause).toBeDefined()
|
||||
expect(yield* fs.readFileString(target)).toBe(text)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
|
||||
@@ -12,6 +12,7 @@ import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "../plugin/fixture"
|
||||
|
||||
@@ -28,27 +29,6 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { $ } from "bun"
|
||||
import { execFile } from "child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { promisify } from "util"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdir } from "./tmpdir"
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
@@ -29,6 +32,30 @@ export async function gitRemote(root: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
export function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
export async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
export async function commit(source: string, content: string, message: string) {
|
||||
await fs.writeFile(path.join(source, "README.md"), content)
|
||||
await git(source, "add", "README.md")
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
const root = path.join(import.meta.dir, "../..")
|
||||
|
||||
export function runLockWorker(entrypoint: string, payload: unknown) {
|
||||
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
|
||||
const proc = spawn(process.execPath, [entrypoint, JSON.stringify(payload)], { cwd: root })
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
||||
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function spawnLockWorker(entrypoint: string, payload: unknown) {
|
||||
return spawn(process.execPath, [entrypoint, JSON.stringify(payload)], {
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopLockWorker(proc: ReturnType<typeof spawnLockWorker>) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||
|
||||
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
|
||||
if (process.platform !== "win32" || !proc.pid) {
|
||||
proc.kill()
|
||||
await closed
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
|
||||
killProc.on("close", () => {
|
||||
proc.kill()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await closed
|
||||
}
|
||||
|
||||
export async function waitForFile(file: string, timeout = 3_000) {
|
||||
const stop = Date.now() + timeout
|
||||
while (Date.now() < stop) {
|
||||
if (await Bun.file(file).exists()) return
|
||||
await Bun.sleep(20)
|
||||
}
|
||||
throw new Error(`Timed out waiting for file: ${file}`)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { GetPromptRequestSchema, ListPromptsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const server = new Server({ name: "prompts", version: "1.0.0" }, { capabilities: { prompts: {} } })
|
||||
|
||||
server.setRequestHandler(ListPromptsRequestSchema, ({ params }) =>
|
||||
Promise.resolve(
|
||||
params?.cursor === "page-2"
|
||||
? { prompts: [{ name: "second", description: "Second prompt" }] }
|
||||
: {
|
||||
prompts: [
|
||||
{
|
||||
name: "first",
|
||||
description: "First prompt",
|
||||
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
||||
},
|
||||
],
|
||||
nextCursor: "page-2",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
server.setRequestHandler(GetPromptRequestSchema, ({ params }) =>
|
||||
Promise.resolve({
|
||||
messages: [{ role: "user", content: { type: "text", text: params.arguments?.topic ?? "missing" } }],
|
||||
}),
|
||||
)
|
||||
|
||||
await server.connect(new StdioServerTransport())
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
export function sessionServer() {
|
||||
const server = new Server({ name: "session", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, () =>
|
||||
Promise.resolve({
|
||||
tools: [{ name: "echo", inputSchema: { type: "object", properties: { text: { type: "string" } } } }],
|
||||
}),
|
||||
)
|
||||
server.setRequestHandler(CallToolRequestSchema, (request) =>
|
||||
Promise.resolve({ content: [], structuredContent: request.params }),
|
||||
)
|
||||
return server
|
||||
}
|
||||
|
||||
if (import.meta.main) await sessionServer().connect(new StdioServerTransport())
|
||||
@@ -6,7 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { branch, commit, gitRemote } from "./fixture/git"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -75,30 +75,6 @@ describe("Git", () => {
|
||||
)
|
||||
})
|
||||
|
||||
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
describe("Git worktrees", () => {
|
||||
it.live("creates, lists, and removes linked worktrees", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -109,9 +85,7 @@ describe("Git worktrees", () => {
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const directory = AbsolutePath.make(yield* Effect.promise(() => fs.realpath(root.path)))
|
||||
const worktree = AbsolutePath.make(`${root.path}-git-worktree`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(worktree, { recursive: true, force: true })))
|
||||
const git = yield* Git.Service
|
||||
const repo = yield* git.repo.discover(directory)
|
||||
if (!repo) throw new Error("Repository not found")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { convertToOpenAICompatibleChatMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
describe("system messages", () => {
|
||||
test("should convert system message content to string", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
@@ -21,7 +21,7 @@ describe("system messages", () => {
|
||||
|
||||
describe("user messages", () => {
|
||||
test("should convert messages with only a text part to a string content", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
@@ -32,7 +32,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -61,7 +61,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should convert messages with image parts from Uint8Array", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -90,7 +90,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle URL-based images", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -117,7 +117,7 @@ describe("user messages", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple text parts without flattening", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
@@ -141,7 +141,7 @@ describe("user messages", () => {
|
||||
|
||||
describe("assistant messages", () => {
|
||||
test("should convert assistant text messages", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello back!" }],
|
||||
@@ -160,7 +160,7 @@ describe("assistant messages", () => {
|
||||
})
|
||||
|
||||
test("should handle assistant message with null content when only tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -195,7 +195,7 @@ describe("assistant messages", () => {
|
||||
})
|
||||
|
||||
test("should concatenate multiple text parts", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -211,7 +211,7 @@ describe("assistant messages", () => {
|
||||
|
||||
describe("tool calls", () => {
|
||||
test("should stringify arguments to tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -262,7 +262,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text output type in tool results", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -286,7 +286,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle multiple tool results as separate messages", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -320,7 +320,7 @@ describe("tool calls", () => {
|
||||
})
|
||||
|
||||
test("should handle text plus multiple tool calls", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -373,7 +373,7 @@ describe("tool calls", () => {
|
||||
|
||||
describe("reasoning (copilot-specific)", () => {
|
||||
test("should omit reasoning_text without reasoning_opaque", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -395,7 +395,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from providerOptions", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -423,7 +423,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should include reasoning_opaque from text part providerOptions", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -450,7 +450,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
})
|
||||
|
||||
test("should handle reasoning-only assistant message", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
@@ -479,7 +479,7 @@ describe("reasoning (copilot-specific)", () => {
|
||||
|
||||
describe("full conversation", () => {
|
||||
test("should convert a multi-turn conversation with reasoning", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
const result = convertToOpenAICompatibleChatMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant.",
|
||||
|
||||
@@ -42,7 +42,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolDefinitions, waitForTool } from "./lib/tool"
|
||||
import { toolDefinitions } from "./lib/tool"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Reference } from "../src/reference"
|
||||
@@ -701,25 +701,9 @@ describe("LocationServiceMap", () => {
|
||||
yield* Reference.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((editor) => editor.provider.update(providerID, () => {}))
|
||||
const supervisor = yield* PluginSupervisor.Service
|
||||
yield* supervisor.flush
|
||||
const registry = yield* Tool.Service
|
||||
// Tool plugins register during the forked PluginSupervisor boot; wait for
|
||||
// every expected tool rather than relying on batch ordering.
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"question",
|
||||
"read",
|
||||
"shell",
|
||||
"skill",
|
||||
"subagent",
|
||||
"webfetch",
|
||||
"websearch",
|
||||
"write",
|
||||
],
|
||||
(name) => waitForTool(registry, name),
|
||||
)
|
||||
return {
|
||||
providers: yield* catalog.provider.all(),
|
||||
tools: yield* toolDefinitions(registry),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { auth, refreshAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { McpOAuth } from "@opencode-ai/core/mcp/oauth"
|
||||
import { Effect } from "effect"
|
||||
@@ -27,6 +28,96 @@ const authorize = (redirect_uri?: string) =>
|
||||
)
|
||||
|
||||
describe("MCP OAuth", () => {
|
||||
test("completes interactive authorization through the loopback callback", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({
|
||||
access_token: "access",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "refresh",
|
||||
expires_in: 3600,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const credential = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const authorization = yield* McpOAuth.authorize({
|
||||
name: "test",
|
||||
config: new ConfigMCP.Remote({
|
||||
type: "remote",
|
||||
url: server.url.href,
|
||||
oauth: { client_id: "client" },
|
||||
}),
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
})
|
||||
const authorizationUrl = new URL(authorization.url)
|
||||
const redirectValue = authorizationUrl.searchParams.get("redirect_uri")
|
||||
const state = authorizationUrl.searchParams.get("state")
|
||||
if (!redirectValue || !state) throw new Error("Missing OAuth redirect parameters")
|
||||
const redirect = new URL(redirectValue)
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", state)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
|
||||
return yield* authorization.callback
|
||||
}),
|
||||
),
|
||||
).finally(() => server.stop(true))
|
||||
|
||||
expect(credential.access).toBe("access")
|
||||
expect(credential.refresh).toBe("refresh")
|
||||
expect(tokenRequests).toHaveLength(1)
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code")
|
||||
expect(tokenRequests[0]?.get("code")).toBe("accepted")
|
||||
expect(tokenRequests[0]?.get("code_verifier")).not.toBeNull()
|
||||
})
|
||||
|
||||
test("refreshes tokens loaded from a persisted credential", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (request.method !== "POST" || url.pathname !== "/token") return new Response(null, { status: 404 })
|
||||
tokenRequests.push(new URLSearchParams(await request.text()))
|
||||
return Response.json({ access_token: "next", token_type: "Bearer" })
|
||||
},
|
||||
})
|
||||
const store = McpOAuth.memoryStore()
|
||||
await store.saveTokens(
|
||||
McpOAuth.toTokens(
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "expired",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() - 1000,
|
||||
metadata: { serverUrl: server.url.href, tokenType: "Bearer" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const oauthProvider = McpOAuth.provider({
|
||||
redirectUrl: "http://127.0.0.1/callback",
|
||||
client: { id: "client" },
|
||||
onRedirect: () => undefined,
|
||||
store,
|
||||
})
|
||||
|
||||
const result = await auth(oauthProvider, { serverUrl: server.url.href }).finally(() => server.stop(true))
|
||||
|
||||
expect(result).toBe("AUTHORIZED")
|
||||
expect(await store.tokens()).toEqual({ access_token: "next", token_type: "Bearer", refresh_token: "refresh" })
|
||||
expect(tokenRequests).toHaveLength(1)
|
||||
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
|
||||
expect(tokenRequests[0]?.get("refresh_token")).toBe("refresh")
|
||||
})
|
||||
|
||||
test("shares concurrent refreshes for the same token", async () => {
|
||||
let requests = 0
|
||||
const pending = Promise.withResolvers<void>()
|
||||
|
||||
@@ -43,8 +43,9 @@ import { Image } from "@opencode-ai/core/image"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { imagePassthrough } from "./lib/image"
|
||||
import { location } from "./fixture/location"
|
||||
import { sessionServer } from "./fixture/mcp-session"
|
||||
import { hostEnvironmentLayer, recordingEnvironmentLayer } from "./fixture/environment"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForCodeModeTool, waitForTool } from "./lib/tool"
|
||||
import { executeTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
||||
let assertion: Deferred.Deferred<Permission.AssertInput> | undefined
|
||||
let decision: Effect.Effect<void, Permission.Error> = Effect.void
|
||||
@@ -380,6 +381,103 @@ test("MCP tool names match V1 sanitization", () => {
|
||||
expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id")
|
||||
})
|
||||
|
||||
for (const transport of ["stdio", "http"]) {
|
||||
for (const codemode of [false, true]) {
|
||||
test(`passes session metadata over ${transport} with codemode=${codemode}`, async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Effect.gen(function* () {
|
||||
if (transport === "stdio")
|
||||
return new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-session.ts")],
|
||||
codemode,
|
||||
})
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const protocol = sessionServer()
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
enableJsonResponse: true,
|
||||
})
|
||||
await protocol.connect(transport)
|
||||
const http = Bun.serve({
|
||||
port: 0,
|
||||
// This request/response fixture does not need a standalone SSE stream.
|
||||
fetch: (request) =>
|
||||
request.method === "GET" ? new Response(null, { status: 405 }) : transport.handleRequest(request),
|
||||
})
|
||||
return {
|
||||
url: http.url.toString(),
|
||||
close: async () => {
|
||||
await protocol.close()
|
||||
await http.stop(true)
|
||||
},
|
||||
}
|
||||
}),
|
||||
(server) => Effect.promise(server.close),
|
||||
)
|
||||
return new ConfigMCP.Remote({ type: "remote", url: server.url, oauth: false, codemode })
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const registration = yield* McpTool.Service
|
||||
const mcp = yield* Mcp.Service
|
||||
yield* registration.flush
|
||||
const snapshot = yield* registry.snapshot()
|
||||
const catalog = yield* mcp.tools()
|
||||
expect(catalog[0]?.inputSchema).toEqual({ type: "object", properties: { text: { type: "string" } } })
|
||||
|
||||
yield* Effect.forEach(
|
||||
["ses_mcp_first", "ses_mcp_second"],
|
||||
(id) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* snapshot.execute({
|
||||
sessionID: Session.ID.make(id),
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: `call_${id}`,
|
||||
name: codemode ? "execute" : "resources_echo",
|
||||
input: codemode
|
||||
? { code: 'return await tools.resources.echo({ text: "hello" })' }
|
||||
: { text: "hello" },
|
||||
},
|
||||
})
|
||||
const expected = {
|
||||
name: "echo",
|
||||
arguments: { text: "hello" },
|
||||
_meta: { sessionID: id, progressToken: expect.any(Number) },
|
||||
}
|
||||
expect(codemode ? JSON.parse(result.output.output) : result.output).toEqual(expected)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
const result = yield* mcp.callTool({ server: "resources", name: "echo" })
|
||||
expect(result.structured).toEqual({
|
||||
name: "echo",
|
||||
arguments: {},
|
||||
_meta: { progressToken: expect.any(Number) },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Mcp.node]), [
|
||||
[Mcp.node, resourceMcpLayer(config)],
|
||||
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
|
||||
[Bus.node, events],
|
||||
[Image.node, imagePassthrough],
|
||||
]),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("preserves output schema validation across paginated tool discovery", async () => {
|
||||
const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } })
|
||||
server.setRequestHandler(ListToolsRequestSchema, ({ params }) =>
|
||||
@@ -474,6 +572,37 @@ test("retains output schemas across paginated MCP discovery", async () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("lists paginated prompts and invokes them through the MCP client", async () => {
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* connect(
|
||||
"prompts",
|
||||
new ConfigMCP.Local({
|
||||
type: "local",
|
||||
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-prompts.ts")],
|
||||
}),
|
||||
import.meta.dir,
|
||||
)
|
||||
return {
|
||||
prompts: yield* connection.prompts(),
|
||||
result: yield* connection.prompt({ name: "first", args: { topic: "Effect" } }),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(result.prompts).toEqual([
|
||||
{
|
||||
name: "first",
|
||||
description: "First prompt",
|
||||
arguments: [{ name: "topic", description: "Topic to explain", required: true }],
|
||||
},
|
||||
{ name: "second", description: "Second prompt", arguments: undefined },
|
||||
])
|
||||
expect(result.result).toEqual({ messages: [{ role: "user", content: { type: "text", text: "Effect" } }] })
|
||||
})
|
||||
|
||||
test("spawns local MCP servers through the location environment", async () => {
|
||||
const spawns: Array<ChildProcess.Command> = []
|
||||
const cwd = path.join(import.meta.dir, "fixture")
|
||||
@@ -1563,7 +1692,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
|
||||
it.effect("advertises MCP output schemas to Code Mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
const execute = toolSet.definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
expect(toolSet.definitions.map((tool) => tool.name)).toEqual([
|
||||
@@ -1582,7 +1713,11 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.status")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.status")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_content_only"),
|
||||
@@ -1605,7 +1740,8 @@ it.effect("returns content-only MCP results through Code Mode", () =>
|
||||
it.effect("advertises MCP tools directly when Code Mode is disabled for the server", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, "direct_lookup")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const definitions = yield* toolDefinitions(registry)
|
||||
const execute = definitions.find((tool) => tool.name === "execute")
|
||||
|
||||
@@ -1621,7 +1757,8 @@ it.effect("fails the call when MCP reports isError", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, "direct_fail")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_is_error"),
|
||||
@@ -1639,7 +1776,8 @@ it.effect("preserves MCP text and media content for the model", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.void
|
||||
const registry = yield* Tool.Service
|
||||
yield* waitForTool(registry, "direct_media")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
|
||||
const execution = yield* executeTool(registry, {
|
||||
sessionID: Session.ID.make("ses_mcp_media"),
|
||||
@@ -1662,7 +1800,10 @@ it.effect("waits for permission before calling an MCP tool", () =>
|
||||
const permission = yield* Deferred.make<void>()
|
||||
decision = Deferred.await(permission)
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const fiber = yield* toolSet
|
||||
.execute({
|
||||
@@ -1703,7 +1844,10 @@ it.effect("does not call MCP when permission is blocked", () =>
|
||||
assertion = yield* Deferred.make<Permission.AssertInput>()
|
||||
decision = Effect.fail(new Permission.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
|
||||
const registry = yield* Tool.Service
|
||||
const toolSet = yield* waitForCodeModeTool(registry, "demo.search")
|
||||
const registration = yield* McpTool.Service
|
||||
yield* registration.flush
|
||||
const toolSet = yield* registry.snapshot()
|
||||
expect(toolSet.codeModeCatalog?.some((tool) => tool.path === "demo.search")).toBe(true)
|
||||
|
||||
const execution = yield* toolSet.execute({
|
||||
sessionID: Session.ID.make("ses_mcp_blocked"),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev"
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { catalogHost, host, integrationHost } from "./host"
|
||||
@@ -29,27 +30,6 @@ const it = testEffect(layer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
|
||||
function withEnv<A, E, R>(variables: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(variables).map((key) => [key, process.env[key]]))
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
it.effect("projects normalized models.dev snapshots into the catalog", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -15,32 +16,10 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* CloudflareAIGatewayPlugin.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, fx: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
fx,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const aiGatewayCalls: Record<string, unknown>[] = []
|
||||
const unifiedCalls: string[] = []
|
||||
const gatewayModelCalls: unknown[] = []
|
||||
@@ -108,9 +87,8 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form: [
|
||||
@@ -132,7 +110,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -153,7 +130,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -198,7 +174,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -228,7 +203,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -266,7 +240,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -298,7 +271,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: "cf-aig-token" }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -321,7 +293,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_ACCOUNT_ID: undefined, CLOUDFLARE_GATEWAY_ID: undefined }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -345,7 +316,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv({ CLOUDFLARE_API_TOKEN: undefined, CF_AIG_TOKEN: undefined }), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -375,7 +345,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) =>
|
||||
@@ -384,9 +353,11 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
}),
|
||||
)
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
(yield* (yield* Integration.Service).get(Integration.ID.make("cloudflare-ai-gateway")))?.methods,
|
||||
).toContainEqual({ type: "key", label: "Gateway API token" })
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
})
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: Model.Info.make({
|
||||
@@ -408,7 +379,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -440,7 +410,6 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
withEnv(cloudflareEnv(), () =>
|
||||
Effect.gen(function* () {
|
||||
resetCalls()
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
|
||||
|
||||
@@ -156,7 +156,6 @@ describe("DynamicProviderPlugin", () => {
|
||||
|
||||
itWithAISDK.live("wraps missing provider factory exports as AISDK init errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const tmp = yield* tempEntrypoint("export const notAProviderFactory = true\n")
|
||||
yield* addPlugin(npmEntrypoint(tmp.entrypoint))
|
||||
@@ -176,7 +175,6 @@ describe("DynamicProviderPlugin", () => {
|
||||
|
||||
itWithAISDK.effect("uses the model modelID for the default language model", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const language = yield* aisdk.language(
|
||||
|
||||
@@ -21,7 +21,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GithubCopilotPlugin.effect(host)
|
||||
})
|
||||
@@ -57,7 +56,8 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("registers GitHub Copilot device OAuth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("github-copilot")))?.methods).toContainEqual({
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
label: "Login with GitHub Copilot",
|
||||
@@ -124,7 +124,8 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("adds Copilot authentication to native Anthropic requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("claude-sonnet-4.5") }),
|
||||
@@ -145,7 +146,8 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies title generation as a background interaction", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_title"),
|
||||
agent: Agent.ID.make("title"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4-nano") }),
|
||||
@@ -158,7 +160,8 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "http.request", {
|
||||
sessionID: Session.ID.make("ses_compaction"),
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.githubCopilot, id: Model.ID.make("gpt-5.4") }),
|
||||
@@ -170,7 +173,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
@@ -221,7 +223,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("selects languageModel when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -240,7 +241,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -259,7 +259,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -320,7 +319,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -350,7 +348,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
@@ -417,7 +414,6 @@ describe("GithubCopilotPlugin", () => {
|
||||
|
||||
it.effect("ignores non-Copilot providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -15,32 +15,10 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GitLabPlugin.effect(host)
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
void mock.module("gitlab-ai-provider", () => ({
|
||||
VERSION: "test-version",
|
||||
createGitLab: (options: Record<string, unknown>) => {
|
||||
@@ -64,7 +42,6 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -102,7 +79,6 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -128,7 +104,6 @@ describe("GitLabPlugin", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
@@ -170,7 +145,6 @@ describe("GitLabPlugin", () => {
|
||||
it.effect("ignores non-GitLab SDK packages", () =>
|
||||
Effect.gen(function* () {
|
||||
gitlabSDKOptions.length = 0
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
@@ -189,7 +163,6 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -223,7 +196,6 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses exact static workflow model ids when the provider recognizes them", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -251,7 +223,6 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses provider feature flags instead of model settings feature flags", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
@@ -278,7 +249,6 @@ describe("GitLabPlugin", () => {
|
||||
|
||||
it.effect("uses agenticChat with provider aiGatewayHeaders and feature flags for normal models", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: [string, unknown][] = []
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
|
||||
})
|
||||
|
||||
describe("KiloPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")),
|
||||
)
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.kilo")
|
||||
})
|
||||
|
||||
it.effect("applies legacy referer headers only to Kilo endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
@@ -15,14 +15,13 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integration = yield* Integration.Service
|
||||
yield* LLMGatewayPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
|
||||
yield* LLMGatewayPlugin.effect(host)
|
||||
})
|
||||
|
||||
describe("LLMGatewayPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")),
|
||||
)
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.llmgateway")
|
||||
})
|
||||
|
||||
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio
|
||||
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Document, Event, Info } from "@opencode-ai/schema/config"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -38,12 +38,10 @@ function eventually<A>(
|
||||
}
|
||||
|
||||
describe("LMStudioPlugin", () => {
|
||||
it.effect("is registered as a built-in provider plugin", () =>
|
||||
Effect.sync(() => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
}),
|
||||
)
|
||||
test("is registered as a built-in provider plugin", () => {
|
||||
expect(LMStudioPlugin.id).toBe("opencode.provider.lmstudio")
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.lmstudio")
|
||||
})
|
||||
|
||||
it.live("discovers local language models with their capabilities and effective context", () =>
|
||||
Effect.acquireUseRelease(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -18,9 +18,9 @@ const addPlugin = Effect.fn(function* () {
|
||||
})
|
||||
|
||||
describe("NvidiaPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")),
|
||||
)
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.nvidia")
|
||||
})
|
||||
|
||||
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -28,8 +28,7 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const integrations = yield* Integration.Service
|
||||
yield* OpenAIPlugin.effect(host).pipe(Effect.provideService(Integration.Service, integrations))
|
||||
yield* OpenAIPlugin.effect(host)
|
||||
})
|
||||
|
||||
const addGithubCopilotPlugin = Effect.fn(function* () {
|
||||
@@ -65,7 +64,8 @@ describe("OpenAIPlugin", () => {
|
||||
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("openai")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("chatgpt-browser"),
|
||||
type: "oauth",
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode-ai/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -18,12 +18,7 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const bus = yield* Bus.Service
|
||||
const integration = yield* Integration.Service
|
||||
yield* OpencodePlugin.effect(host).pipe(
|
||||
Effect.provideService(Bus.Service, bus),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
)
|
||||
yield* OpencodePlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
@@ -45,27 +40,6 @@ function eventually<A>(
|
||||
})
|
||||
}
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
Object.entries(vars).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
})
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() =>
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const cost = (input: number, output = 0) => [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(input),
|
||||
@@ -81,7 +55,8 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("registers account and service account methods", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
expect((yield* (yield* Integration.Service).get(Integration.ID.make("opencode")))?.methods).toEqual([
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("opencode")))?.methods).toEqual([
|
||||
{
|
||||
id: Integration.MethodID.make("device"),
|
||||
type: "oauth",
|
||||
@@ -140,7 +115,8 @@ describe("OpencodePlugin", () => {
|
||||
expect(requests).toContain("POST /console/auth/device/token")
|
||||
expect(requests).toContain("GET /console/api/user")
|
||||
expect(requests).toContain("GET /console/api/orgs")
|
||||
expect((yield* (yield* Credential.Service).list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
|
||||
const credentials = yield* Credential.Service
|
||||
expect((yield* credentials.list(Integration.ID.make("opencode")))[0]?.value).toMatchObject({
|
||||
metadata: { server: `${server.url.origin}/console` },
|
||||
})
|
||||
}),
|
||||
@@ -166,7 +142,8 @@ describe("OpencodePlugin", () => {
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const error = yield* (yield* Integration.Service).oauth
|
||||
const integrations = yield* Integration.Service
|
||||
const error = yield* integrations.oauth
|
||||
.connect({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
@@ -183,7 +160,8 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("rejects non-HTTP OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const error = yield* (yield* Integration.Service).oauth
|
||||
const integrations = yield* Integration.Service
|
||||
const error = yield* integrations.oauth
|
||||
.connect({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
@@ -198,7 +176,8 @@ describe("OpencodePlugin", () => {
|
||||
it.effect("rejects non-string OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const error = yield* (yield* Integration.Service).oauth
|
||||
const integrations = yield* Integration.Service
|
||||
const error = yield* integrations.oauth
|
||||
.connect({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
@@ -299,7 +278,8 @@ describe("OpencodePlugin", () => {
|
||||
settings: { baseURL: `${server.url.origin}/v1`, custom: "value" },
|
||||
headers: { "x-org-id": "org" },
|
||||
})
|
||||
expect(yield* (yield* Integration.Service).get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
const integrations = yield* Integration.Service
|
||||
expect(yield* integrations.get(Integration.ID.make("remote"))).toBeUndefined()
|
||||
|
||||
const model = required(yield* catalog.model.get(Provider.ID.make("remote"), Model.ID.make("model")))
|
||||
expect(model).toMatchObject({
|
||||
@@ -365,19 +345,9 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("paid")),
|
||||
modelID: Model.ID.make("paid"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.opencode, () => {})
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -392,19 +362,9 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("free")),
|
||||
modelID: Model.ID.make("free"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(0),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.opencode, () => {})
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("free"), (draft) => {
|
||||
draft.cost = cost(0)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -421,19 +381,9 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("output-only")),
|
||||
modelID: Model.ID.make("output-only"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(0, 1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.opencode, () => {})
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("output-only"), (draft) => {
|
||||
draft.cost = cost(0, 1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -450,19 +400,9 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("paid")),
|
||||
modelID: Model.ID.make("paid"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.opencode, () => {})
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -484,19 +424,9 @@ describe("OpencodePlugin", () => {
|
||||
})
|
||||
})
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("paid")),
|
||||
modelID: Model.ID.make("paid"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.opencode, () => {})
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -511,23 +441,12 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.opencode),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
settings: { apiKey: "configured" },
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("paid")),
|
||||
modelID: Model.ID.make("paid"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, (draft) => {
|
||||
draft.package = provider.package
|
||||
catalog.provider.update(Provider.ID.opencode, (draft) => {
|
||||
draft.package = Provider.aisdk("test-provider")
|
||||
draft.settings = { apiKey: "configured" }
|
||||
})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.model.update(Provider.ID.opencode, Model.ID.make("paid"), (draft) => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
@@ -542,19 +461,9 @@ describe("OpencodePlugin", () => {
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
const provider = Provider.Info.make({
|
||||
...Provider.Info.empty(Provider.ID.openai),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
})
|
||||
const model = Model.Info.make({
|
||||
...Model.Info.default(provider.id, Model.ID.make("paid")),
|
||||
modelID: Model.ID.make("paid"),
|
||||
package: Provider.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
})
|
||||
catalog.provider.update(provider.id, () => {})
|
||||
catalog.model.update(provider.id, model.id, (draft) => {
|
||||
draft.cost = [...model.cost]
|
||||
catalog.provider.update(Provider.ID.openai, () => {})
|
||||
catalog.model.update(Provider.ID.openai, Model.ID.make("paid"), (draft) => {
|
||||
draft.cost = cost(1)
|
||||
})
|
||||
})
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
@@ -19,9 +19,9 @@ const addPlugin = Effect.fn(function* () {
|
||||
})
|
||||
|
||||
describe("OpenRouterPlugin", () => {
|
||||
it.effect("is registered so legacy OpenRouter behavior can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")),
|
||||
)
|
||||
test("is registered so legacy OpenRouter behavior can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.openrouter")
|
||||
})
|
||||
|
||||
it.effect("applies legacy referer headers only to openrouter", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { SapAICorePlugin } from "@opencode-ai/core/plugin/provider/sap-ai-core"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
@@ -20,32 +21,10 @@ const npm = Npm.Service.of({
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* SapAICorePlugin.effect(host).pipe(Effect.provideService(Npm.Service, npm))
|
||||
})
|
||||
|
||||
function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]]))
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
return previous
|
||||
}),
|
||||
effect,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
for (const [key, value] of Object.entries(previous)) {
|
||||
if (value === undefined) delete process.env[key]
|
||||
else process.env[key] = value
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function model(providerID: string) {
|
||||
return Model.Info.make({
|
||||
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make("sap-model")),
|
||||
@@ -60,7 +39,6 @@ describe("SapAICorePlugin", () => {
|
||||
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
@@ -83,7 +61,6 @@ describe("SapAICorePlugin", () => {
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
@@ -102,7 +79,6 @@ describe("SapAICorePlugin", () => {
|
||||
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
@@ -118,7 +94,6 @@ describe("SapAICorePlugin", () => {
|
||||
|
||||
it.effect("uses the callable SDK for language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const sdk = Object.assign((modelID: string) => ({ modelID, provider: "callable" }), {
|
||||
@@ -136,7 +111,6 @@ describe("SapAICorePlugin", () => {
|
||||
{ AICORE_SERVICE_KEY: undefined, AICORE_DEPLOYMENT_ID: "deployment", AICORE_RESOURCE_GROUP: "resource-group" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
|
||||
@@ -14,7 +14,6 @@ const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* VercelPlugin.effect(host)
|
||||
})
|
||||
@@ -54,7 +53,6 @@ describe("VercelPlugin", () => {
|
||||
|
||||
it.effect("creates @ai-sdk/vercel SDKs for custom provider IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const event = yield* aisdk.runSDK({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
@@ -23,9 +23,9 @@ function required<T>(value: T | undefined): T {
|
||||
}
|
||||
|
||||
describe("ZenmuxPlugin", () => {
|
||||
it.effect("is registered so legacy referer headers can be applied", () =>
|
||||
Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")),
|
||||
)
|
||||
test("is registered so legacy referer headers can be applied", () => {
|
||||
expect(ProviderPlugins.map((item) => item.id)).toContain("opencode.provider.zenmux")
|
||||
})
|
||||
|
||||
it.effect("applies the exact legacy Zenmux headers", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
import type { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ShellSelect } from "@opencode-ai/core/shell/select"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -88,7 +88,7 @@ describe("pty", () => {
|
||||
it.live("returns typed not found errors for missing sessions", () =>
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
const id = "pty_missing" as PtyID
|
||||
const id = PtyID.make("pty_missing")
|
||||
|
||||
for (const result of [
|
||||
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
|
||||
|
||||
@@ -7,8 +7,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Repository } from "@opencode-ai/core/repository"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { branch, git, gitRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { branch, git, read, withRemote } from "./fixture/git"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
@@ -127,21 +126,6 @@ function cacheLayer(root: string) {
|
||||
])
|
||||
}
|
||||
|
||||
function withRemote<A, E, R>(body: (fixture: Awaited<ReturnType<typeof gitRemote>>) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(async () => {
|
||||
const root = await tmpdir()
|
||||
return { root, fixture: await gitRemote(root.path) }
|
||||
}),
|
||||
(input) => body(input.fixture),
|
||||
(input) => Effect.promise(() => input.root[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replace(/\r\n/g, "\n")))
|
||||
}
|
||||
|
||||
function exists(file: string) {
|
||||
return Effect.promise(() =>
|
||||
fs.stat(file).then(
|
||||
|
||||
@@ -379,7 +379,7 @@ describe("SessionRestart background recovery", () => {
|
||||
yield* seedSessions(database, [parent])
|
||||
yield* seedSessions(database, [child], { parent_id: parent, time_suspended: Date.now() })
|
||||
yield* seedBackground(jobs, parent, [
|
||||
{ id: "call-background-shell", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
{ id: "sh_background_orphan", shellID: "sh_background_orphan", command: "sleep 60" },
|
||||
])
|
||||
yield* seedBackground(jobs, child, [{ id: "call-child-shell", shellID: "sh_child_orphan", command: "sleep 30" }])
|
||||
|
||||
@@ -413,7 +413,7 @@ describe("SessionRestart background recovery", () => {
|
||||
text: expect.stringContaining("server restarted"),
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-shell",
|
||||
jobID: "sh_background_orphan",
|
||||
shellID: "sh_background_orphan",
|
||||
state: "cancelled",
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ const testLayer = AppNodeBuilder.build(
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
],
|
||||
) as unknown as Layer.Layer<unknown>
|
||||
)
|
||||
|
||||
const it = testEffect(testLayer)
|
||||
|
||||
|
||||
@@ -60,25 +60,32 @@ const assistantRow = (
|
||||
return { id, session_id: sessionID, type, seq, time_created: DateTime.toEpochMillis(time.created), data }
|
||||
}
|
||||
|
||||
const seedSession = (overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
...overrides,
|
||||
})
|
||||
.run()
|
||||
return db
|
||||
})
|
||||
|
||||
describe("SessionProjector", () => {
|
||||
it.effect("does not settle a pending manual compaction on an auto failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const inputID = SessionMessage.ID.make("msg_manual_compaction")
|
||||
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
|
||||
@@ -95,22 +102,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("loads legacy revert storage into canonical state", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
const db = yield* seedSession()
|
||||
const legacy = JSON.stringify({
|
||||
messageID: "msg_boundary",
|
||||
snapshot: "tree",
|
||||
@@ -131,28 +123,14 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects staged, cleared, and committed reverts", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
cost: 1.25,
|
||||
tokens_input: 10,
|
||||
tokens_output: 4,
|
||||
tokens_reasoning: 2,
|
||||
tokens_cache_read: 3,
|
||||
tokens_cache_write: 1,
|
||||
})
|
||||
.run()
|
||||
const db = yield* seedSession({
|
||||
cost: 1.25,
|
||||
tokens_input: 10,
|
||||
tokens_output: 4,
|
||||
tokens_reasoning: 2,
|
||||
tokens_cache_read: 3,
|
||||
tokens_cache_write: 1,
|
||||
})
|
||||
const boundary = SessionMessage.ID.make("msg_boundary")
|
||||
const earlier = SessionMessage.ID.make("msg_earlier")
|
||||
yield* db
|
||||
@@ -227,24 +205,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("orders projected messages and context by durable aggregate sequence", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* bus.publish(SessionEvent.InboxEnqueued, {
|
||||
@@ -300,24 +261,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("maps malformed persisted rows consistently while single-message lookup defects", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const messageID = SessionMessage.ID.make("msg_malformed")
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
@@ -344,24 +288,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("consumes the pending row and projects the message at promotion", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const id = SessionMessage.ID.make("msg_admitted")
|
||||
const admitted = yield* SessionInbox.admit(db, bus, {
|
||||
@@ -387,26 +314,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects durable context messages supported by the updater", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
agent: "plan",
|
||||
model: previousModel,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession({ agent: "plan", model: previousModel })
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
@@ -532,24 +440,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("rejects distinct creator events that reuse one projected message ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const id = SessionMessage.ID.make("msg_creator_collision")
|
||||
const { id: _, type, ...data } = encodeMessage({ id, type: "synthetic", text: "existing", time: { created } })
|
||||
@@ -576,24 +467,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const first = SessionMessage.ID.make("msg_retry_first")
|
||||
const second = SessionMessage.ID.make("msg_retry_second")
|
||||
@@ -643,24 +517,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("does not infer restart continuation from lifecycle history", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const bus = yield* Bus.Service
|
||||
const suspended = () =>
|
||||
db
|
||||
@@ -680,24 +537,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("updates only the newest incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
@@ -757,24 +597,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("projects ended and failed step terminal state", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
const endedID = SessionMessage.ID.make("msg_ended")
|
||||
const failedID = SessionMessage.ID.make("msg_failed")
|
||||
yield* db
|
||||
@@ -844,24 +667,7 @@ describe("SessionProjector", () => {
|
||||
|
||||
it.effect("does not revive a stale incomplete assistant projection", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: Project.ID.global,
|
||||
slug: "test",
|
||||
directory: "/project",
|
||||
title: "test",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const db = yield* seedSession()
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
RateLimitError,
|
||||
UnknownProviderError,
|
||||
} from "@opencode-ai/ai"
|
||||
import * as OpenAIChat from "@opencode-ai/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
@@ -847,18 +848,19 @@ function* verifyEphemeralDeltas(s: Scenario, kind: FragmentKind) {
|
||||
function* verifyPartialFlushOnFailure(s: Scenario, kind: FragmentKind) {
|
||||
const prompt = `Fail after ${kind}`
|
||||
const fixture = fragmentFixture(kind, fragmentID(kind, "partial"), ["Partial"])
|
||||
const failure = providerUnavailable()
|
||||
// A non-retryable failure keeps the step terminal so the flushed fragments settle durably.
|
||||
const failure = invalidRequest()
|
||||
yield* s.admit(prompt)
|
||||
yield* s.llm.push(TestLLM.failAfter(failure, ...fixture.partialEvents))
|
||||
|
||||
expect(yield* s.resume.pipe(Effect.flip)).toBe(failure)
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user(prompt),
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
|
||||
kind === "tool input"
|
||||
? Expected.failedTool(
|
||||
{ id: fragmentID(kind, "partial") },
|
||||
{ error: { type: "provider.transport", message: "Provider unavailable" } },
|
||||
{ error: { type: "provider.invalid-request", message: "Invalid request" } },
|
||||
)
|
||||
: fixture.expectedContent,
|
||||
]),
|
||||
@@ -2903,6 +2905,54 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("keeps one durable reasoning part when reasoning closes after text", function* (s) {
|
||||
yield* s.admit("Think and answer")
|
||||
|
||||
const details = [{ type: "reasoning.text", text: "thinking", signature: "signed", index: 0 }]
|
||||
yield* s.llm.push(
|
||||
TestLLM.stop(
|
||||
LLMEvent.reasoningStart({ id: "reasoning-0" }),
|
||||
LLMEvent.reasoningDelta({ id: "reasoning-0", text: "thinking" }),
|
||||
LLMEvent.textStart({ id: "text-0" }),
|
||||
LLMEvent.textDelta({ id: "text-0", text: "Hello" }),
|
||||
LLMEvent.textDelta({ id: "text-0", text: " world" }),
|
||||
LLMEvent.reasoningEnd({
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
LLMEvent.textEnd({ id: "text-0" }),
|
||||
),
|
||||
)
|
||||
yield* s.resume
|
||||
yield* replaySessionProjection(sessionID)
|
||||
|
||||
const assistant = requireAssistant(yield* s.context)
|
||||
expect(assistant.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Think and answer"),
|
||||
Expected.assistant({}, [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
state: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
},
|
||||
{ type: "text", text: "Hello world" },
|
||||
]),
|
||||
])
|
||||
|
||||
yield* s.admit("Continue")
|
||||
yield* s.llm.push([])
|
||||
yield* s.resume
|
||||
|
||||
expect(s.requests[1]?.messages[1]?.content.filter((part) => part.type === "reasoning")).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
scenario("restores durable text provider metadata in the next request", function* (s) {
|
||||
yield* s.admit("Check first")
|
||||
|
||||
@@ -3961,7 +4011,8 @@ describe("SessionRunnerLLM", () => {
|
||||
|
||||
scenario("awaits started local tools before surfacing provider stream failure", function* (s) {
|
||||
yield* s.admit("Settle before failing")
|
||||
const failure = providerUnavailable()
|
||||
// Non-retryable so the step settles terminally instead of continuing after tool output.
|
||||
const failure = invalidRequest()
|
||||
const tools = yield* s.blockTools()
|
||||
yield* s.llm.push(
|
||||
TestLLM.failAfter(
|
||||
@@ -4516,6 +4567,74 @@ describe("SessionRunnerLLM", () => {
|
||||
])
|
||||
})
|
||||
|
||||
scenario("continues after a mid-stream rate limit honoring retry-after", function* (s) {
|
||||
yield* s.admit("Continue after rate limit")
|
||||
yield* s.llm.push(
|
||||
TestLLM.failAfter(
|
||||
rateLimited(5_000),
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "rate-limited-partial" }),
|
||||
LLMEvent.textDelta({ id: "rate-limited-partial", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
yield* s.llm.push(TestLLM.text(" continuation", "rate-limit-continuation"))
|
||||
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* s.llm.wait(1)
|
||||
yield* TestClock.adjust("4999 millis")
|
||||
expect(s.requests).toHaveLength(1)
|
||||
yield* TestClock.adjust("1 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(s.requests[1]?.messages.at(-2)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Partial" }],
|
||||
})
|
||||
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
|
||||
})
|
||||
expect(yield* recordedEventTypes(sessionID)).toContain("session.retry.scheduled.1")
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Continue after rate limit"),
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.rate-limit" } }, [Expected.text("Partial")]),
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
|
||||
])
|
||||
})
|
||||
|
||||
scenario("continues after an unrecognized mid-stream provider failure", function* (s) {
|
||||
const failure = new AIError({ reason: new UnknownProviderError({ message: "Provider returned error" }) })
|
||||
yield* s.admit("Continue after unknown failure")
|
||||
yield* s.llm.push(
|
||||
TestLLM.failAfter(
|
||||
failure,
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
LLMEvent.textStart({ id: "unknown-failure-partial" }),
|
||||
LLMEvent.textDelta({ id: "unknown-failure-partial", text: "Partial" }),
|
||||
),
|
||||
)
|
||||
yield* s.llm.push(TestLLM.text(" continuation", "unknown-failure-continuation"))
|
||||
|
||||
const run = yield* s.resume.pipe(Effect.forkChild)
|
||||
yield* s.llm.wait(1)
|
||||
yield* TestClock.adjust("2400 millis")
|
||||
yield* Fiber.join(run)
|
||||
|
||||
expect(s.requests).toHaveLength(2)
|
||||
expect(s.requests[1]?.messages.at(-1)).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }],
|
||||
})
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Continue after unknown failure"),
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.unknown" } }, [Expected.text("Partial")]),
|
||||
{ type: "synthetic", text: INCOMPLETE_STREAM_CONTINUATION },
|
||||
Expected.assistant({ finish: "stop" }, [Expected.text(" continuation")]),
|
||||
])
|
||||
})
|
||||
|
||||
scenario("lowers interrupted reasoning before continuing an incomplete stream", function* (s) {
|
||||
yield* s.admit("Continue interrupted reasoning")
|
||||
yield* s.llm.push(
|
||||
@@ -5273,7 +5392,8 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
|
||||
scenario("durably fails a hosted tool left unresolved by a raw provider stream failure", function* (s) {
|
||||
const failure = providerUnavailable()
|
||||
// Non-retryable so the step settles terminally instead of continuing after tool output.
|
||||
const failure = invalidRequest()
|
||||
yield* s.llm.push(
|
||||
Stream.concat(
|
||||
Stream.fromIterable([LLMEvent.stepStart({ index: 0 }), hostedCall("call-hosted-raw-failure", "effect")]),
|
||||
@@ -5297,7 +5417,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* s.context).toMatchObject([
|
||||
Expected.user("Fail hosted tool on raw failure"),
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.transport", message: "Provider unavailable" } }, [
|
||||
Expected.assistant({ finish: "error", error: { type: "provider.invalid-request", message: "Invalid request" } }, [
|
||||
Expected.failedTool({ id: "call-hosted-raw-failure" }, {}),
|
||||
]),
|
||||
])
|
||||
|
||||
@@ -198,18 +198,20 @@ beforeEach(() => {
|
||||
titleStream = successfulTitle
|
||||
})
|
||||
|
||||
const enableTitleAgent = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("generates a title from the sole user message and renames the session", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_generate")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
@@ -240,17 +242,8 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
|
||||
it.effect("uses a small model from the primary provider", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
selectedSmall = small
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_small_model")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Use a small model for this title")
|
||||
@@ -267,20 +260,12 @@ it.effect("uses a small model from the primary provider", () =>
|
||||
|
||||
it.effect("falls back to the primary model when the small model fails", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = () =>
|
||||
requests.length === 1
|
||||
? Stream.make(LLMEvent.providerError({ message: "Small model unavailable" }))
|
||||
: successfulTitle()
|
||||
selectedSmall = lowSmall
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_small_fallback")
|
||||
yield* insertSession(
|
||||
sessionID,
|
||||
@@ -314,16 +299,7 @@ it.effect("falls back to the primary model when the small model fails", () =>
|
||||
|
||||
it.effect("generates from the first user message after later messages exist", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_second_message")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "First message")
|
||||
@@ -342,16 +318,7 @@ it.effect("generates from the first user message after later messages exist", ()
|
||||
|
||||
it.effect("retries a legacy persisted fallback title", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_legacy")
|
||||
const created = Date.parse("2026-07-30T18:45:03.662Z")
|
||||
yield* insertSession(sessionID, "New session - 2026-07-30T18:45:03.662Z", created)
|
||||
@@ -368,16 +335,7 @@ it.effect("retries a legacy persisted fallback title", () =>
|
||||
|
||||
it.effect("generates a title for an explicitly requested child session", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_child")
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
@@ -411,8 +369,6 @@ it.effect("generates a title for an explicitly requested child session", () =>
|
||||
|
||||
it.effect("does not generate when the title agent is removed", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const sessionID = Session.ID.make("ses_title_no_agent")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Help me debug the failing build")
|
||||
@@ -429,14 +385,7 @@ it.effect("does not generate when the title agent is removed", () =>
|
||||
|
||||
it.effect("regenerates an existing title using the title agent", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_regenerate")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Investigate the login failure")
|
||||
@@ -480,14 +429,7 @@ it.effect("regenerates an existing title using the title agent", () =>
|
||||
|
||||
it.effect("bounds regeneration context while preserving the original request and recent conversation", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_regenerate_bounded")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, `ORIGINAL_GOAL ${"a".repeat(3_000)} OMITTED_ORIGINAL_END`)
|
||||
@@ -509,14 +451,7 @@ it.effect("bounds regeneration context while preserving the original request and
|
||||
|
||||
it.effect("preserves the existing title when regeneration fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_regenerate_failure")
|
||||
yield* insertSession(sessionID, "Original title")
|
||||
yield* prompt(sessionID, "Fail to regenerate this title")
|
||||
@@ -533,15 +468,7 @@ it.effect("preserves the existing title when regeneration fails", () =>
|
||||
|
||||
it.effect("retries after a failed title request", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_retry")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Retry this title")
|
||||
@@ -560,14 +487,7 @@ it.effect("retries after a failed title request", () =>
|
||||
|
||||
it.effect("does not rename after a failed title stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_stream_failure")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Fail this title stream")
|
||||
@@ -589,16 +509,7 @@ it.effect("does not rename after a failed title stream", () =>
|
||||
|
||||
it.effect("keeps session context hooks away from title requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
// Context hooks shape the agent conversation; title generation is not part of
|
||||
// it, so it opts out and the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -621,15 +532,7 @@ it.effect("keeps session context hooks away from title requests", () =>
|
||||
|
||||
it.effect("preserves a manual rename completed while generation is in flight", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
yield* enableTitleAgent
|
||||
const sessionID = Session.ID.make("ses_title_manual_rename")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Generate this title")
|
||||
|
||||
@@ -678,6 +678,40 @@ describe("ReadTool", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes a zero directory offset in the model heading", () =>
|
||||
Effect.gen(function* () {
|
||||
readResult = new ReadToolFileSystem.ListPage({
|
||||
type: "list-page",
|
||||
entries: [FileSystem.Entry.make({ path: RelativePath.make("index.ts"), type: "file" })],
|
||||
truncated: true,
|
||||
next: 2,
|
||||
})
|
||||
const registry = yield* Tool.Service
|
||||
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-read-directory-zero",
|
||||
name: "read",
|
||||
input: { path: "src", offset: 0, limit: 1 },
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe("completed")
|
||||
if (result.status !== "completed") return
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Read directory src, entries 1-1\nindex.ts\n[Output truncated. Continue reading with offset: 2]",
|
||||
},
|
||||
])
|
||||
expect(readCalls).toEqual([
|
||||
{ input: AbsolutePath.make(path.join(process.cwd(), "src")), page: { offset: 0, limit: 1 } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not list a directory when permission is denied", () =>
|
||||
Effect.gen(function* () {
|
||||
allow = false
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { z } from "zod"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { Info } from "@opencode-ai/schema/tool"
|
||||
import { Tool } from "../src/tool"
|
||||
import { definition, execute } from "../src/tool/runtime"
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_tool_schema"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_tool_schema"),
|
||||
id: Tool.CallID.make("call_tool_schema"),
|
||||
progress: () => Effect.void,
|
||||
} satisfies Tool.Context
|
||||
|
||||
test("tools are structural values", async () => {
|
||||
const config = {
|
||||
name: "foreign",
|
||||
@@ -136,7 +147,7 @@ test("portable schemas validate and describe typed tools", async () => {
|
||||
inputSchema: { type: "object", properties: { count: { type: "string" } } },
|
||||
outputSchema: { type: "string" },
|
||||
})
|
||||
const result = await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))
|
||||
const result = await Effect.runPromise(execute(tool, { count: "41" }, context))
|
||||
expect(result.output).toBe("42")
|
||||
})
|
||||
|
||||
@@ -166,10 +177,10 @@ test("Zod schemas validate, transform, and describe typed tools", async () => {
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { count: "41" }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { count: "41" }, context))).toMatchObject({
|
||||
output: { count: 42 },
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { count: 41 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "zod":\n- count: Invalid input: expected string, received number\n\nArguments provided:\n{\n "count": 41\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -205,7 +216,7 @@ test("portable schema failures become tool failures", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
},
|
||||
1,
|
||||
{} as Tool.Context,
|
||||
context,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -228,9 +239,7 @@ test("Effect schema failures use normalized input issues", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "effect":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -259,7 +268,7 @@ test("input error prompts limit normalized issues", async () => {
|
||||
execute: () => Effect.succeed({ content: "unused" }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "limited":\n- root: issue 1\n- root: issue 2\n- root: issue 3\n- root: issue 4\n- root: issue 5\n- ...and 1 more issue\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -278,7 +287,7 @@ test("canonical results carry metadata with typed output", async () => {
|
||||
execute: ({ value }) => Effect.succeed({ output: { value, internal: true }, metadata: { value }, content: value }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(tool.execute({ value: "out" }, {} as Tool.Context))).toEqual({
|
||||
expect(await Effect.runPromise(tool.execute({ value: "out" }, context))).toEqual({
|
||||
output: { value: "out", internal: true },
|
||||
metadata: { value: "out" },
|
||||
content: "out",
|
||||
@@ -312,33 +321,29 @@ test("raw JSON schemas validate and decode tool input", async () => {
|
||||
description: "Raw tool",
|
||||
inputSchema: input,
|
||||
})
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, {} as Tool.Context))).toEqual({
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok", extra: true }, context))).toEqual({
|
||||
output: undefined,
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, {}, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Missing key\n\nArguments provided:\n{}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: "ok", nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": "ok",\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, {} as Tool.Context))),
|
||||
).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1, nested: { count: 0 } }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "raw":\n- value: Expected string\n- nested.count: Expected a value greater than or equal to 1\n\nArguments provided:\n{\n "value": 1,\n "nested": {\n "count": 0\n }\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -359,10 +364,10 @@ test("raw JSON schemas resolve draft-07 definitions", async () => {
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok" }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { value: "ok" }, context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":"ok"}' }],
|
||||
})
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, {} as Tool.Context)))).toEqual(
|
||||
expect(await Effect.runPromise(Effect.flip(execute(tool, { value: 1 }, context)))).toEqual(
|
||||
new Tool.Error({
|
||||
message:
|
||||
'Invalid arguments for tool "draft-07":\n- value: Expected value\n\nArguments provided:\n{\n "value": 1\n}\n\nUpdate the arguments and call the tool again.',
|
||||
@@ -381,7 +386,7 @@ test("raw JSON schemas pass input through when they cannot be imported", async (
|
||||
execute: (input) => Effect.succeed({ content: JSON.stringify(input) }),
|
||||
}
|
||||
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, {} as Tool.Context))).toMatchObject({
|
||||
expect(await Effect.runPromise(execute(tool, { value: 1, extra: true }, context))).toMatchObject({
|
||||
content: [{ type: "text", text: '{"value":1,"extra":true}' }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -702,6 +702,42 @@ describe("ShellTool ordinary shell syntax", () => {
|
||||
})
|
||||
|
||||
describe("ShellTool", () => {
|
||||
it.live("returns both parallel CodeMode shell results", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
yield* registry.transform((draft) =>
|
||||
draft.update("shell", (tool) => {
|
||||
tool.options = { ...tool.options, codemode: true }
|
||||
}),
|
||||
)
|
||||
const command = isWindows ? helloCommand : `${helloCommand}; sleep 0.1`
|
||||
const inputs = ["one", "two"].map((text) => JSON.stringify({ command: command.replace("hello", text) }))
|
||||
const result = yield* executeTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-parallel-shells",
|
||||
name: "execute",
|
||||
input: { code: `return await Promise.all([tools.shell(${inputs[0]}), tools.shell(${inputs[1]})])` },
|
||||
},
|
||||
}).pipe(Effect.timeout("3 seconds"))
|
||||
expect(result.status).toBe("completed")
|
||||
expect(JSON.parse(result.output.output)).toEqual([
|
||||
{ output: "one", exit: 0, truncated: false, status: "completed" },
|
||||
{ output: "two", exit: 0, truncated: false, status: "completed" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"registers and returns real successful output from the active Location",
|
||||
() =>
|
||||
@@ -1342,7 +1378,7 @@ describe("ShellTool", () => {
|
||||
description: bodyExitCommand,
|
||||
metadata: {
|
||||
source: "shell",
|
||||
jobID: "call-background-nonzero",
|
||||
jobID: shellID,
|
||||
shellID,
|
||||
state: "completed",
|
||||
exit: 7,
|
||||
@@ -1375,7 +1411,7 @@ describe("ShellTool", () => {
|
||||
)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* executeTool(registry, {
|
||||
const settled = yield* executeTool(registry, {
|
||||
...call({ command: "exit 7", background: true }, "call-background-silent-nonzero"),
|
||||
// The command can finish while its initial progress update is being published.
|
||||
progress: (update) =>
|
||||
@@ -1386,7 +1422,7 @@ describe("ShellTool", () => {
|
||||
|
||||
expect(yield* Deferred.await(persisted)).toMatchObject([
|
||||
{
|
||||
id: "call-background-silent-nonzero",
|
||||
id: settled.metadata?.shellID,
|
||||
status: "completed",
|
||||
output: "(no output)\n\nCommand exited with code 7.",
|
||||
},
|
||||
@@ -1519,9 +1555,10 @@ describe("ShellTool", () => {
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* backgroundWhenReady(remaining - 1)
|
||||
})
|
||||
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
|
||||
const backgrounded = yield* backgroundWhenReady()
|
||||
const settled = yield* Fiber.join(waiting)
|
||||
const shellID = typeof settled.metadata?.shellID === "string" ? settled.metadata.shellID : undefined
|
||||
expect(backgrounded).toMatchObject([{ id: shellID, type: "shell" }])
|
||||
expect(settled.metadata).toMatchObject({ truncated: false })
|
||||
expect(shellID).toStartWith("sh_")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { spawn } from "child_process"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
@@ -9,15 +8,13 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
function lock(dir: string, key: string) {
|
||||
return path.join(dir, Hash.fast(key) + ".lock")
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return fs
|
||||
.stat(file)
|
||||
@@ -42,59 +39,8 @@ type Msg = {
|
||||
done?: string
|
||||
}
|
||||
|
||||
const root = path.join(import.meta.dir, "../..")
|
||||
const worker = path.join(import.meta.dir, "../fixture/effect-flock-worker.ts")
|
||||
|
||||
function run(msg: Msg) {
|
||||
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
|
||||
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], { cwd: root })
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
||||
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
||||
proc.on("close", (code) => {
|
||||
resolve({ code: code ?? 1, stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr) })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function spawnWorker(msg: Msg) {
|
||||
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
}
|
||||
|
||||
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||
|
||||
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
|
||||
|
||||
if (process.platform !== "win32" || !proc.pid) {
|
||||
proc.kill()
|
||||
await closed
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
|
||||
killProc.on("close", () => {
|
||||
proc.kill()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await closed
|
||||
}
|
||||
|
||||
async function waitForFile(file: string, timeout = 3_000) {
|
||||
const stop = Date.now() + timeout
|
||||
while (Date.now() < stop) {
|
||||
if (await exists(file)) return
|
||||
await sleep(20)
|
||||
}
|
||||
throw new Error(`Timed out waiting for file: ${file}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test layer
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -122,14 +68,13 @@ describe("util.effect-flock", () => {
|
||||
"acquire and release via scoped Effect",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const lockDir = lock(dir, "eflock:acquire")
|
||||
|
||||
yield* Effect.scoped(flock.acquire("eflock:acquire", dir))
|
||||
|
||||
expect(yield* Effect.promise(() => exists(lockDir))).toBe(false)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -137,7 +82,7 @@ describe("util.effect-flock", () => {
|
||||
"supports an acquisition timeout",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:timeout"
|
||||
|
||||
@@ -150,7 +95,6 @@ describe("util.effect-flock", () => {
|
||||
expect(error._tag).toBe("LockTimeoutError")
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -158,7 +102,7 @@ describe("util.effect-flock", () => {
|
||||
"withLock data-first",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
|
||||
let hit = false
|
||||
@@ -170,7 +114,6 @@ describe("util.effect-flock", () => {
|
||||
dir,
|
||||
)
|
||||
expect(hit).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -178,7 +121,7 @@ describe("util.effect-flock", () => {
|
||||
"withLock pipeable",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
|
||||
let hit = false
|
||||
@@ -186,7 +129,6 @@ describe("util.effect-flock", () => {
|
||||
hit = true
|
||||
}).pipe(flock.withLock("eflock:pipe", dir))
|
||||
expect(hit).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -194,7 +136,7 @@ describe("util.effect-flock", () => {
|
||||
"writes owner metadata",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:meta"
|
||||
const file = path.join(lock(dir, key), "meta.json")
|
||||
@@ -211,7 +153,6 @@ describe("util.effect-flock", () => {
|
||||
expect(typeof json.createdAt).toBe("string")
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -219,7 +160,7 @@ describe("util.effect-flock", () => {
|
||||
"breaks stale lock dirs",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:stale"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -239,7 +180,6 @@ describe("util.effect-flock", () => {
|
||||
dir,
|
||||
)
|
||||
expect(hit).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -247,7 +187,7 @@ describe("util.effect-flock", () => {
|
||||
"recovers from stale breaker",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:stale-breaker"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -271,7 +211,6 @@ describe("util.effect-flock", () => {
|
||||
)
|
||||
expect(hit).toBe(true)
|
||||
expect(yield* Effect.promise(() => exists(breaker))).toBe(false)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -279,7 +218,7 @@ describe("util.effect-flock", () => {
|
||||
"detects compromise when lock dir removed",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:compromised"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -294,7 +233,6 @@ describe("util.effect-flock", () => {
|
||||
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("missing")
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -302,7 +240,7 @@ describe("util.effect-flock", () => {
|
||||
"detects token mismatch",
|
||||
Effect.gen(function* () {
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
const key = "eflock:token"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -323,7 +261,6 @@ describe("util.effect-flock", () => {
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
expect(Exit.isFailure(result) ? Cause.pretty(result.cause) : "").toContain("token mismatch")
|
||||
expect(yield* Effect.promise(() => exists(lockDir))).toBe(true)
|
||||
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -332,18 +269,18 @@ describe("util.effect-flock", () => {
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
const flock = yield* EffectFlock.Service
|
||||
const tmp = yield* Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), "eflock-test-")))
|
||||
const tmp = (yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("eflock-test-")))).path
|
||||
const dir = path.join(tmp, "locks")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.chmod(dir, 0o500)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(dir, 0o700)))
|
||||
yield* Effect.promise(() => fs.chmod(dir, 0o500))
|
||||
|
||||
const result = yield* flock.withLock(Effect.void, "eflock:perm", dir).pipe(Effect.exit)
|
||||
// oxlint-disable-next-line no-base-to-string -- Exit has a useful toString for test assertions
|
||||
expect(String(result)).toContain("PermissionDenied")
|
||||
yield* Effect.promise(() => fs.chmod(dir, 0o700).then(() => fs.rm(tmp, { recursive: true, force: true })))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -359,7 +296,9 @@ describe("util.effect-flock", () => {
|
||||
|
||||
try {
|
||||
const out = await Promise.all(
|
||||
Array.from({ length: n }, () => run({ key: "eflock:stress", dir, done, active, holdMs: 30 })),
|
||||
Array.from({ length: n }, () =>
|
||||
runLockWorker(worker, { key: "eflock:stress", dir, done, active, holdMs: 30 } satisfies Msg),
|
||||
),
|
||||
)
|
||||
|
||||
expect(out.map((x) => x.code)).toEqual(Array.from({ length: n }, () => 0))
|
||||
@@ -385,11 +324,11 @@ describe("util.effect-flock", () => {
|
||||
const dir = path.join(tmp, "locks")
|
||||
const ready = path.join(tmp, "ready")
|
||||
|
||||
const proc = spawnWorker({ key: "eflock:crash", dir, ready, holdMs: 120_000 })
|
||||
const proc = spawnLockWorker(worker, { key: "eflock:crash", dir, ready, holdMs: 120_000 } satisfies Msg)
|
||||
|
||||
try {
|
||||
await waitForFile(ready, 5_000)
|
||||
await stopWorker(proc)
|
||||
await stopLockWorker(proc)
|
||||
|
||||
// Backdate lock files so they're past STALE_MS (60s)
|
||||
const lockDir = lock(dir, "eflock:crash")
|
||||
@@ -399,11 +338,11 @@ describe("util.effect-flock", () => {
|
||||
await fs.utimes(path.join(lockDir, "meta.json"), old, old).catch(() => {})
|
||||
|
||||
const done = path.join(tmp, "done.log")
|
||||
const result = await run({ key: "eflock:crash", dir, done, holdMs: 10 })
|
||||
const result = await runLockWorker(worker, { key: "eflock:crash", dir, done, holdMs: 10 } satisfies Msg)
|
||||
expect(result.code).toBe(0)
|
||||
expect(result.stderr.toString()).toBe("")
|
||||
} finally {
|
||||
await stopWorker(proc).catch(() => {})
|
||||
await stopLockWorker(proc).catch(() => {})
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import { spawn } from "child_process"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Flock } from "@opencode-ai/util/flock"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { runLockWorker, spawnLockWorker, stopLockWorker, waitForFile } from "../fixture/lock-worker"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
|
||||
type Msg = {
|
||||
key: string
|
||||
@@ -19,29 +19,12 @@ type Msg = {
|
||||
done?: string
|
||||
}
|
||||
|
||||
const root = path.join(import.meta.dir, "../..")
|
||||
const worker = path.join(import.meta.dir, "../fixture/flock-worker.ts")
|
||||
|
||||
async function tmpdir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "flock-test-"))
|
||||
return {
|
||||
path: dir,
|
||||
async [Symbol.asyncDispose]() {
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function lock(dir: string, key: string) {
|
||||
return path.join(dir, Hash.fast(key) + ".lock")
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function exists(file: string) {
|
||||
return fs
|
||||
.stat(file)
|
||||
@@ -49,73 +32,13 @@ async function exists(file: string) {
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
async function wait(file: string, timeout = 3_000) {
|
||||
const stop = Date.now() + timeout
|
||||
while (Date.now() < stop) {
|
||||
if (await exists(file)) return
|
||||
await sleep(20)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for file: ${file}`)
|
||||
}
|
||||
|
||||
function run(msg: Msg) {
|
||||
return new Promise<{ code: number; stdout: Buffer; stderr: Buffer }>((resolve) => {
|
||||
const proc = spawn(process.execPath, [worker, JSON.stringify(msg)], {
|
||||
cwd: root,
|
||||
})
|
||||
|
||||
const stdout: Buffer[] = []
|
||||
const stderr: Buffer[] = []
|
||||
|
||||
proc.stdout?.on("data", (data) => stdout.push(Buffer.from(data)))
|
||||
proc.stderr?.on("data", (data) => stderr.push(Buffer.from(data)))
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
code: code ?? 1,
|
||||
stdout: Buffer.concat(stdout),
|
||||
stderr: Buffer.concat(stderr),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function spawnWorker(msg: Msg) {
|
||||
return spawn(process.execPath, [worker, JSON.stringify(msg)], {
|
||||
cwd: root,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
}
|
||||
|
||||
async function stopWorker(proc: ReturnType<typeof spawnWorker>) {
|
||||
if (proc.exitCode !== null || proc.signalCode !== null) return
|
||||
|
||||
const closed = new Promise<void>((resolve) => proc.once("close", () => resolve()))
|
||||
|
||||
if (process.platform !== "win32" || !proc.pid) {
|
||||
proc.kill()
|
||||
await closed
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const killProc = spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"])
|
||||
killProc.on("close", () => {
|
||||
proc.kill()
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
await closed
|
||||
}
|
||||
|
||||
async function readJson<T>(p: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(p, "utf8"))
|
||||
}
|
||||
|
||||
describe("util.flock", () => {
|
||||
test("enforces mutual exclusion under process contention", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const done = path.join(tmp.path, "done.log")
|
||||
const active = path.join(tmp.path, "active")
|
||||
@@ -124,7 +47,7 @@ describe("util.flock", () => {
|
||||
|
||||
const out = await Promise.all(
|
||||
Array.from({ length: n }, () =>
|
||||
run({
|
||||
runLockWorker(worker, {
|
||||
key,
|
||||
dir,
|
||||
done,
|
||||
@@ -132,7 +55,7 @@ describe("util.flock", () => {
|
||||
holdMs: 30,
|
||||
staleMs: 1_000,
|
||||
timeoutMs: 15_000,
|
||||
}),
|
||||
} satisfies Msg),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -147,21 +70,21 @@ describe("util.flock", () => {
|
||||
}, 20_000)
|
||||
|
||||
test("times out while waiting when lock is still healthy", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:timeout"
|
||||
const ready = path.join(tmp.path, "ready")
|
||||
const proc = spawnWorker({
|
||||
const proc = spawnLockWorker(worker, {
|
||||
key,
|
||||
dir,
|
||||
ready,
|
||||
holdMs: 20_000,
|
||||
staleMs: 10_000,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
} satisfies Msg)
|
||||
|
||||
try {
|
||||
await wait(ready, 5_000)
|
||||
await waitForFile(ready, 5_000)
|
||||
const seen: string[] = []
|
||||
const err = await Flock.withLock(key, async () => {}, {
|
||||
dir,
|
||||
@@ -178,45 +101,49 @@ describe("util.flock", () => {
|
||||
expect(seen.length).toBeGreaterThan(0)
|
||||
expect(seen.every((x) => x === key)).toBe(true)
|
||||
} finally {
|
||||
await stopWorker(proc).catch(() => undefined)
|
||||
await stopLockWorker(proc).catch(() => undefined)
|
||||
}
|
||||
}, 15_000)
|
||||
|
||||
test("recovers after a crashed lock owner", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:crash"
|
||||
const ready = path.join(tmp.path, "ready")
|
||||
const proc = spawnWorker({
|
||||
const proc = spawnLockWorker(worker, {
|
||||
key,
|
||||
dir,
|
||||
ready,
|
||||
holdMs: 20_000,
|
||||
staleMs: 500,
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
} satisfies Msg)
|
||||
|
||||
await wait(ready, 5_000)
|
||||
await stopWorker(proc)
|
||||
try {
|
||||
await waitForFile(ready, 5_000)
|
||||
await stopLockWorker(proc)
|
||||
|
||||
let hit = false
|
||||
await Flock.withLock(
|
||||
key,
|
||||
async () => {
|
||||
hit = true
|
||||
},
|
||||
{
|
||||
dir,
|
||||
staleMs: 500,
|
||||
timeoutMs: 8_000,
|
||||
},
|
||||
)
|
||||
let hit = false
|
||||
await Flock.withLock(
|
||||
key,
|
||||
async () => {
|
||||
hit = true
|
||||
},
|
||||
{
|
||||
dir,
|
||||
staleMs: 500,
|
||||
timeoutMs: 8_000,
|
||||
},
|
||||
)
|
||||
|
||||
expect(hit).toBe(true)
|
||||
expect(hit).toBe(true)
|
||||
} finally {
|
||||
await stopLockWorker(proc)
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
test("breaks stale lock dirs when heartbeat is missing", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:missing-heartbeat"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -242,7 +169,7 @@ describe("util.flock", () => {
|
||||
})
|
||||
|
||||
test("recovers when a stale breaker claim was left behind", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:stale-breaker"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -273,7 +200,7 @@ describe("util.flock", () => {
|
||||
})
|
||||
|
||||
test("fails clearly if lock dir is removed while held", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:compromised"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -313,7 +240,7 @@ describe("util.flock", () => {
|
||||
})
|
||||
|
||||
test("writes owner metadata while lock is held", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:meta"
|
||||
const file = path.join(lock(dir, key), "meta.json")
|
||||
@@ -342,7 +269,7 @@ describe("util.flock", () => {
|
||||
})
|
||||
|
||||
test("supports acquire with await using", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:acquire"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -360,7 +287,7 @@ describe("util.flock", () => {
|
||||
})
|
||||
|
||||
test("refuses token mismatch release and recovers from stale", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:token"
|
||||
const lockDir = lock(dir, key)
|
||||
@@ -403,7 +330,7 @@ describe("util.flock", () => {
|
||||
test("fails clearly on unwritable lock roots", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
await using tmp = await tmpdir("flock-test-")
|
||||
const dir = path.join(tmp.path, "locks")
|
||||
const key = "flock:perm"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Worktree } from "@opencode-ai/core/worktree"
|
||||
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
|
||||
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
|
||||
import { initRepo } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
@@ -29,15 +30,6 @@ function abs(input: string) {
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(directory).quiet()
|
||||
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
|
||||
await $`git config user.name Test`.cwd(directory).quiet()
|
||||
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
|
||||
}
|
||||
|
||||
function setup() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
@@ -85,9 +77,7 @@ describe("Worktree", () => {
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const linked = `${root.path}-linked`
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(linked, { recursive: true, force: true })))
|
||||
yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
|
||||
const project = yield* Project.Service
|
||||
|
||||
@@ -162,9 +152,7 @@ describe("Worktree", () => {
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
|
||||
const target = abs(path.join(parent, "worktree"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
const fiber = yield* bus
|
||||
.subscribe(Worktree.Event.Updated)
|
||||
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
|
||||
@@ -198,9 +186,7 @@ describe("Worktree", () => {
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
yield* input.db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
@@ -284,9 +270,7 @@ describe("Worktree", () => {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const parent = abs(`${input.root.path}-branch-worktree`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
yield* Effect.promise(async () => {
|
||||
await $`git branch feature-base`.cwd(input.sourceDirectory).quiet()
|
||||
})
|
||||
@@ -312,9 +296,7 @@ describe("Worktree", () => {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const parent = abs(`${input.root.path}-option-worktree`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
@@ -360,8 +342,8 @@ describe("Worktree", () => {
|
||||
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.all([
|
||||
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })),
|
||||
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })),
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
@@ -397,9 +379,7 @@ describe("Worktree", () => {
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
const created = yield* worktree.create({
|
||||
projectID: input.projectID,
|
||||
strategy: gitWorktree,
|
||||
@@ -455,9 +435,7 @@ describe("Worktree", () => {
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
|
||||
const target = abs(path.join(parent, "worktree-3"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
|
||||
@@ -487,9 +465,7 @@ describe("Worktree", () => {
|
||||
const worktree = yield* Worktree.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
@@ -546,7 +522,7 @@ describe("Worktree", () => {
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() =>
|
||||
Promise.all([target, unchanged].map((item) => fs.rm(item, { recursive: true, force: true }))),
|
||||
).pipe(Effect.ignore),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${unchanged} HEAD`.cwd(input.root.path).quiet())
|
||||
@@ -594,9 +570,7 @@ describe("Worktree", () => {
|
||||
const worktree = yield* Worktree.Service
|
||||
const stale = abs(`${input.root.path}-worktree-stale`)
|
||||
const target = abs(`${input.root.path}-worktree-after-stale`)
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(target, { recursive: true, force: true })))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
|
||||
@@ -5,7 +5,9 @@ The Promise plugin API at `@opencode-ai/plugin` is the async/await equivalent of
|
||||
- `hook` installs behavior at an OpenCode extension point.
|
||||
- `reload` reruns every transform hook for a stateful domain.
|
||||
|
||||
The only difference from the Effect API is the async boundary: hook callbacks, hook registration, `reload`, and `Registration.dispose` use Promises instead of Effects.
|
||||
The Promise API uses Promises instead of Effects for setup, runtime hook
|
||||
callbacks, hook registration, `reload`, and `Registration.dispose`. Transform
|
||||
draft callbacks remain synchronous.
|
||||
|
||||
## Defining A Plugin
|
||||
|
||||
@@ -46,12 +48,15 @@ await registration.dispose()
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains. The draft editor is synchronous; the callback may be `async` when it needs to await other work:
|
||||
Transform hooks contribute to stateful domains. The draft editor is synchronous,
|
||||
so load asynchronous data before registering a transform or reloading its domain:
|
||||
|
||||
```ts
|
||||
const description = await loadReviewerDescription()
|
||||
|
||||
await ctx.agent.transform((agent) => {
|
||||
agent.update("reviewer", (item) => {
|
||||
item.description = "Reviews code for regressions"
|
||||
item.description = description
|
||||
item.mode = "subagent"
|
||||
})
|
||||
})
|
||||
@@ -64,8 +69,12 @@ ctx.agent.transform
|
||||
ctx.catalog.transform
|
||||
ctx.command.transform
|
||||
ctx.integration.transform
|
||||
ctx.mcp.transform
|
||||
ctx.reference.transform
|
||||
ctx.skill.transform
|
||||
ctx.tool.transform
|
||||
ctx.vcs.transform
|
||||
ctx.websearch.transform
|
||||
```
|
||||
|
||||
## Runtime Hooks
|
||||
@@ -81,7 +90,7 @@ await ctx.aisdk.hook("sdk", async (event) => {
|
||||
|
||||
await ctx.aisdk.hook("language", (event) => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.api.id)
|
||||
event.language = event.sdk.responses(event.model.modelID)
|
||||
})
|
||||
```
|
||||
|
||||
@@ -94,14 +103,15 @@ await ctx.session.hook("context", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
Promise tools use executable tool values with async executors. Registration
|
||||
supplies the tool's name and options separately:
|
||||
Promise tools use complete executable tool values with async executors:
|
||||
|
||||
```ts
|
||||
import { Schema } from "effect"
|
||||
|
||||
await ctx.tool.transform((tools) => {
|
||||
tools.add("echo", {
|
||||
tools.add({
|
||||
name: "echo",
|
||||
options: { codemode: false },
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.Struct({ text: Schema.String }),
|
||||
@@ -132,6 +142,10 @@ ctx.agent.reload()
|
||||
ctx.catalog.reload()
|
||||
ctx.command.reload()
|
||||
ctx.integration.reload()
|
||||
ctx.mcp.reload()
|
||||
ctx.reference.reload()
|
||||
ctx.skill.reload()
|
||||
ctx.tool.reload()
|
||||
ctx.vcs.reload()
|
||||
ctx.websearch.reload()
|
||||
```
|
||||
|
||||
@@ -31,7 +31,9 @@ Registrations are owned by the plugin scope. Closing the scope removes them auto
|
||||
|
||||
## Transform Hooks
|
||||
|
||||
Transform hooks contribute to stateful domains:
|
||||
Transform hooks contribute to stateful domains. Their draft callbacks are
|
||||
synchronous, so load effectful data before registering a transform or reloading
|
||||
its domain:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
@@ -52,8 +54,12 @@ ctx.agent.transform
|
||||
ctx.catalog.transform
|
||||
ctx.command.transform
|
||||
ctx.integration.transform
|
||||
ctx.mcp.transform
|
||||
ctx.reference.transform
|
||||
ctx.skill.transform
|
||||
ctx.tool.transform
|
||||
ctx.vcs.transform
|
||||
ctx.websearch.transform
|
||||
```
|
||||
|
||||
## Runtime Hooks
|
||||
@@ -72,10 +78,12 @@ yield *
|
||||
)
|
||||
|
||||
yield *
|
||||
ctx.aisdk.hook("language", (event) => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.api.id)
|
||||
})
|
||||
ctx.aisdk.hook("language", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.model.providerID !== "xai") return
|
||||
event.language = event.sdk.responses(event.model.modelID)
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Hooks run sequentially in registration order. Later hooks observe mutations made by earlier hooks.
|
||||
@@ -117,6 +125,10 @@ ctx.agent.reload()
|
||||
ctx.catalog.reload()
|
||||
ctx.command.reload()
|
||||
ctx.integration.reload()
|
||||
ctx.mcp.reload()
|
||||
ctx.reference.reload()
|
||||
ctx.skill.reload()
|
||||
ctx.tool.reload()
|
||||
ctx.vcs.reload()
|
||||
ctx.websearch.reload()
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user