mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-29 04:56:20 +00:00
Compare commits
104
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68b7440a0e | ||
|
|
afb107143b | ||
|
|
0ca23f8e47 | ||
|
|
3789bf5482 | ||
|
|
d7f2e119fc | ||
|
|
fa283c38f7 | ||
|
|
d374929e70 | ||
|
|
e3bda5e2d0 | ||
|
|
ad9117b107 | ||
|
|
6b7a1d419c | ||
|
|
c0d0f5f4bc | ||
|
|
095ed63ea0 | ||
|
|
fa5ccac707 | ||
|
|
6809be2d0a | ||
|
|
a5f8869b35 | ||
|
|
1c9c5305a6 | ||
|
|
cf014bf2c1 | ||
|
|
8e7190f795 | ||
|
|
82b6e0e316 | ||
|
|
5b39f5184f | ||
|
|
1da25727b2 | ||
|
|
51d53f45c1 | ||
|
|
f779b2748a | ||
|
|
f2fb191f53 | ||
|
|
0b32bdf1e5 | ||
|
|
2751813454 | ||
|
|
f61858e683 | ||
|
|
87525e00b9 | ||
|
|
803b7718b8 | ||
|
|
8a24a01bff | ||
|
|
9e39a4fbdf | ||
|
|
d0baff184b | ||
|
|
d82a0b28a9 | ||
|
|
bd379e13cb | ||
|
|
6e1f783aec | ||
|
|
edef6a4b15 | ||
|
|
5990679ebd | ||
|
|
b0c8a8c827 | ||
|
|
d837ffe70f | ||
|
|
b1d7dd82fc | ||
|
|
e409567428 | ||
|
|
9538c2171f | ||
|
|
0a718be0d9 | ||
|
|
67845091ba | ||
|
|
fe788b7842 | ||
|
|
ee42eb3ca3 | ||
|
|
80323a4deb | ||
|
|
a35f96f427 | ||
|
|
d354c3d640 | ||
|
|
ce005ce002 | ||
|
|
964245bc2a | ||
|
|
aea3e7c1d2 | ||
|
|
3625942952 | ||
|
|
0593a6b8eb | ||
|
|
426e5c6389 | ||
|
|
ebdfcf4866 | ||
|
|
000d0882c3 | ||
|
|
6062e30cb9 | ||
|
|
3badee1a3c | ||
|
|
4a0256d374 | ||
|
|
52ec62bef0 | ||
|
|
31af9858fd | ||
|
|
3151660fbb | ||
|
|
0362ef48ff | ||
|
|
facd7ff452 | ||
|
|
134cdda333 | ||
|
|
5634ef1bb6 | ||
|
|
2379ab3d51 | ||
|
|
5c908ebba5 | ||
|
|
ba0755d933 | ||
|
|
f7d6b00c1e | ||
|
|
0bb6cf37be | ||
|
|
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.
|
||||
@@ -2,3 +2,4 @@ packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
packages/core/src/models-dev/snapshot.txt linguist-generated
|
||||
packages/core/src/**/*.txt text eol=lf
|
||||
packages/httpapi-codegen/test/generated/*.ts text eol=lf
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `v2`.
|
||||
- Base all new branches and worktrees on `v2`, or `origin/v2` when the local `v2` ref is unavailable. Do not base them on `dev`.
|
||||
- Local `main` ref may not exist; use `v2` or `origin/v2` for diffs.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -845,6 +845,21 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
const decodeMessagePart = Schema.decodeUnknownOption(
|
||||
Schema.Union([OpenResponsesOutputText, Schema.Struct({ type: Schema.tag("refusal"), refusal: Schema.String })]),
|
||||
)
|
||||
|
||||
const decodeSummaryPart = Schema.decodeUnknownOption(OpenResponsesReasoningSummaryText)
|
||||
|
||||
const decodeReasoningPart = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ type: Schema.tag("reasoning_text"), text: Schema.String }),
|
||||
)
|
||||
|
||||
const joinReasoningText = (parts: ReadonlyArray<string | undefined>) => {
|
||||
if (!parts.some((part) => part !== undefined && part.length > 0)) return undefined
|
||||
return parts.filter((part) => part !== undefined).join("\n\n")
|
||||
}
|
||||
|
||||
export const outputItemID = (state: ParserState, event: Event) =>
|
||||
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
|
||||
|
||||
@@ -1065,24 +1080,33 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (state: ParserState, event: Event) {
|
||||
const item = event.item
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: Event["item"],
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
const message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of parts) {
|
||||
const decoded = Option.getOrUndefined(decodeMessagePart(part))
|
||||
if (!decoded) continue
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }),
|
||||
),
|
||||
message: state.message?.id === item.id ? undefined : state.message,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
message: message ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1137,17 +1161,33 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
if (state.reasoningItems[item.id]?.open === false) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const summaryParts: ReadonlyArray<unknown> = Array.isArray(item.summary) ? item.summary : []
|
||||
const summary: Array<string | undefined> = []
|
||||
for (const part of summaryParts) {
|
||||
const decoded = Option.getOrUndefined(decodeSummaryPart(part))
|
||||
// Keep missing entries so the array still matches the provider's summary indexes.
|
||||
summary.push(decoded?.text)
|
||||
}
|
||||
const reasoningParts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
const content: string[] = []
|
||||
for (const part of reasoningParts) {
|
||||
const decoded = Option.getOrUndefined(decodeReasoningPart(part))
|
||||
if (decoded) content.push(decoded.text)
|
||||
}
|
||||
const itemText = joinReasoningText(summary) ?? joinReasoningText(content)
|
||||
const events: LLMEvent[] = []
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
if (!reasoningItem.open) return [state, NO_EVENTS] satisfies StepResult
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const fragments = Object.entries(reasoningItem.summaryParts)
|
||||
let lifecycle = state.lifecycle
|
||||
for (const [index, status] of fragments) {
|
||||
if (status === "concluded") continue
|
||||
// Do not repeat earlier summaries that were already emitted as separate fragments.
|
||||
const finalText = fragments.length === 1 ? itemText : summary[Number(index)]
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${index}`, metadata, finalText || undefined)
|
||||
}
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1167,7 +1207,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(
|
||||
LLMEvent.reasoningEnd({
|
||||
id: item.id,
|
||||
providerMetadata: metadata,
|
||||
text: itemText,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
@@ -1195,32 +1241,24 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
})
|
||||
|
||||
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
|
||||
const reconciled =
|
||||
event.type === "response.completed"
|
||||
? yield* Effect.reduce(
|
||||
event.response?.output ?? [],
|
||||
() => [state, NO_EVENTS] satisfies StepResult,
|
||||
([current, events], item) => {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (
|
||||
id === undefined ||
|
||||
((item.type !== "function_call" || !current.tools[id]) &&
|
||||
(item.type !== "reasoning" || !current.reasoningItems[id]?.open))
|
||||
)
|
||||
return Effect.succeed([current, events] satisfies StepResult)
|
||||
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
|
||||
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
|
||||
)
|
||||
},
|
||||
)
|
||||
: ([state, NO_EVENTS] satisfies StepResult)
|
||||
const current = reconciled[0]
|
||||
let current = state
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
|
||||
if (id === undefined) continue
|
||||
if (item.type !== "function_call" || !current.tools[id]) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
@@ -1346,7 +1384,7 @@ export const step = (state: ParserState, input: Event) => {
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && event.item.id === undefined)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
return onOutputItemDone(state, event.item)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
|
||||
|
||||
@@ -815,7 +815,12 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
case "tool_calls":
|
||||
return "tool-calls" as const
|
||||
default:
|
||||
return "unknown" as const
|
||||
return yield* new AIError({
|
||||
reason: new UnknownProviderError({
|
||||
message: `Provider finish_reason: ${reason}`,
|
||||
body: ProviderShared.encodeJson(event),
|
||||
}),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1000,33 +1005,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.
|
||||
@@ -1075,17 +1059,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length"
|
||||
if (
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(pendingTools).length
|
||||
)
|
||||
return yield* ProviderShared.eventError(
|
||||
ADAPTER,
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
ProviderShared.encodeJson(event),
|
||||
)
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
// Filtering or truncation terminates the response without confirming pending tool calls.
|
||||
const finished =
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
finishReason !== undefined &&
|
||||
!incompleteTools &&
|
||||
state.finishReason === undefined &&
|
||||
Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
: undefined
|
||||
|
||||
@@ -1132,10 +1124,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
|
||||
|
||||
@@ -76,6 +76,8 @@ const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error"
|
||||
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
|
||||
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
|
||||
const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i
|
||||
const SERVER_ERROR_TEXT =
|
||||
/\b(?:try again|(?:please |you can )?retry (?:the |this |your )?request|try (?:the |this |your )?request again|(?:currently |temporarily )?at capacity|overloaded|temporarily unavailable|service[-_\s]?unavailable|(?:server|internal)[-_\s]?error|server (?:is )?busy|provider returned (?:an )?error|resource[-_\s]?exhausted|upstream (?:connect|connection|request)|request buffer limit while retrying upstream)\b/i
|
||||
|
||||
export interface ProviderFailure {
|
||||
readonly message: string
|
||||
@@ -139,6 +141,9 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
|
||||
input.status === 408 ||
|
||||
input.status === 409 ||
|
||||
(input.status !== undefined && input.status >= 500) ||
|
||||
((input.status === undefined || input.status < 400) &&
|
||||
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
|
||||
SERVER_ERROR_TEXT.test(text)) ||
|
||||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
|
||||
)
|
||||
return new ProviderInternalError({
|
||||
|
||||
@@ -81,6 +81,46 @@ describe("provider error classification", () => {
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies retryable server messages as provider internal", () => {
|
||||
const message =
|
||||
"The model is currently at capacity due to high demand. Please try again in a few minutes, or use a higher service tier for priority processing."
|
||||
|
||||
expect(
|
||||
[
|
||||
message,
|
||||
"Try again",
|
||||
"Please retry your request shortly.",
|
||||
"You can retry the request.",
|
||||
"Try your request again.",
|
||||
"The service is temporarily at capacity.",
|
||||
"The model is overloaded.",
|
||||
"Service unavailable",
|
||||
"Internal server error",
|
||||
"The server is busy.",
|
||||
"Provider returned error",
|
||||
"Provider returned an error",
|
||||
"ResourceExhausted",
|
||||
"Upstream connection failed",
|
||||
"Exceeded request buffer limit while retrying upstream",
|
||||
].map((message) => classifyProviderFailure({ message })._tag),
|
||||
).toEqual(Array(15).fill("ProviderInternal"))
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Provider request failed", rawBody: "Please try again later." })._tag,
|
||||
).toBe("ProviderInternal")
|
||||
})
|
||||
|
||||
test("prioritizes specific failures over retryable server text", () => {
|
||||
expect(
|
||||
[
|
||||
classifyProviderFailure({ message: "Invalid credentials, try again", status: 401 }),
|
||||
classifyProviderFailure({ message: "Quota exceeded, try again", status: 429 }),
|
||||
classifyProviderFailure({ message: "Rate limit exceeded, try again" }),
|
||||
classifyProviderFailure({ message: "Upstream request failed: validation failed", status: 400 }),
|
||||
classifyProviderFailure({ message: "Try again", status: 200 }),
|
||||
].map((failure) => failure._tag),
|
||||
).toEqual(["Authentication", "QuotaExceeded", "RateLimit", "InvalidRequest", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
|
||||
"ProviderInternal",
|
||||
@@ -111,7 +151,6 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: '{"type":"error","error":{"code":123}}' })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "network error" })._tag).toBe("UnknownProvider")
|
||||
expect(classifyProviderFailure({ message: "Provider returned error" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
prompt: "Respond.",
|
||||
})
|
||||
const completed = { type: "response.completed", response: { id: "resp_1" } }
|
||||
const generate = (...events: OpenResponses.Event[]) =>
|
||||
LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...events))))
|
||||
|
||||
describe("Open Responses completed item text", () => {
|
||||
;["Draft expanded", "D", "Replacement", ""].forEach((text) => {
|
||||
it.effect(`replaces streamed text with completed item text ${JSON.stringify(text)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", phase: "final_answer", content: [{ type: "output_text", text }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe(text)
|
||||
expect(response.events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["Draft"])
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toEqual([
|
||||
{
|
||||
type: "text-end",
|
||||
id: "msg_1",
|
||||
text,
|
||||
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "final_answer" } },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("joins completed text and refusal parts without streamed text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [
|
||||
{ type: "output_text", text: "Answer. " },
|
||||
{ type: "refusal", refusal: "Cannot help." },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.text).toBe("Answer. Cannot help.")
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not create an empty text fragment for an empty completed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", text: "" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "" }] },
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.message.content).toEqual([])
|
||||
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Open Responses completed item reasoning", () => {
|
||||
;[
|
||||
{
|
||||
name: "summary",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Final" },
|
||||
{ type: "summary_text", text: "summary" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Final\n\nsummary",
|
||||
},
|
||||
{
|
||||
name: "raw text",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [{ type: "reasoning_text", text: "Raw" }],
|
||||
text: "Raw",
|
||||
},
|
||||
{
|
||||
name: "streamed fallback",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "" },
|
||||
{ type: "summary_text", text: "" },
|
||||
],
|
||||
content: [
|
||||
{ type: "reasoning_text", text: "" },
|
||||
{ type: "reasoning_text", text: "" },
|
||||
],
|
||||
text: "Draft",
|
||||
},
|
||||
].forEach((fixture) => {
|
||||
it.effect(`uses ${fixture.name} at item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: fixture.summary,
|
||||
content: fixture.content,
|
||||
encrypted_content: "encrypted",
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe(fixture.text)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
"openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("replaces only the still-open summary without repeating earlier text", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First " },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "draft" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First " },
|
||||
{ type: "summary_text", text: "final" },
|
||||
],
|
||||
},
|
||||
},
|
||||
completed,
|
||||
)
|
||||
expect(response.reasoning).toBe("First final")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined, "final"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
;["response.completed", "response.incomplete"].forEach((type) => {
|
||||
it.effect(`keeps streamed text when part finals are followed by ${type} without item completion`, () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* generate(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 0, delta: "Hel" },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", content_index: 1, delta: "world" },
|
||||
{ type: "response.output_text.done", item_id: "msg_1", content_index: 0, text: "Hello " },
|
||||
{
|
||||
type: "response.content_part.done",
|
||||
item_id: "msg_1",
|
||||
content_index: 0,
|
||||
part: { type: "output_text", text: "Hello " },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Draft" },
|
||||
{ type: "response.reasoning_summary_text.done", item_id: "rs_1", text: "Part final" },
|
||||
{
|
||||
type: "response.reasoning_summary_part.done",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
part: { type: "summary_text", text: "Part final" },
|
||||
},
|
||||
{
|
||||
type,
|
||||
response: {
|
||||
id: "resp_1",
|
||||
incomplete_details: type === "response.incomplete" ? { reason: "max_output_tokens" } : undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
expect(response.text).toBe("Helworld")
|
||||
expect(response.reasoning).toBe("Draft")
|
||||
expect(response.events.filter(LLMEvent.is.textEnd).map((event) => event.text)).toEqual([undefined])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).map((event) => event.text)).toEqual([undefined])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -129,7 +129,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves done-only encrypted reasoning without replaying its summary or late events", () =>
|
||||
it.effect("preserves done-only reasoning text and encryption without replaying late events", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "reasoning",
|
||||
@@ -157,6 +157,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
text: "Not streamed",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
@@ -301,7 +302,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("recovers pending items in completed output order with terminal encrypted metadata", () =>
|
||||
it.effect("recovers pending calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{
|
||||
@@ -325,11 +326,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
},
|
||||
)
|
||||
expect(events.slice(5, -2)).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
@@ -341,8 +337,10 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "final" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: "fc_1" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1419,6 +1460,162 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "content_filter"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter", raw: "content_filter" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "content_filter"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when output is truncated", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "length"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: { normalized: "length", raw: "length" },
|
||||
usage: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "finish", reason: { normalized: "length", raw: "length" }, usage: undefined },
|
||||
])
|
||||
expect(response.toolCalls).toEqual([])
|
||||
|
||||
const missingIdentity = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }),
|
||||
deltaChunk({}, "length"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" })
|
||||
expect(missingIdentity.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unknown finish reasons without finalizing streamed tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }],
|
||||
}),
|
||||
deltaChunk({}, "future_reason"),
|
||||
)
|
||||
const events = yield* Ref.make<ReadonlyArray<LLMEvent>>([])
|
||||
const error = yield* LLMClient.stream(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Stream.tap((event) => Ref.update(events, (current) => [...current, event])),
|
||||
Stream.runDrain,
|
||||
Effect.provide(fixedResponse(body)),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(yield* Ref.get(events)).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -554,6 +554,19 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider reported an error (finish_reason: error)",
|
||||
})
|
||||
|
||||
const unknown = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(unknown).toMatchObject({
|
||||
reason: { _tag: "UnknownProvider" },
|
||||
message: "Provider finish_reason: future_reason",
|
||||
})
|
||||
expect(decodeJson(unknown.reason.body ?? "")).toMatchObject({
|
||||
id: "chatcmpl_fixture",
|
||||
choices: [{ finish_reason: "future_reason" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -581,17 +594,13 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves provider finish outcomes in the common reason algebra", () =>
|
||||
it.effect("preserves content-filter finishes in the common reason algebra", () =>
|
||||
Effect.gen(function* () {
|
||||
const filtered = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "content_filter")))),
|
||||
)
|
||||
const future = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(deltaChunk({}, "future_reason")))),
|
||||
)
|
||||
|
||||
expect(filtered.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" })
|
||||
expect(future.finishReason).toEqual({ normalized: "unknown", raw: "future_reason" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ describe("Open Responses-compatible route", () => {
|
||||
})
|
||||
|
||||
routings.forEach((routing) => {
|
||||
it.effect(`preserves reasoning summary boundaries and terminal metadata with ${routing.name}`, () =>
|
||||
it.effect(`preserves reasoning summary boundaries without terminal reconciliation with ${routing.name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const address = { item_id: routing.item_id, output_index: routing.output_index }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -444,21 +444,18 @@ describe("Open Responses-compatible route", () => {
|
||||
type: "reasoning",
|
||||
text: "Second.",
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: null },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toEqual([
|
||||
expect.objectContaining({
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: `${routing.id}:0`,
|
||||
text: undefined,
|
||||
providerMetadata: { "openai-compatible": { itemId: routing.id } },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: `${routing.id}:1`,
|
||||
providerMetadata: {
|
||||
"openai-compatible": { itemId: routing.id, reasoningEncryptedContent: "final-state" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
{ type: "reasoning-end", id: `${routing.id}:1` },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -671,7 +668,7 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
@@ -697,8 +694,9 @@ describe("Open Responses-compatible route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { "openai-compatible": { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_raw:0",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2554,7 +2554,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
|
||||
it.effect("ignores terminal reasoning output when item completion is missing", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2595,29 +2595,13 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Checked the diff.")
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
},
|
||||
{ type: "reasoning-end", id: "rs_1:0" },
|
||||
])
|
||||
expect(response.message.content).toContainEqual({
|
||||
type: "reasoning",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Checked the diff." }],
|
||||
encrypted_content: "terminal-state",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2644,7 +2628,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
|
||||
it.effect("recovers pending function calls without reconciling terminal reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { store: false } }),
|
||||
@@ -2682,14 +2666,15 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.toolCall),
|
||||
expect(response.events.findIndex(LLMEvent.is.toolCall)).toBeLessThan(
|
||||
response.events.findIndex((event) => event.type === "reasoning-end"),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
}),
|
||||
@@ -3019,6 +3004,7 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:0",
|
||||
text: "Checked the diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -20,95 +20,134 @@ const inventory: WorktreeDirectory[] = [
|
||||
|
||||
test.use({ serviceWorkers: "block" })
|
||||
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{ name: "main root with Windows case and separators", directory: "c:\\OPENCODE\\workspaceaccent\\", accent: false },
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test.describe(theme, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
}, theme)
|
||||
})
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
for (const scenario of [
|
||||
{ name: "managed Git worktree", directory: workspace, accent: true },
|
||||
{ name: "linked Git worktree outside main", directory: "C:/OpenCode/LinkedWorkspace", accent: true },
|
||||
{
|
||||
name: "linked Git worktree on a narrow screen",
|
||||
directory: "C:/OpenCode/LinkedWorkspace",
|
||||
accent: true,
|
||||
viewport: { width: 390, height: 844 },
|
||||
},
|
||||
{
|
||||
name: "main root with Windows case and separators",
|
||||
directory: "c:\\OPENCODE\\workspaceaccent\\",
|
||||
accent: false,
|
||||
},
|
||||
{ name: "nested main directory", directory: `${root}/packages/app`, accent: false },
|
||||
{ name: "nested workspace inside main", directory: `${workspace}/packages/app`, accent: true },
|
||||
{
|
||||
name: "workspace with Windows case and separators",
|
||||
directory: "c:\\opencode\\WORKSPACEACCENT\\.worktrees\\FEATURE\\src\\",
|
||||
accent: true,
|
||||
},
|
||||
{ name: "unregistered sibling with the same prefix", directory: `${workspace}-unregistered`, accent: false },
|
||||
{ name: "workspace using another strategy", directory: "C:/OpenCode/WorkspaceCopy", accent: true },
|
||||
{ name: "registered directory without a strategy", directory: "C:/OpenCode/RegisteredDirectory", accent: true },
|
||||
]) {
|
||||
test(`existing session send button: ${scenario.name}`, async ({ page }, testInfo) => {
|
||||
if (scenario.viewport) await page.setViewportSize(scenario.viewport)
|
||||
const view = await openSession(page, scenario.directory)
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
if (scenario.name === "managed Git worktree") {
|
||||
// Capture before the color assertion so both red and green runs have evidence.
|
||||
const path = testInfo.outputPath("workspace-accent.png")
|
||||
await view.composer.screenshot({ path })
|
||||
await testInfo.attach("workspace-accent", { path, contentType: "image/png" })
|
||||
}
|
||||
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(
|
||||
message,
|
||||
scenario.accent ? "accent" : theme === "light" ? "layer-02" : "layer-01",
|
||||
"background-color",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
await expectBackground(view.send, scenario.accent ? "accent" : "contrast")
|
||||
const message = page.locator('[data-slot="user-message-text"]')
|
||||
await expect(message).toHaveText("Check this fixture workspace.")
|
||||
await expectBackground(message, scenario.accent ? "accent" : "layer-02", "background-color")
|
||||
test("inventory updates leave send neutral; disabled and stop stay neutral", async ({ page }) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
test("new workspace send button stays neutral", async ({ page }) => {
|
||||
const view = await openSession(page, root, [...inventory], true)
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await page.getByRole("button", { name: "Local", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
|
||||
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
|
||||
await view.input.fill("Inspect this fixture workspace.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.send.hover()
|
||||
await expectBackground(view.send, "contrast")
|
||||
await view.composer.locator('[data-action="composer-model"]').press("Tab")
|
||||
await expect(view.send).toBeFocused()
|
||||
await expectBackground(view.send, "contrast")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test("inventory updates recolor the send button without navigation; disabled and stop stay neutral", async ({
|
||||
page,
|
||||
}) => {
|
||||
const view = await openSession(page, workspace, [{ directory: root }])
|
||||
await view.input.fill("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
const url = page.url()
|
||||
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_inventory",
|
||||
created: 1700000001000,
|
||||
type: "worktree.updated",
|
||||
data: { projectID },
|
||||
})
|
||||
expect((await refreshed).ok()).toBe(true)
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
await expect(view.input).toHaveText("Keep this draft while the inventory changes.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
|
||||
await view.input.fill("")
|
||||
await expect(view.send).toBeDisabled()
|
||||
await expectBackground(view.send, "contrast")
|
||||
|
||||
view.events.push({
|
||||
id: "evt_workspace_accent_running",
|
||||
created: 1700000002000,
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID },
|
||||
})
|
||||
const stop = view.composer.getByRole("button", { name: "Stop", exact: true })
|
||||
await expect(stop).toBeEnabled()
|
||||
await expectBackground(stop, "contrast")
|
||||
|
||||
await view.input.fill("Send a follow-up instead of stopping.")
|
||||
await expect(view.send).toBeEnabled()
|
||||
await expectBackground(view.send, "accent")
|
||||
await expect(page).toHaveURL(url)
|
||||
})
|
||||
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory]) {
|
||||
async function openSession(page: Page, directory: string, worktrees = [...inventory], draft = false) {
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -159,18 +198,32 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "light")
|
||||
})
|
||||
if (draft)
|
||||
await page.addInitScript(
|
||||
({ root, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: root, expanded: true }] },
|
||||
lastProject: { local: root },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID: "draft_workspace_accent", server, directory: root }]),
|
||||
)
|
||||
},
|
||||
{ root, server },
|
||||
)
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await page.goto(
|
||||
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
|
||||
)
|
||||
expect((await loaded).ok()).toBe(true)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "light")
|
||||
if (!draft) await expectSessionReady(page, { server, sessionID, title })
|
||||
const composer = page.locator('[data-component="composer"]')
|
||||
await expectAppVisible(composer)
|
||||
const input = composer.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
@@ -12,12 +12,7 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { ComposerModel } from "./model"
|
||||
|
||||
export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
accentSubmit?: boolean
|
||||
}) {
|
||||
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
@@ -26,7 +21,6 @@ export function Composer(props: {
|
||||
<div class="flex flex-col gap-3">
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
accentSubmit={props.accentSubmit}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
|
||||
@@ -37,7 +37,6 @@ export type ComposerMode = "normal" | "shell"
|
||||
|
||||
export type ComposerEditorProps = {
|
||||
controller: ComposerEditorModel
|
||||
accentSubmit?: boolean
|
||||
disabled?: boolean
|
||||
readOnly?: boolean
|
||||
borderUnderlay?: boolean
|
||||
@@ -265,7 +264,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
accent={props.accentSubmit}
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={() => props.controller.submit()}
|
||||
@@ -751,7 +749,6 @@ export function ComposerEditorSubmitButton(props: {
|
||||
mode: ComposerMode
|
||||
stopping: boolean
|
||||
disabled: boolean
|
||||
accent?: boolean
|
||||
sendLabel: string
|
||||
stopLabel: string
|
||||
onSubmit: () => void
|
||||
@@ -770,16 +767,10 @@ export function ComposerEditorSubmitButton(props: {
|
||||
tabIndex={props.mode === "normal" ? undefined : -1}
|
||||
icon={<Icon name={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} />}
|
||||
variant="contrast"
|
||||
class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
classList={{
|
||||
"text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled,
|
||||
"text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled,
|
||||
}}
|
||||
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
|
||||
style={{
|
||||
"background-image":
|
||||
props.accent && !props.stopping && !props.disabled
|
||||
? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)"
|
||||
: "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
|
||||
}}
|
||||
aria-label={props.stopping ? props.stopLabel : props.sendLabel}
|
||||
onClick={(event) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ export function NewSessionView(props: {
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<Wordmark class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8 flex flex-col gap-8">
|
||||
<Composer model={props.composer} accentSubmit={props.workspace.selection.workspace()} />
|
||||
<Composer model={props.composer} />
|
||||
<Show when={props.project.empty()}>
|
||||
<PromptProjectAddButton controller={props.project} />
|
||||
</Show>
|
||||
|
||||
@@ -216,7 +216,6 @@ export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegi
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
model: ActiveSessionRegionModel
|
||||
session: SessionModel
|
||||
accentSubmit: boolean
|
||||
onResponseSubmit: () => void
|
||||
}) {
|
||||
const settings = useSettings()
|
||||
@@ -251,7 +250,7 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={composer} borderUnderlay accentSubmit={props.accentSubmit} />
|
||||
<Composer model={composer} borderUnderlay />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
{
|
||||
|
||||
@@ -145,12 +145,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
|
||||
<Show when={!review.mobile.changes() ? session.identity.params.id : undefined} keyed>
|
||||
{(_id) => (
|
||||
<ActiveSessionComposerRegion
|
||||
model={composer}
|
||||
session={session}
|
||||
accentSubmit={session.workspace.current()}
|
||||
onResponseSubmit={timeline.actions.resume}
|
||||
/>
|
||||
<ActiveSessionComposerRegion model={composer} session={session} onResponseSubmit={timeline.actions.resume} />
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!!session.identity.params.id && mobileTabsBottom()}>
|
||||
|
||||
@@ -22,6 +22,9 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
|
||||
|
||||
export type TurnControl = {
|
||||
cancelled: boolean
|
||||
admitted?: boolean
|
||||
interrupting?: boolean
|
||||
stream?: AbortController
|
||||
readonly admission: AbortController
|
||||
}
|
||||
|
||||
@@ -69,6 +72,44 @@ function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
|
||||
async function openEventStream(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly streamController: AbortController
|
||||
readonly admission: AbortController
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}) {
|
||||
const connectionAbort = () => {
|
||||
input.streamController.abort()
|
||||
input.admission.abort()
|
||||
}
|
||||
const sessionAbort = () => {
|
||||
input.streamController.abort()
|
||||
input.admission.abort()
|
||||
}
|
||||
let stream: AsyncIterator<EventSubscribeOutput> | undefined
|
||||
let opened = false
|
||||
const close = async () => {
|
||||
input.streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", sessionAbort)
|
||||
await stream?.return?.(undefined).catch(() => {})
|
||||
}
|
||||
try {
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
input.sessionSignal?.addEventListener("abort", sessionAbort, { once: true })
|
||||
if (input.connectionSignal?.aborted) connectionAbort()
|
||||
if (input.sessionSignal?.aborted) sessionAbort()
|
||||
stream = input.client.event.subscribe({ signal: input.streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
opened = true
|
||||
return { stream, close }
|
||||
} finally {
|
||||
if (!opened) await close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: Connection
|
||||
@@ -83,12 +124,17 @@ export async function streamTurn(input: {
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<PromptResponse> {
|
||||
const streamController = new AbortController()
|
||||
const connectionAbort = () => streamController.abort()
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
const streamController = input.control.stream ?? new AbortController()
|
||||
input.control.stream = streamController
|
||||
const opened = await openEventStream({
|
||||
client: input.client,
|
||||
streamController,
|
||||
admission: input.control.admission,
|
||||
connectionSignal: input.connectionSignal,
|
||||
sessionSignal: input.sessionSignal,
|
||||
})
|
||||
const stream = opened.stream
|
||||
const closeStream = opened.close
|
||||
|
||||
const control = input.control
|
||||
let started = false
|
||||
@@ -173,6 +219,7 @@ export async function streamTurn(input: {
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
control.admitted = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
@@ -335,34 +382,38 @@ export async function streamTurn(input: {
|
||||
return "interrupted" as const
|
||||
}
|
||||
|
||||
const completed = consume("turn")
|
||||
const closeStream = async () => {
|
||||
streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
const completed = consume("turn").then(
|
||||
(value) => ({ success: true as const, value }),
|
||||
(error) => ({ success: false as const, error }),
|
||||
)
|
||||
const submitted = input.submit(control.admission.signal).then(
|
||||
() => ({ success: true as const }),
|
||||
(error) => ({ success: false as const, error }),
|
||||
)
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
})
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "succeeded", control.cancelled, undefined)
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "interrupted", true, undefined)
|
||||
const first = await Promise.race([
|
||||
submitted.then((result) => ({ source: "admission" as const, result })),
|
||||
completed.then((result) => ({ source: "stream" as const, result })),
|
||||
])
|
||||
const completion = await (async () => {
|
||||
if (first.source === "stream") {
|
||||
control.admission.abort()
|
||||
return first.result
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
if (!first.result.success && !control.cancelled) throw first.result.error
|
||||
if (first.result.success) control.admitted = true
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed
|
||||
return { success: true as const, value: "succeeded" as const }
|
||||
}
|
||||
if (control.cancelled && !started) streamController.abort()
|
||||
return completed
|
||||
})()
|
||||
if (!completion.success && !control.cancelled) throw completion.error
|
||||
const terminal = completion.success ? completion.value : "interrupted"
|
||||
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||
handedOff = true
|
||||
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
void consume("background")
|
||||
.catch(() => {})
|
||||
.finally(closeStream)
|
||||
@@ -381,7 +432,6 @@ export async function streamTurn(input: {
|
||||
)
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (!handedOff) await closeStream()
|
||||
|
||||
+337
-126
@@ -9,6 +9,7 @@ import {
|
||||
type SkillInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import type {
|
||||
AgentSideConnection,
|
||||
AuthenticateRequest,
|
||||
@@ -78,6 +79,27 @@ type Attached = {
|
||||
modeID: string
|
||||
}
|
||||
|
||||
type ActiveTurn = {
|
||||
readonly state: Attached
|
||||
readonly control: TurnControl
|
||||
readonly stopped: Promise<void>
|
||||
readonly resolveStopped: () => void
|
||||
}
|
||||
|
||||
type RegisteredMcp = {
|
||||
readonly server: string
|
||||
readonly config: ReturnType<typeof mcpConfig>
|
||||
}
|
||||
|
||||
class McpRollbackError extends AggregateError {
|
||||
readonly servers: ReadonlyArray<string>
|
||||
|
||||
constructor(primary: unknown, rollback: unknown, servers: ReadonlyArray<string>) {
|
||||
super([primary, rollback], "ACP attachment failed and MCP rollback did not complete", { cause: primary })
|
||||
this.servers = servers
|
||||
}
|
||||
}
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: TurnStart
|
||||
readonly text: string
|
||||
@@ -104,11 +126,42 @@ export interface Interface {
|
||||
cancel(input: CancelNotification): Promise<void>
|
||||
}
|
||||
|
||||
type KeyedLock<Key> = <A>(key: Key, operation: () => Promise<A>) => Promise<A>
|
||||
|
||||
function makeKeyedLock<Key>(): KeyedLock<Key> {
|
||||
const entries = new Map<Key, { readonly semaphore: Semaphore.Semaphore; users: number }>()
|
||||
return async <A>(key: Key, operation: () => Promise<A>) => {
|
||||
const current = entries.get(key)
|
||||
const entry = current ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 }
|
||||
if (!current) entries.set(key, entry)
|
||||
// Count holders and waiters so cleanup cannot split one key across two locks.
|
||||
entry.users++
|
||||
const result = await Effect.runPromise(
|
||||
entry.semaphore.withPermit(
|
||||
Effect.promise(() =>
|
||||
operation().then(
|
||||
(value) => ({ success: true as const, value }),
|
||||
(error) => ({ success: false as const, error }),
|
||||
),
|
||||
),
|
||||
),
|
||||
).finally(() => {
|
||||
entry.users--
|
||||
if (entry.users === 0) entries.delete(key)
|
||||
})
|
||||
if (!result.success) throw result.error
|
||||
return result.value
|
||||
}
|
||||
}
|
||||
|
||||
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
|
||||
const sessions = new Map<string, Attached>()
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const registeredMcp = new Map<string, Map<string, RegisteredMcp>>()
|
||||
const uncertainMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, ActiveTurn>()
|
||||
const withSessionLock = makeKeyedLock<string>()
|
||||
const withLocationLock = makeKeyedLock<string>()
|
||||
const capabilities = { writeTextFile: false, childSessionUpdates: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
@@ -128,15 +181,50 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
}
|
||||
|
||||
const detach = (sessionID: string) => {
|
||||
sessions.get(sessionID)?.abort.abort()
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
const retire = (state: Attached) => {
|
||||
state.abort.abort()
|
||||
const turn = active.get(state.id)
|
||||
if (turn?.state !== state) return
|
||||
turn.control.admission.abort()
|
||||
active.delete(state.id)
|
||||
}
|
||||
|
||||
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
|
||||
const detach = (sessionID: string) => {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) retire(state)
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
const invalidateLocation = (cwd: string, servers: ReadonlyArray<string>) => {
|
||||
sessions.forEach((state, sessionID) => {
|
||||
if (state.cwd !== cwd) return
|
||||
retire(state)
|
||||
sessions.delete(sessionID)
|
||||
})
|
||||
const uncertain = new Set([...(registeredMcp.get(cwd)?.keys() ?? []), ...servers])
|
||||
if (uncertain.size > 0) uncertainMcp.set(cwd, uncertain)
|
||||
registeredMcp.delete(cwd)
|
||||
}
|
||||
|
||||
const reconcileLocation = async (cwd: string) => {
|
||||
const uncertain = uncertainMcp.get(cwd)
|
||||
if (!uncertain) return
|
||||
const removed = await Promise.allSettled(
|
||||
[...uncertain].map((server) => input.client.mcp.remove({ server, location: { directory: cwd } })),
|
||||
)
|
||||
const failures = removed.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))
|
||||
if (failures.length > 0) throw new AggregateError(failures, "Failed to reconcile uncertain MCP configuration")
|
||||
uncertainMcp.delete(cwd)
|
||||
}
|
||||
|
||||
// Lifecycle operations acquire Session ID before entering this Location transaction.
|
||||
const attach = async (
|
||||
session: SessionInfo,
|
||||
cwd: string,
|
||||
mcpServers: readonly McpServer[],
|
||||
replayHistory: boolean,
|
||||
) => {
|
||||
const currentCatalog = await catalog(cwd)
|
||||
sessions.get(session.id)?.abort.abort()
|
||||
const state: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
@@ -145,25 +233,56 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
model: session.model ?? currentCatalog.defaultModel,
|
||||
modeID: session.agent ?? currentCatalog.defaultModeID,
|
||||
}
|
||||
sessions.set(session.id, state)
|
||||
await registerMcpServers(input.client, registeredMcp, state, mcpServers)
|
||||
await input.connection.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...state.catalog.commands,
|
||||
...state.catalog.skills.filter(
|
||||
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
|
||||
),
|
||||
].map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
},
|
||||
return withLocationLock(cwd, async () => {
|
||||
const history = replayHistory ? await messages(input.client, state.id) : undefined
|
||||
await reconcileLocation(cwd)
|
||||
const registration = await registerMcpServers(
|
||||
input.client,
|
||||
registeredMcp.get(cwd) ?? new Map(),
|
||||
state,
|
||||
mcpServers,
|
||||
).catch((error) => {
|
||||
state.abort.abort()
|
||||
if (error instanceof McpRollbackError) invalidateLocation(cwd, error.servers)
|
||||
throw error
|
||||
})
|
||||
return input.connection
|
||||
.sessionUpdate({
|
||||
sessionId: state.id,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...state.catalog.commands,
|
||||
...state.catalog.skills.filter(
|
||||
(skill) => !state.catalog.commands.some((command) => command.name === skill.name),
|
||||
),
|
||||
].map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
},
|
||||
})
|
||||
.then(async () => {
|
||||
if (history) await replayMessages(input.connection, state.id, state.cwd, history)
|
||||
const previous = sessions.get(session.id)
|
||||
if (previous) retire(previous)
|
||||
sessions.set(session.id, state)
|
||||
registeredMcp.set(cwd, registration.registered)
|
||||
return state
|
||||
})
|
||||
.catch(async (error) => {
|
||||
state.abort.abort()
|
||||
const rollback = await registration.rollback().then(
|
||||
() => ({ success: true as const }),
|
||||
(failure) => ({ success: false as const, failure }),
|
||||
)
|
||||
if (!rollback.success) {
|
||||
invalidateLocation(cwd, registration.changed)
|
||||
throw new McpRollbackError(error, rollback.failure, registration.changed)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
}).catch((error) => {
|
||||
state.abort.abort()
|
||||
throw error
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
const replay = async (state: Attached) => {
|
||||
await replayMessages(input.connection, state.id, state.cwd, await messages(input.client, state.id))
|
||||
}
|
||||
|
||||
const configOptions = (state: Attached) =>
|
||||
@@ -213,15 +332,15 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
agent: currentCatalog.defaultModeID,
|
||||
model: currentCatalog.defaultModel,
|
||||
})
|
||||
const state = await attach(created, params.cwd, params.mcpServers)
|
||||
const state = await withSessionLock(created.id, () => attach(created, params.cwd, params.mcpServers, false))
|
||||
return { sessionId: state.id, configOptions: configOptions(state) }
|
||||
},
|
||||
loadSession: async (params) => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
const state = await attach(session, session.location.directory, params.mcpServers)
|
||||
await replay(state)
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
loadSession: (params) =>
|
||||
withSessionLock(params.sessionId, async () => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
const state = await attach(session, session.location.directory, params.mcpServers, true)
|
||||
return { configOptions: configOptions(state) }
|
||||
}),
|
||||
listSessions: async (params) => {
|
||||
const page = await input.client.session.list({
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
@@ -239,86 +358,109 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
},
|
||||
deleteSession: async (params) => {
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
detach(params.sessionId)
|
||||
return {}
|
||||
},
|
||||
resumeSession: async (params) => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: configOptions(state) }
|
||||
},
|
||||
closeSession: async (params) => {
|
||||
detach(params.sessionId)
|
||||
const turn = active.get(params.sessionId)
|
||||
if (turn) {
|
||||
turn.cancelled = true
|
||||
turn.admission.abort()
|
||||
}
|
||||
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
|
||||
return {}
|
||||
},
|
||||
deleteSession: (params) =>
|
||||
withSessionLock(params.sessionId, async () => {
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
resumeSession: (params) =>
|
||||
withSessionLock(params.sessionId, async () => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
const state = await attach(session, session.location.directory, params.mcpServers ?? [], false)
|
||||
return { configOptions: configOptions(state) }
|
||||
}),
|
||||
closeSession: (params) =>
|
||||
withSessionLock(params.sessionId, async () => {
|
||||
const turn = active.get(params.sessionId)
|
||||
if (turn) {
|
||||
turn.control.cancelled = true
|
||||
turn.control.interrupting = true
|
||||
}
|
||||
detach(params.sessionId)
|
||||
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
|
||||
return {}
|
||||
}),
|
||||
forkSession: async (params) => {
|
||||
const forked = await input.client.session.fork({
|
||||
sessionID: params.sessionId,
|
||||
boundary: { type: "through" },
|
||||
const forked = await withSessionLock(params.sessionId, () =>
|
||||
input.client.session.fork({
|
||||
sessionID: params.sessionId,
|
||||
boundary: { type: "through" },
|
||||
}),
|
||||
)
|
||||
const state = await withSessionLock(forked.id, async () => {
|
||||
return attach(forked, forked.location.directory, params.mcpServers ?? [], true)
|
||||
})
|
||||
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
await replay(state)
|
||||
return { sessionId: state.id, configOptions: configOptions(state) }
|
||||
},
|
||||
setSessionConfigOption: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog, params.value)
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
break
|
||||
return withSessionLock(params.sessionId, async () => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
switch (params.configId) {
|
||||
case "model": {
|
||||
const selected = requireModel(state.catalog, params.value)
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
state.model = selected
|
||||
break
|
||||
}
|
||||
case "effort": {
|
||||
const model = state.catalog.models.find(
|
||||
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
|
||||
)
|
||||
if (!model?.variants.some((variant) => variant.id === params.value))
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
const selected = { ...state.model, variant: params.value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
state.model = selected
|
||||
break
|
||||
}
|
||||
case "mode":
|
||||
await selectMode(input.client, state, params.value)
|
||||
break
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
case "effort": {
|
||||
const model = state.catalog.models.find(
|
||||
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
|
||||
)
|
||||
if (!model?.variants.some((variant) => variant.id === params.value))
|
||||
throw new ACPError.InvalidEffortError({ effort: params.value })
|
||||
state.model = { ...state.model, variant: params.value }
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
|
||||
break
|
||||
}
|
||||
case "mode":
|
||||
await selectMode(input.client, state, params.value)
|
||||
break
|
||||
default:
|
||||
throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
}
|
||||
return { configOptions: configOptions(state) }
|
||||
return { configOptions: configOptions(state) }
|
||||
})
|
||||
},
|
||||
setSessionMode: async (params) => {
|
||||
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
||||
await withSessionLock(params.sessionId, async () => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
await selectMode(input.client, state, params.modeId)
|
||||
})
|
||||
return {}
|
||||
},
|
||||
prompt: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (active.has(state.id)) {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
|
||||
service: "session",
|
||||
})
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const acquired = await withSessionLock(params.sessionId, async () => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (active.has(state.id)) {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
|
||||
service: "session",
|
||||
})
|
||||
}
|
||||
const prepared = preparePrompt(state.catalog, params.prompt, SessionMessage.ID.create())
|
||||
const control: TurnControl = {
|
||||
cancelled: false,
|
||||
admission: new AbortController(),
|
||||
stream: new AbortController(),
|
||||
}
|
||||
const stopped = Promise.withResolvers<void>()
|
||||
const turn = { state, control, stopped: stopped.promise, resolveStopped: () => stopped.resolve() }
|
||||
active.set(state.id, turn)
|
||||
return { state, control, prepared, turn }
|
||||
})
|
||||
const state = acquired.state
|
||||
const control = acquired.control
|
||||
const prepared = acquired.prepared
|
||||
const extNotification = input.connection.extNotification
|
||||
const childSessionUpdate =
|
||||
capabilities.childSessionUpdates && extNotification
|
||||
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||
: undefined
|
||||
active.set(state.id, control)
|
||||
const response = await streamTurn({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
@@ -333,18 +475,49 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||
}).finally(() => {
|
||||
if (active.get(state.id) === control) active.delete(state.id)
|
||||
if (active.get(state.id) === acquired.turn) active.delete(state.id)
|
||||
acquired.turn.resolveStopped()
|
||||
})
|
||||
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
|
||||
return response
|
||||
},
|
||||
cancel: async (params) => {
|
||||
const current = active.get(params.sessionId)
|
||||
if (current) {
|
||||
current.cancelled = true
|
||||
current.admission.abort()
|
||||
const cancellation = await withSessionLock(params.sessionId, async () => {
|
||||
const current = active.get(params.sessionId)
|
||||
if (!current) return undefined
|
||||
current.control.cancelled = true
|
||||
current.control.admission.abort()
|
||||
if (!current.control.admitted) {
|
||||
current.control.stream?.abort()
|
||||
return { current, interrupt: false as const }
|
||||
}
|
||||
if (current.control.interrupting) return { current, interrupt: false as const }
|
||||
current.control.interrupting = true
|
||||
return { current, interrupt: true as const }
|
||||
})
|
||||
if (!cancellation) return
|
||||
const current = cancellation.current
|
||||
if (!cancellation.interrupt) {
|
||||
await current.stopped
|
||||
return
|
||||
}
|
||||
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
|
||||
const interrupted = await input.client.session.interrupt({ sessionID: params.sessionId }).catch(async (error) => {
|
||||
await withSessionLock(params.sessionId, async () => {
|
||||
if (active.get(params.sessionId) === current) current.control.interrupting = false
|
||||
})
|
||||
throw error
|
||||
})
|
||||
if (!interrupted.interrupted) {
|
||||
await withSessionLock(params.sessionId, async () => {
|
||||
if (active.get(params.sessionId) === current) current.control.interrupting = false
|
||||
})
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: `Failed to interrupt active ACP prompt: ${params.sessionId}`,
|
||||
service: "session",
|
||||
})
|
||||
}
|
||||
current.control.stream?.abort()
|
||||
await current.stopped
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -364,16 +537,21 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
|
||||
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
if (prompt.synthetic.length > 0) {
|
||||
await client.session.synthetic({
|
||||
sessionID: session.id,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
await client.session.synthetic(
|
||||
{
|
||||
sessionID: session.id,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.skill) return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id })
|
||||
if (prompt.start.type === "compaction")
|
||||
return client.session.compact({ sessionID: session.id, id: prompt.start.id }, { signal })
|
||||
if (prompt.skill)
|
||||
return client.session.skill({ sessionID: session.id, id: prompt.start.id, skill: prompt.skill.id }, { signal })
|
||||
if (prompt.command) {
|
||||
return client.session.command(
|
||||
{
|
||||
@@ -461,8 +639,8 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
|
||||
|
||||
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
|
||||
if (!state.catalog.modes.some((mode) => mode.id === modeID)) throw new ACPError.InvalidModeError({ mode: modeID })
|
||||
state.modeID = modeID
|
||||
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
|
||||
state.modeID = modeID
|
||||
}
|
||||
|
||||
async function getSession(client: OpenCodeClient, sessionID: string) {
|
||||
@@ -487,26 +665,59 @@ async function messages(client: OpenCodeClient, sessionID: string) {
|
||||
|
||||
async function registerMcpServers(
|
||||
client: OpenCodeClient,
|
||||
registered: Map<string, Set<string>>,
|
||||
current: Map<string, RegisteredMcp>,
|
||||
session: Attached,
|
||||
servers: readonly McpServer[],
|
||||
) {
|
||||
const current = registered.get(session.id) ?? new Set<string>()
|
||||
registered.set(session.id, current)
|
||||
await Promise.all(
|
||||
servers.flatMap((server) => {
|
||||
const requested = new Map(
|
||||
servers.map((server) => {
|
||||
const config = mcpConfig(server)
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (current.has(key)) return []
|
||||
current.add(key)
|
||||
return [
|
||||
client.mcp.add({ server: server.name, location: { directory: session.cwd }, config }).catch((error) => {
|
||||
current.delete(key)
|
||||
throw error
|
||||
}),
|
||||
]
|
||||
return [server.name, { server: server.name, config }] as const
|
||||
}),
|
||||
)
|
||||
const changed = [...requested.values()].filter(
|
||||
(entry) => stableStringify(current.get(entry.server)?.config) !== stableStringify(entry.config),
|
||||
)
|
||||
const registered = new Map(current)
|
||||
changed.forEach((entry) => registered.set(entry.server, entry))
|
||||
const rollback = async () => {
|
||||
const results = await Promise.allSettled(
|
||||
changed.map((entry) => {
|
||||
const previous = current.get(entry.server)
|
||||
if (previous) {
|
||||
return client.mcp.add({
|
||||
server: previous.server,
|
||||
location: { directory: session.cwd },
|
||||
config: previous.config,
|
||||
})
|
||||
}
|
||||
return client.mcp.remove({ server: entry.server, location: { directory: session.cwd } })
|
||||
}),
|
||||
)
|
||||
const failures = results.flatMap((result) => (result.status === "rejected" ? [result.reason] : []))
|
||||
if (failures.length > 0) throw new AggregateError(failures, "Failed to roll back MCP configuration")
|
||||
}
|
||||
const additions = await Promise.allSettled(
|
||||
changed.map((entry) =>
|
||||
client.mcp.add({ server: entry.server, location: { directory: session.cwd }, config: entry.config }),
|
||||
),
|
||||
)
|
||||
const failure = additions.find((result): result is PromiseRejectedResult => result.status === "rejected")
|
||||
if (failure) {
|
||||
const rollbackResult = await rollback().then(
|
||||
() => ({ success: true as const }),
|
||||
(error) => ({ success: false as const, error }),
|
||||
)
|
||||
if (!rollbackResult.success) {
|
||||
throw new McpRollbackError(
|
||||
failure.reason,
|
||||
rollbackResult.error,
|
||||
changed.map((entry) => entry.server),
|
||||
)
|
||||
}
|
||||
throw failure.reason
|
||||
}
|
||||
return { registered, rollback, changed: changed.map((entry) => entry.server) }
|
||||
}
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
|
||||
@@ -375,6 +375,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ export default Runtime.handler(
|
||||
mode: input.service ? "service" : input.stdio ? "stdio" : "default",
|
||||
hostname: Option.getOrUndefined(input.hostname),
|
||||
port: Option.getOrUndefined(input.port),
|
||||
cors: input.cors.length > 0 ? input.cors : undefined,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Options = {
|
||||
readonly mode: Mode
|
||||
readonly hostname?: string
|
||||
readonly port?: number
|
||||
readonly cors?: readonly string[]
|
||||
}
|
||||
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
@@ -88,6 +89,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
},
|
||||
hostname,
|
||||
port,
|
||||
cors: options.cors ?? config.cors,
|
||||
password,
|
||||
pty: { handoff },
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
|
||||
@@ -15,11 +15,12 @@ export const Info = Schema.Struct({
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
cors: Schema.optional(Schema.Array(Schema.String)),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "env"] as const
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -77,7 +78,7 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "env") return key
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -160,6 +161,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
case "password": {
|
||||
return yield* password()
|
||||
}
|
||||
case "cors": {
|
||||
return JSON.stringify((yield* read()).cors ?? [], null, 2)
|
||||
}
|
||||
case "env": {
|
||||
const env = (yield* read()).env ?? {}
|
||||
return name === undefined ? JSON.stringify(env, null, 2) : (env[name] ?? "")
|
||||
@@ -197,6 +201,19 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
yield* write({ ...existing, env: { ...existing.env, [value]: nestedValue } })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
const cors = value.split(",").map((origin) => origin.trim())
|
||||
if (
|
||||
cors.some((origin) => {
|
||||
const url = URL.parse(origin)
|
||||
return !url || (url.protocol !== "http:" && url.protocol !== "https:") || url.origin !== origin
|
||||
})
|
||||
)
|
||||
throw new Error("CORS must be a comma-separated list of HTTP(S) origins without paths or trailing slashes")
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), cors })
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -231,6 +248,12 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
yield* write(Object.keys(env).length === 0 ? rest : { ...rest, env })
|
||||
return
|
||||
}
|
||||
case "cors": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { cors: _cors, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@ describe("acp event behavior", () => {
|
||||
try {
|
||||
await withTimeout(submitted.promise, "cancel test prompt was not admitted")
|
||||
control.cancelled = true
|
||||
control.interrupting = true
|
||||
control.admission.abort()
|
||||
expect(await fixture.client.session.interrupt({ sessionID: "ses_cancel" })).toEqual({ interrupted: true })
|
||||
|
||||
@@ -669,7 +670,7 @@ describe("acp event behavior", () => {
|
||||
|
||||
const response = await withTimeout(result, "pre-admission cancellation did not terminate")
|
||||
expect(response).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
|
||||
expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(0)
|
||||
} finally {
|
||||
control.cancelled = true
|
||||
control.admission.abort()
|
||||
@@ -678,6 +679,169 @@ describe("acp event behavior", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("observes stream failure while cancelled admission settles", async () => {
|
||||
const fixture = createSseFixture()
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const session = new AbortController()
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: `ses_abort_stress_${index}`,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: `input_abort_stress_${index}` },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
sessionSignal: session.signal,
|
||||
submit: (signal) =>
|
||||
new Promise<void>((resolve) => {
|
||||
submitted.resolve()
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void Bun.sleep(5).then(resolve)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
const observed = result.catch((error: unknown) => error)
|
||||
|
||||
await submitted.promise
|
||||
session.abort()
|
||||
expect(await withTimeout(observed, "cancelled stress turn did not settle")).toBeInstanceOf(Error)
|
||||
}
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("connection abort stops a turn with hanging admission", async () => {
|
||||
const fixture = createSseFixture()
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const admissionAborted = Promise.withResolvers<void>()
|
||||
const connection = new AbortController()
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
connectionSignal: connection.signal,
|
||||
sessionID: "ses_connection_abort",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_connection_abort" },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) =>
|
||||
new Promise<void>(() => {
|
||||
submitted.resolve()
|
||||
signal.addEventListener("abort", () => admissionAborted.resolve(), { once: true })
|
||||
}),
|
||||
})
|
||||
const observed = result.catch((error: unknown) => error)
|
||||
|
||||
try {
|
||||
await submitted.promise
|
||||
connection.abort()
|
||||
|
||||
await withTimeout(admissionAborted.promise, "connection abort did not cancel admission")
|
||||
expect(await withTimeout(observed, "connection-aborted turn did not settle")).toBeInstanceOf(Error)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cleans stream setup when attachment is already aborted", async () => {
|
||||
const fixture = createSseFixture()
|
||||
const session = new AbortController()
|
||||
let submitted = false
|
||||
session.abort()
|
||||
|
||||
try {
|
||||
const failure = await streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionSignal: session.signal,
|
||||
sessionID: "ses_preaborted",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_preaborted" },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: async () => {
|
||||
submitted = true
|
||||
},
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
expect(submitted).toBe(false)
|
||||
expect(fixture.streamCount()).toBe(0)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves admission failure when admission settles first", async () => {
|
||||
const fixture = createSseFixture()
|
||||
const expected = new Error("admission failed first")
|
||||
|
||||
try {
|
||||
const failure = await streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
sessionID: "ses_admission_first",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_admission_first" },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => Promise.reject(expected),
|
||||
}).catch((error: unknown) => error)
|
||||
|
||||
expect(failure).toBe(expected)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves stream failure when stream settles before admission", async () => {
|
||||
const fixture = createSseFixture()
|
||||
const connection = new AbortController()
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
const lateAdmission = new Error("admission failed later")
|
||||
const result = streamTurn({
|
||||
client: fixture.client,
|
||||
connection: recordingConnection([]),
|
||||
connectionSignal: connection.signal,
|
||||
sessionID: "ses_stream_first",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_stream_first" },
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) =>
|
||||
new Promise<void>((_, reject) => {
|
||||
submitted.resolve()
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void Bun.sleep(20).then(() => reject(lateAdmission))
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}),
|
||||
})
|
||||
const observed = result.catch((error: unknown) => error)
|
||||
|
||||
try {
|
||||
await submitted.promise
|
||||
connection.abort()
|
||||
|
||||
const failure = await withTimeout(observed, "stream-first failure did not settle")
|
||||
expect(failure).toBeInstanceOf(Error)
|
||||
expect(failure).not.toBe(lateAdmission)
|
||||
} finally {
|
||||
await fixture.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels unsupported session forms so execution can continue", async () => {
|
||||
const fixture = createSseFixture({
|
||||
onPrompt({ id, send }) {
|
||||
|
||||
@@ -181,6 +181,180 @@ describe("acp service directory behavior", () => {
|
||||
expect(invalidConfig).toMatchObject({ _tag: "ACPInvalidConfigOptionError" })
|
||||
})
|
||||
|
||||
test("keeps the last confirmed config after switch requests are rejected", async () => {
|
||||
let rejected: "model" | "effort" | "config-mode" | "mode" | undefined
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_rejected_config") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_rejected_config/model") {
|
||||
if (rejected === "model" || rejected === "effort") return new Response(null, { status: 409 })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_rejected_config/agent") {
|
||||
if (rejected === "config-mode" || rejected === "mode") return new Response(null, { status: 409 })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
rejected = "model"
|
||||
const modelFailure = await fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "model", value: "test/second-model" })
|
||||
.catch((error: unknown) => error)
|
||||
rejected = undefined
|
||||
const afterModelFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
|
||||
rejected = "effort"
|
||||
const effortFailure = await fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "default" })
|
||||
.catch((error: unknown) => error)
|
||||
rejected = undefined
|
||||
const afterEffortFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
})
|
||||
|
||||
rejected = "config-mode"
|
||||
const configModeFailure = await fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "mode", value: "build" })
|
||||
.catch((error: unknown) => error)
|
||||
rejected = undefined
|
||||
const afterConfigModeFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/test-model",
|
||||
})
|
||||
|
||||
rejected = "mode"
|
||||
const modeFailure = await fixture.service
|
||||
.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
.catch((error: unknown) => error)
|
||||
rejected = undefined
|
||||
const afterModeFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
|
||||
expect([modelFailure, effortFailure, configModeFailure, modeFailure]).toEqual([
|
||||
expect.any(Error),
|
||||
expect.any(Error),
|
||||
expect.any(Error),
|
||||
expect.any(Error),
|
||||
])
|
||||
expect(currentValue(afterModelFailure, "model")).toBe("test/test-model")
|
||||
expect(currentValue(afterModelFailure, "effort")).toBe("high")
|
||||
expect(currentValue(afterEffortFailure, "effort")).toBe("high")
|
||||
expect(currentValue(afterConfigModeFailure, "mode")).toBe("plan")
|
||||
expect(currentValue(afterModeFailure, "mode")).toBe("plan")
|
||||
})
|
||||
|
||||
test("orders overlapping model and effort switches for one session", async () => {
|
||||
const modelStarted = Promise.withResolvers<void>()
|
||||
const releaseModel = Promise.withResolvers<void>()
|
||||
let serverModel = "test/test-model/default"
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_ordered_model") })
|
||||
}
|
||||
if (request.method !== "POST" || request.path !== "/api/session/ses_ordered_model/model") return undefined
|
||||
if (serverModel === "test/test-model/default") {
|
||||
serverModel = "test/second-model"
|
||||
modelStarted.resolve()
|
||||
return releaseModel.promise.then(() => new Response(null, { status: 204 }))
|
||||
}
|
||||
serverModel = "test/second-model/medium"
|
||||
return new Response(null, { status: 204 })
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const model = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await modelStarted.promise
|
||||
const effort = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
})
|
||||
releaseModel.resolve()
|
||||
await model
|
||||
const result = await effort
|
||||
|
||||
expect(serverModel).toBe("test/second-model/medium")
|
||||
expect(currentValue(result, "model")).toBe("test/second-model")
|
||||
expect(currentValue(result, "effort")).toBe("medium")
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_ordered_model/model")
|
||||
.map((request) => request.body),
|
||||
).toEqual([
|
||||
{ model: { providerID: "test", id: "second-model" } },
|
||||
{ model: { providerID: "test", id: "second-model", variant: "medium" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("orders overlapping config-mode and setSessionMode switches for one session", async () => {
|
||||
const configModeStarted = Promise.withResolvers<void>()
|
||||
const releaseConfigMode = Promise.withResolvers<void>()
|
||||
let serverMode = "build"
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_ordered_mode") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_ordered_mode/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method !== "POST" || request.path !== "/api/session/ses_ordered_mode/agent") return undefined
|
||||
if (serverMode !== "build") {
|
||||
serverMode = "build"
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
serverMode = "plan"
|
||||
configModeStarted.resolve()
|
||||
return releaseConfigMode.promise.then(() => new Response(null, { status: 204 }))
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const configMode = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
})
|
||||
await configModeStarted.promise
|
||||
const mode = fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
releaseConfigMode.resolve()
|
||||
await Promise.all([configMode, mode])
|
||||
const result = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/test-model",
|
||||
})
|
||||
|
||||
expect(serverMode).toBe("build")
|
||||
expect(currentValue(result, "mode")).toBe("build")
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/session/ses_ordered_mode/agent")
|
||||
.map((request) => request.body),
|
||||
).toEqual([{ agent: "plan" }, { agent: "build" }])
|
||||
})
|
||||
|
||||
test("converts MCP configs and deduplicates registrations per session and config", async () => {
|
||||
const local: McpServer = {
|
||||
name: "tools",
|
||||
|
||||
@@ -30,6 +30,8 @@ type FixtureHandler = (
|
||||
|
||||
type FixtureOptions = {
|
||||
readonly fetch?: FixtureHandler
|
||||
readonly clientFetch?: (...args: Parameters<typeof fetch>) => ReturnType<typeof fetch>
|
||||
readonly sessionUpdate?: AgentSideConnection["sessionUpdate"]
|
||||
readonly models?: readonly ModelInfo[]
|
||||
readonly defaultModel?: ModelInfo
|
||||
readonly agents?: readonly AgentInfo[]
|
||||
@@ -127,13 +129,15 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
const requests: FixtureRequest[] = []
|
||||
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
|
||||
const encoder = new TextEncoder()
|
||||
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const eventControllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const models = options.models ?? [testModel, secondModel]
|
||||
const context: FixtureContext = {
|
||||
requests,
|
||||
send(event) {
|
||||
if (!eventController) throw new Error("ACP fixture has no active event stream")
|
||||
eventController.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
if (eventControllers.size === 0) throw new Error("ACP fixture has no active event stream")
|
||||
eventControllers.forEach((controller) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
|
||||
})
|
||||
},
|
||||
}
|
||||
const server = Bun.serve({
|
||||
@@ -158,11 +162,11 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
eventController = value
|
||||
eventControllers.add(value)
|
||||
context.send({ id: "evt_connected", type: "server.connected", data: {} })
|
||||
},
|
||||
cancel() {
|
||||
if (eventController === controller) eventController = undefined
|
||||
if (controller) eventControllers.delete(controller)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
@@ -185,10 +189,14 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
},
|
||||
})
|
||||
const service = ACPService.make({
|
||||
client: OpenCode.make({ baseUrl: server.url.toString() }),
|
||||
client: OpenCode.make({
|
||||
baseUrl: server.url.toString(),
|
||||
...(options.clientFetch ? { fetch: Object.assign(options.clientFetch, { preconnect: fetch.preconnect }) } : {}),
|
||||
}),
|
||||
connection: {
|
||||
sessionUpdate: async (update) => {
|
||||
updates.push(update)
|
||||
await options.sessionUpdate?.(update)
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
},
|
||||
@@ -198,8 +206,10 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
service,
|
||||
requests,
|
||||
updates,
|
||||
send: (event: unknown) => context.send(event),
|
||||
async [Symbol.asyncDispose]() {
|
||||
eventController?.close()
|
||||
eventControllers.forEach((controller) => controller.close())
|
||||
eventControllers.clear()
|
||||
await server.stop(true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
import { withTimeout } from "./sse-fixture"
|
||||
|
||||
describe("acp service lifecycle", () => {
|
||||
test("does not persist the first catalog variant when no explicit default exists", async () => {
|
||||
@@ -172,6 +173,833 @@ describe("acp service lifecycle", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads authoritative config after an overlapping switch completes", async () => {
|
||||
const switchStarted = Promise.withResolvers<void>()
|
||||
const releaseSwitch = Promise.withResolvers<void>()
|
||||
const nativeFetch = fetch
|
||||
let switching = true
|
||||
let serverModel = makeSession("server").model
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (init?.method !== "GET" || url.pathname !== "/api/session/ses_load_during_switch") {
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
return Promise.resolve(Response.json({ data: makeSession("ses_load_during_switch", { model: serverModel }) }))
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_load_during_switch", { model: serverModel }) })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_load_during_switch/model") {
|
||||
if (!switching) return new Response(null, { status: 204 })
|
||||
switching = false
|
||||
switchStarted.resolve()
|
||||
return releaseSwitch.promise.then(() => {
|
||||
serverModel = { providerID: "test", id: secondModel.id }
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_load_during_switch/message") {
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const switched = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await switchStarted.promise
|
||||
const loaded = fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
|
||||
releaseSwitch.resolve()
|
||||
await switched
|
||||
const result = await loaded
|
||||
const effort = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
})
|
||||
|
||||
expect(currentValue(result, "model")).toBe("test/second-model")
|
||||
expect(currentValue(effort, "effort")).toBe("medium")
|
||||
})
|
||||
|
||||
test("detaches and resumes after an overlapping switch completes", async () => {
|
||||
const switchStarted = Promise.withResolvers<void>()
|
||||
const releaseSwitch = Promise.withResolvers<void>()
|
||||
const nativeFetch = fetch
|
||||
let switching = true
|
||||
let serverModel = makeSession("server").model
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (init?.method !== "GET" || url.pathname !== "/api/session/ses_resume_during_switch") {
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
return Promise.resolve(Response.json({ data: makeSession("ses_resume_during_switch", { model: serverModel }) }))
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_resume_during_switch", { model: serverModel }) })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_resume_during_switch/model") {
|
||||
if (!switching) return new Response(null, { status: 204 })
|
||||
switching = false
|
||||
switchStarted.resolve()
|
||||
return releaseSwitch.promise.then(() => {
|
||||
serverModel = { providerID: "test", id: secondModel.id }
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_resume_during_switch/interrupt") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const switched = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await switchStarted.promise
|
||||
const closed = fixture.service.closeSession({ sessionId: session.sessionId })
|
||||
const resumed = fixture.service.resumeSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
|
||||
releaseSwitch.resolve()
|
||||
await Promise.all([switched, closed])
|
||||
const result = await resumed
|
||||
const effort = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
})
|
||||
|
||||
expect(currentValue(result, "model")).toBe("test/second-model")
|
||||
expect(currentValue(effort, "effort")).toBe("medium")
|
||||
})
|
||||
|
||||
test("forks from authoritative parent config after an overlapping switch completes", async () => {
|
||||
const switchStarted = Promise.withResolvers<void>()
|
||||
const releaseSwitch = Promise.withResolvers<void>()
|
||||
const nativeFetch = fetch
|
||||
let serverModel = makeSession("server").model
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_fork_parent/fork") {
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({ data: makeSession("ses_fork_child", { model: serverModel, agent: "build" }) }),
|
||||
)
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_fork_parent", { model: serverModel }) })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_fork_parent/model") {
|
||||
switchStarted.resolve()
|
||||
return releaseSwitch.promise.then(() => {
|
||||
serverModel = { providerID: "test", id: secondModel.id }
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_fork_child/message") {
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const switched = fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await switchStarted.promise
|
||||
const forked = fixture.service.forkSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
|
||||
releaseSwitch.resolve()
|
||||
await switched
|
||||
const result = await forked
|
||||
|
||||
expect(result.sessionId).toBe("ses_fork_child")
|
||||
expect(currentValue(result, "model")).toBe("test/second-model")
|
||||
})
|
||||
|
||||
test("keeps the prior attachment when staged MCP, command, or replay setup fails", async () => {
|
||||
let phase: "mcp" | "commands" | "replay" | "success" = "mcp"
|
||||
await using fixture = makeACPFixture({
|
||||
sessionUpdate: async () => {
|
||||
if (phase === "commands") throw new Error("command publication failed")
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_attach_transaction") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_attach_transaction") {
|
||||
return Response.json({
|
||||
data: makeSession("ses_attach_transaction", {
|
||||
model: { providerID: secondModel.providerID, id: secondModel.id },
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_attach_transaction/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_attach_transaction/message") {
|
||||
if (phase === "replay") return new Response(null, { status: 500 })
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
}
|
||||
if (request.method === "PUT" && request.path === "/api/mcp/docs" && phase === "mcp") {
|
||||
return new Response(null, { status: 500 })
|
||||
}
|
||||
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path.startsWith("/api/mcp/")) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const tools = { name: "tools", command: "bun", args: ["tools.ts"], env: [] }
|
||||
const docs = { name: "docs", command: "bun", args: ["docs.ts"], env: [] }
|
||||
|
||||
const mcpFailure = await fixture.service
|
||||
.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools, docs] })
|
||||
.catch((error: unknown) => error)
|
||||
const afterMcpFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
|
||||
phase = "commands"
|
||||
const commandFailure = await fixture.service
|
||||
.resumeSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools] })
|
||||
.catch((error: unknown) => error)
|
||||
const afterCommandFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
})
|
||||
|
||||
phase = "replay"
|
||||
const replayFailure = await fixture.service
|
||||
.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [tools] })
|
||||
.catch((error: unknown) => error)
|
||||
const afterReplayFailure = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
|
||||
phase = "success"
|
||||
const attached = await fixture.service.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: session.sessionId,
|
||||
mcpServers: [tools],
|
||||
})
|
||||
|
||||
expect([mcpFailure, commandFailure, replayFailure]).toEqual([
|
||||
expect.any(Error),
|
||||
expect.any(Error),
|
||||
expect.any(Error),
|
||||
])
|
||||
expect(currentValue(afterMcpFailure, "model")).toBe("test/test-model")
|
||||
expect(currentValue(afterCommandFailure, "model")).toBe("test/test-model")
|
||||
expect(currentValue(afterReplayFailure, "model")).toBe("test/test-model")
|
||||
expect(currentValue(attached, "model")).toBe("test/second-model")
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path.startsWith("/api/mcp/"))
|
||||
.map((request) => `${request.method} ${request.path}`),
|
||||
).toEqual([
|
||||
"PUT /api/mcp/tools",
|
||||
"PUT /api/mcp/docs",
|
||||
"DELETE /api/mcp/tools",
|
||||
"DELETE /api/mcp/docs",
|
||||
"PUT /api/mcp/tools",
|
||||
"DELETE /api/mcp/tools",
|
||||
"PUT /api/mcp/tools",
|
||||
])
|
||||
})
|
||||
|
||||
test("serializes MCP attachment transactions for sessions in the same location", async () => {
|
||||
const firstStaged = Promise.withResolvers<void>()
|
||||
const releaseFirst = Promise.withResolvers<void>()
|
||||
let racing = false
|
||||
let created = 0
|
||||
let installed: unknown
|
||||
await using fixture = makeACPFixture({
|
||||
sessionUpdate: async (update) => {
|
||||
if (!racing || update.sessionId !== "ses_location_1") return
|
||||
firstStaged.resolve()
|
||||
await releaseFirst.promise
|
||||
throw new Error("first publication failed")
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
return Response.json({ data: makeSession(`ses_location_${created}`) })
|
||||
}
|
||||
if (request.method === "GET" && request.path.startsWith("/api/session/ses_location_")) {
|
||||
return Response.json({ data: makeSession(request.path.split("/").at(-1) ?? "missing") })
|
||||
}
|
||||
if (request.method === "PUT" && request.path === "/api/mcp/shared") {
|
||||
installed = request.body
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path === "/api/mcp/shared") {
|
||||
installed = undefined
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
racing = true
|
||||
|
||||
const failed = fixture.service
|
||||
.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: first.sessionId,
|
||||
mcpServers: [{ name: "shared", command: "bun", args: ["first.ts"], env: [] }],
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
await firstStaged.promise
|
||||
const succeeded = fixture.service.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: second.sessionId,
|
||||
mcpServers: [{ name: "shared", command: "bun", args: ["second.ts"], env: [] }],
|
||||
})
|
||||
releaseFirst.resolve()
|
||||
|
||||
expect(await failed).toBeInstanceOf(Error)
|
||||
await succeeded
|
||||
expect(installed).toEqual({
|
||||
config: { type: "local", command: ["bun", "second.ts"], environment: {} },
|
||||
})
|
||||
expect(
|
||||
fixture.requests
|
||||
.filter((request) => request.path === "/api/mcp/shared")
|
||||
.map((request) => ({ method: request.method, body: request.body })),
|
||||
).toEqual([
|
||||
{
|
||||
method: "PUT",
|
||||
body: { config: { type: "local", command: ["bun", "first.ts"], environment: {} } },
|
||||
},
|
||||
{ method: "DELETE", body: undefined },
|
||||
{
|
||||
method: "PUT",
|
||||
body: { config: { type: "local", command: ["bun", "second.ts"], environment: {} } },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("invalidates a location when MCP rollback fails", async () => {
|
||||
let failing = false
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
sessionUpdate: async (update) => {
|
||||
if (failing && update.sessionId === "ses_rollback_1") throw new Error("command publication failed")
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
return Response.json({ data: makeSession(`ses_rollback_${created}`) })
|
||||
}
|
||||
if (request.method === "GET" && request.path.startsWith("/api/session/ses_rollback_")) {
|
||||
return Response.json({ data: makeSession(request.path.split("/").at(-1) ?? "missing") })
|
||||
}
|
||||
if (request.method === "PUT" && request.path === "/api/mcp/tools") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path === "/api/mcp/tools") {
|
||||
return new Response(null, { status: failing ? 500 : 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path.endsWith("/model")) {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
failing = true
|
||||
|
||||
const failure = await fixture.service
|
||||
.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: first.sessionId,
|
||||
mcpServers: [{ name: "tools", command: "bun", args: ["tools.ts"], env: [] }],
|
||||
})
|
||||
.catch((error: unknown) => error)
|
||||
const missing = await Promise.all(
|
||||
[first, second].map((session) =>
|
||||
fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
|
||||
.catch((error: unknown) => error),
|
||||
),
|
||||
)
|
||||
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
expect(failure).toMatchObject({ message: "ACP attachment failed and MCP rollback did not complete" })
|
||||
expect(missing).toEqual([
|
||||
expect.objectContaining({ _tag: "ACPSessionNotFoundError" }),
|
||||
expect.objectContaining({ _tag: "ACPSessionNotFoundError" }),
|
||||
])
|
||||
|
||||
failing = false
|
||||
const recovered = await fixture.service.resumeSession({
|
||||
cwd: "/workspace",
|
||||
sessionId: first.sessionId,
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(currentValue(recovered, "model")).toBe("test/test-model")
|
||||
expect(
|
||||
fixture.requests.filter((request) => request.path === "/api/mcp/tools").map((request) => request.method),
|
||||
).toEqual(["PUT", "DELETE", "DELETE"])
|
||||
})
|
||||
|
||||
test("allows MCP attachment transactions in distinct locations to proceed concurrently", async () => {
|
||||
const firstStaged = Promise.withResolvers<void>()
|
||||
const releaseFirst = Promise.withResolvers<void>()
|
||||
let racing = false
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
sessionUpdate: async (update) => {
|
||||
if (!racing || update.sessionId !== "ses_distinct_1") return
|
||||
firstStaged.resolve()
|
||||
await releaseFirst.promise
|
||||
},
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
const cwd = created === 1 ? "/first" : "/second"
|
||||
return Response.json({ data: makeSession(`ses_distinct_${created}`, { cwd }) })
|
||||
}
|
||||
if (request.method === "GET" && request.path.startsWith("/api/session/ses_distinct_")) {
|
||||
const id = request.path.split("/").at(-1) ?? "missing"
|
||||
return Response.json({ data: makeSession(id, { cwd: id.endsWith("1") ? "/first" : "/second" }) })
|
||||
}
|
||||
if (request.method === "PUT" && request.path === "/api/mcp/shared") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const first = await fixture.service.newSession({ cwd: "/first", mcpServers: [] })
|
||||
const second = await fixture.service.newSession({ cwd: "/second", mcpServers: [] })
|
||||
racing = true
|
||||
|
||||
const blocked = fixture.service.resumeSession({
|
||||
cwd: "/first",
|
||||
sessionId: first.sessionId,
|
||||
mcpServers: [{ name: "shared", command: "bun", args: ["first.ts"], env: [] }],
|
||||
})
|
||||
await firstStaged.promise
|
||||
const concurrent = fixture.service.resumeSession({
|
||||
cwd: "/second",
|
||||
sessionId: second.sessionId,
|
||||
mcpServers: [{ name: "shared", command: "bun", args: ["second.ts"], env: [] }],
|
||||
})
|
||||
|
||||
await withTimeout(concurrent, "distinct location transaction was blocked")
|
||||
releaseFirst.resolve()
|
||||
await blocked
|
||||
})
|
||||
|
||||
test("allows config switches for distinct sessions to proceed concurrently", async () => {
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const releaseFirst = Promise.withResolvers<void>()
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
created++
|
||||
return Response.json({ data: makeSession(`ses_concurrent_${created}`) })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_concurrent_1/model") {
|
||||
firstStarted.resolve()
|
||||
return releaseFirst.promise.then(() => new Response(null, { status: 204 }))
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_concurrent_2/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const first = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const second = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const blocked = fixture.service.setSessionConfigOption({
|
||||
sessionId: first.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
await firstStarted.promise
|
||||
const concurrent = await fixture.service.setSessionConfigOption({
|
||||
sessionId: second.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
releaseFirst.resolve()
|
||||
await blocked
|
||||
|
||||
expect(currentValue(concurrent, "effort")).toBe("high")
|
||||
})
|
||||
|
||||
test("load replacement cancels prompt ownership acquired before streaming", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_prompt_load") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_prompt_load") {
|
||||
return Response.json({ data: makeSession("ses_prompt_load") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_prompt_load/message") {
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_load/prompt") {
|
||||
const id = requestField(request.body, "id")
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_prompt_load", inboxID: id },
|
||||
})
|
||||
if (requestField(request.body, "text") === "second") {
|
||||
context.send({
|
||||
id: "evt_second_complete",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_prompt_load" },
|
||||
})
|
||||
}
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
const prompt = fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
})
|
||||
const stoppedPrompt = prompt.then(
|
||||
() => "resolved",
|
||||
() => "rejected",
|
||||
)
|
||||
const loaded = fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
|
||||
await loaded
|
||||
const stopped = await withTimeout(stoppedPrompt, "replaced prompt did not stop")
|
||||
const second = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
})
|
||||
|
||||
expect(stopped).toBe("rejected")
|
||||
expect(second.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("replacement permits a new prompt while the retired request does not settle", async () => {
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstAborted = Promise.withResolvers<void>()
|
||||
const nativeFetch = fetch
|
||||
let prompts = 0
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_prompt_handoff/prompt") {
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
prompts++
|
||||
if (prompts > 1) return nativeFetch(input, init)
|
||||
firstStarted.resolve()
|
||||
init.signal?.addEventListener("abort", () => firstAborted.resolve(), { once: true })
|
||||
return new Promise<Response>(() => {})
|
||||
},
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_prompt_handoff") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_prompt_handoff") {
|
||||
return Response.json({ data: makeSession("ses_prompt_handoff") })
|
||||
}
|
||||
if (request.method === "GET" && request.path === "/api/session/ses_prompt_handoff/message") {
|
||||
return Response.json({ data: [], cursor: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_handoff/prompt") {
|
||||
const id = requestField(request.body, "id")
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_prompt_handoff", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_handoff_complete",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_prompt_handoff" },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
void fixture.service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "never settles" }] })
|
||||
.catch(() => undefined)
|
||||
await firstStarted.promise
|
||||
|
||||
await fixture.service.loadSession({ cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] })
|
||||
await firstAborted.promise
|
||||
const current = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "new owner" }],
|
||||
})
|
||||
|
||||
expect(current.stopReason).toBe("end_turn")
|
||||
expect(prompts).toBe(2)
|
||||
})
|
||||
|
||||
test("cancel permits a new prompt while the cancelled request does not settle", async () => {
|
||||
const firstStarted = Promise.withResolvers<void>()
|
||||
const firstAborted = Promise.withResolvers<void>()
|
||||
const nativeFetch = fetch
|
||||
let prompts = 0
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (init?.method !== "POST" || url.pathname !== "/api/session/ses_prompt_cancel_handoff/prompt") {
|
||||
return nativeFetch(input, init)
|
||||
}
|
||||
prompts++
|
||||
if (prompts > 1) return nativeFetch(input, init)
|
||||
firstStarted.resolve()
|
||||
init.signal?.addEventListener("abort", () => firstAborted.resolve(), { once: true })
|
||||
return new Promise<Response>(() => {})
|
||||
},
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_prompt_cancel_handoff") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_cancel_handoff/interrupt") {
|
||||
return Response.json({ interrupted: true })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_cancel_handoff/prompt") {
|
||||
const id = requestField(request.body, "id")
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_prompt_cancel_handoff", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: "evt_cancel_handoff_complete",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_prompt_cancel_handoff" },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
void fixture.service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "never settles" }] })
|
||||
.catch(() => undefined)
|
||||
await firstStarted.promise
|
||||
|
||||
await fixture.service.cancel({ sessionId: session.sessionId })
|
||||
await firstAborted.promise
|
||||
const current = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "new owner" }],
|
||||
})
|
||||
|
||||
expect(current.stopReason).toBe("end_turn")
|
||||
expect(prompts).toBe(2)
|
||||
})
|
||||
|
||||
test("failed authoritative interruption retains sole prompt ownership", async () => {
|
||||
const admitted = Promise.withResolvers<void>()
|
||||
const afterFailure = Promise.withResolvers<void>()
|
||||
let interruptFails = true
|
||||
let prompts = 0
|
||||
await using fixture = makeACPFixture({
|
||||
sessionUpdate: async (update) => {
|
||||
if (
|
||||
update.update.sessionUpdate === "agent_message_chunk" &&
|
||||
update.update.content.type === "text" &&
|
||||
update.update.content.text === "before cancel"
|
||||
) {
|
||||
admitted.resolve()
|
||||
}
|
||||
if (
|
||||
update.update.sessionUpdate === "agent_message_chunk" &&
|
||||
update.update.content.type === "text" &&
|
||||
update.update.content.text === "after failure"
|
||||
) {
|
||||
afterFailure.resolve()
|
||||
}
|
||||
},
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_interrupt_owner") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_interrupt_owner/prompt") {
|
||||
prompts++
|
||||
const id = requestField(request.body, "id")
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_interrupt_owner", inboxID: id },
|
||||
})
|
||||
context.send({
|
||||
id: `evt_text_${prompts}`,
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID: "ses_interrupt_owner",
|
||||
assistantMessageID: `msg_assistant_${prompts}`,
|
||||
ordinal: 0,
|
||||
delta: prompts === 1 ? "before cancel" : "replacement",
|
||||
},
|
||||
})
|
||||
if (prompts > 1) {
|
||||
context.send({
|
||||
id: "evt_replacement_complete",
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_interrupt_owner" },
|
||||
})
|
||||
}
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_interrupt_owner/interrupt") {
|
||||
if (interruptFails) return new Response(null, { status: 500 })
|
||||
context.send({
|
||||
id: "evt_owner_interrupted",
|
||||
type: "session.execution.interrupted",
|
||||
data: { sessionID: "ses_interrupt_owner", reason: "user" },
|
||||
})
|
||||
return Response.json({ interrupted: true })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const first = fixture.service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "first" }] })
|
||||
.catch((error: unknown) => error)
|
||||
await admitted.promise
|
||||
|
||||
const interruptionFailure = await fixture.service.cancel({ sessionId: session.sessionId }).catch((error) => error)
|
||||
const replacementFailure = await fixture.service
|
||||
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "must wait" }] })
|
||||
.catch((error: unknown) => error)
|
||||
fixture.updates.length = 0
|
||||
fixture.send({
|
||||
id: "evt_after_failed_interrupt",
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID: "ses_interrupt_owner",
|
||||
assistantMessageID: "msg_assistant_1",
|
||||
ordinal: 1,
|
||||
delta: "after failure",
|
||||
},
|
||||
})
|
||||
await afterFailure.promise
|
||||
const activeStream = fixture.requests.filter((request) => request.path === "/api/event").length
|
||||
|
||||
expect(interruptionFailure).toBeInstanceOf(Error)
|
||||
expect(replacementFailure).toMatchObject({ _tag: "ACPServiceFailureError" })
|
||||
expect(activeStream).toBe(1)
|
||||
expect(
|
||||
fixture.updates.flatMap((update) =>
|
||||
update.update.sessionUpdate === "agent_message_chunk" && update.update.content.type === "text"
|
||||
? [update.update.content.text]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["after failure"])
|
||||
|
||||
interruptFails = false
|
||||
await fixture.service.cancel({ sessionId: session.sessionId })
|
||||
await first
|
||||
const replacement = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "replacement" }],
|
||||
})
|
||||
|
||||
expect(replacement.stopReason).toBe("end_turn")
|
||||
expect(prompts).toBe(2)
|
||||
expect(
|
||||
fixture.updates.flatMap((update) =>
|
||||
update.update.sessionUpdate === "agent_message_chunk" && update.update.content.type === "text"
|
||||
? [update.update.content.text]
|
||||
: [],
|
||||
),
|
||||
).toEqual(["after failure", "replacement"])
|
||||
})
|
||||
|
||||
test("delete detaches and cancels an active foreground prompt", async () => {
|
||||
const promptStarted = Promise.withResolvers<void>()
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_prompt_delete") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_delete/prompt") {
|
||||
const id = requestField(request.body, "id")
|
||||
context.send({
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_prompt_delete", inboxID: id },
|
||||
})
|
||||
promptStarted.resolve()
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_prompt_delete/model") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path === "/api/session/ses_prompt_delete") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
const prompt = fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "running" }],
|
||||
})
|
||||
const stoppedPrompt = prompt.then(
|
||||
() => "resolved",
|
||||
() => "rejected",
|
||||
)
|
||||
await promptStarted.promise
|
||||
|
||||
const configured = await fixture.service.setSessionConfigOption({
|
||||
sessionId: session.sessionId,
|
||||
configId: "effort",
|
||||
value: "high",
|
||||
})
|
||||
await fixture.service.deleteSession({ sessionId: session.sessionId })
|
||||
const stopped = await withTimeout(stoppedPrompt, "deleted session prompt did not stop")
|
||||
|
||||
expect(currentValue(configured, "effort")).toBe("high")
|
||||
expect(stopped).toBe("rejected")
|
||||
})
|
||||
|
||||
test("lists server-backed pages and forwards cwd and cursor", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) =>
|
||||
makeSession(`ses_${100 - index}`, {
|
||||
@@ -251,11 +1079,7 @@ describe("acp service lifecycle", () => {
|
||||
expect(await fixture.service.closeSession({ sessionId: "missing" })).toEqual({})
|
||||
expect(
|
||||
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
|
||||
).toEqual([
|
||||
"/api/session/ses_lifecycle/interrupt",
|
||||
"/api/session/ses_lifecycle/interrupt",
|
||||
"/api/session/missing/interrupt",
|
||||
])
|
||||
).toEqual(["/api/session/ses_lifecycle/interrupt", "/api/session/missing/interrupt"])
|
||||
})
|
||||
|
||||
test("deletes sessions from backing and local storage", async () => {
|
||||
@@ -289,3 +1113,10 @@ describe("acp service lifecycle", () => {
|
||||
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
|
||||
return result.configOptions?.find((option) => option.id === id)?.currentValue
|
||||
}
|
||||
|
||||
function requestField(value: unknown, key: string) {
|
||||
if (!value || typeof value !== "object") throw new Error(`Missing request ${key}`)
|
||||
const field = Reflect.get(value, key)
|
||||
if (typeof field !== "string") throw new Error(`Missing request ${key}`)
|
||||
return field
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { makeACPFixture, makeSession, secondModel, type FixtureContext, type FixtureRequest } from "./service-fixture"
|
||||
import { withTimeout } from "./sse-fixture"
|
||||
|
||||
describe("acp service prompt routing and usage", () => {
|
||||
test("routes slash commands, skills, and compact through their session endpoints", async () => {
|
||||
@@ -69,6 +70,87 @@ describe("acp service prompt routing and usage", () => {
|
||||
expect(fixture.requests.some((request) => request.path === "/api/session/ses_routes/prompt")).toBe(false)
|
||||
})
|
||||
|
||||
test("forwards admission signals through every prompt route", async () => {
|
||||
const nativeFetch = fetch
|
||||
const signalled: string[] = []
|
||||
await using fixture = makeACPFixture({
|
||||
clientFetch(input, init) {
|
||||
const url = new URL(input instanceof Request ? input.url : input.toString())
|
||||
if (
|
||||
["synthetic", "prompt", "skill", "compact", "command"].some((suffix) => url.pathname.endsWith(`/${suffix}`))
|
||||
) {
|
||||
if (init?.signal instanceof AbortSignal) signalled.push(url.pathname.split("/").at(-1) ?? "")
|
||||
}
|
||||
return nativeFetch(input, init)
|
||||
},
|
||||
fetch(request, context) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_signals") })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_signals/synthetic") {
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_signals/prompt") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_signals", {
|
||||
id: id.replace(/^msg_/, "evt_"),
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_signals", inboxID: id },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_signals/skill") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_signals", {
|
||||
id: id.replace(/^msg_/, "evt_"),
|
||||
type: "session.skill.activated",
|
||||
data: { sessionID: "ses_signals", skill: "verify" },
|
||||
})
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_signals/compact") {
|
||||
const id = requestID(request)
|
||||
completeTurn(context, "ses_signals", {
|
||||
id: `evt_${id}`,
|
||||
type: "session.inbox.delivered",
|
||||
data: { sessionID: "ses_signals", inboxID: id },
|
||||
})
|
||||
return Response.json({ data: {} })
|
||||
}
|
||||
if (request.method === "POST" && request.path === "/api/session/ses_signals/command") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
await withTimeout(
|
||||
fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "context", annotations: { audience: ["assistant"] } },
|
||||
{ type: "text", text: "hello" },
|
||||
],
|
||||
}),
|
||||
"synthetic prompt did not complete",
|
||||
)
|
||||
await withTimeout(
|
||||
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/verify" }] }),
|
||||
"skill prompt did not complete",
|
||||
)
|
||||
await withTimeout(
|
||||
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
|
||||
"compact prompt did not complete",
|
||||
)
|
||||
await withTimeout(
|
||||
fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/review" }] }),
|
||||
"command prompt did not complete",
|
||||
)
|
||||
|
||||
expect(signalled).toEqual(["synthetic", "prompt", "skill", "compact", "command"])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = {
|
||||
input: 100,
|
||||
|
||||
@@ -166,6 +166,7 @@ export function createSseFixture(options: FixtureOptions = {}) {
|
||||
messages,
|
||||
requests,
|
||||
send,
|
||||
streamCount: () => streams.size,
|
||||
async stop() {
|
||||
for (const stream of streams) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Exit, FileSystem } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../src/commands/commands"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
|
||||
it.live("service CORS config persists multiple origins and preserves other settings on set and unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { hostname: "127.0.0.1", port: 4321, password: "test-secret", env: { TEST: "value" } }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
yield* ServiceConfig.set("cors", " http://192.0.2.10:3001, https://app.example.com ")
|
||||
const cors = ["http://192.0.2.10:3001", "https://app.example.com"]
|
||||
expect(yield* ServiceConfig.read()).toEqual({ ...existing, cors })
|
||||
expect(yield* ServiceConfig.get("cors")).toBe(JSON.stringify(cors, null, 2))
|
||||
expect(JSON.parse(yield* ServiceConfig.get())).toEqual({
|
||||
hostname: existing.hostname,
|
||||
port: existing.port,
|
||||
env: existing.env,
|
||||
cors,
|
||||
})
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual({ ...existing, cors })
|
||||
yield* ServiceConfig.set("cors", "https://replacement.example.com")
|
||||
expect((yield* ServiceConfig.read()).cors).toEqual(["https://replacement.example.com"])
|
||||
yield* ServiceConfig.unset("cors")
|
||||
expect(yield* ServiceConfig.get("cors")).toBe("[]")
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
it.live("service CORS config rejects empty lists, invalid origins, and extra arguments without changing config", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-cors-invalid-" })
|
||||
const config = path.join(root, "config")
|
||||
const state = path.join(root, "state")
|
||||
const file = path.join(config, ServiceConfig.filename())
|
||||
const existing = { port: 4321, cors: ["https://app.example.com"] }
|
||||
yield* fs.makeDirectory(config)
|
||||
yield* fs.makeDirectory(state)
|
||||
yield* fs.writeFileString(file, JSON.stringify(existing))
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"",
|
||||
" ",
|
||||
",",
|
||||
"https://app.example.com,",
|
||||
",https://app.example.com",
|
||||
"https://app.example.com,,https://other.example.com",
|
||||
"not-a-url",
|
||||
"*",
|
||||
"null",
|
||||
"ftp://app.example.com",
|
||||
"https://app.example.com/",
|
||||
"https://app.example.com/path",
|
||||
"https://app.example.com?query=1",
|
||||
"https://app.example.com#fragment",
|
||||
"https://user:password@app.example.com",
|
||||
],
|
||||
(value) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* ServiceConfig.set("cors", value).pipe(Effect.exit))).toBe(true)
|
||||
expect(yield* ServiceConfig.read()).toEqual(existing)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
ServiceConfig.get("cors", "extra"),
|
||||
ServiceConfig.set("cors", "https://app.example.com", "extra"),
|
||||
ServiceConfig.unset("cors", "extra"),
|
||||
],
|
||||
(operation) =>
|
||||
Effect.gen(function* () {
|
||||
expect(Exit.isFailure(yield* operation.pipe(Effect.exit))).toBe(true)
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(yield* fs.readFileString(file))).toEqual(existing)
|
||||
}).pipe(Effect.provideService(Global.Service, Global.make({ config, state })))
|
||||
}).pipe(Effect.provide(NodeServices.layer)),
|
||||
)
|
||||
|
||||
test.each([
|
||||
{ args: [], cors: [] },
|
||||
{ args: ["--cors", "https://app.example.com"], cors: ["https://app.example.com"] },
|
||||
{
|
||||
args: ["--service", "--cors", "http://192.0.2.10:3001", "--cors", "https://app.example.com"],
|
||||
cors: ["http://192.0.2.10:3001", "https://app.example.com"],
|
||||
},
|
||||
])("serve parses CORS flags: $args", async ({ args, cors }) => {
|
||||
const received: (readonly string[])[] = []
|
||||
const command = Commands.commands.serve.spec.pipe(
|
||||
Command.withHandler((input) => Effect.sync(() => void received.push(input.cors))),
|
||||
)
|
||||
await Effect.runPromise(Command.runWith(command, { version: "test" })(args).pipe(Effect.provide(NodeServices.layer)))
|
||||
expect(received).toEqual([cors])
|
||||
})
|
||||
|
||||
test.each([{ args: ["--cors"] }, { args: ["--cors", ""] }])(
|
||||
"serve rejects a missing or empty CORS flag value: $args",
|
||||
async ({ args }) => {
|
||||
const command = Commands.commands.serve.spec.pipe(Command.withHandler(() => Effect.void))
|
||||
const result = await Effect.runPromise(
|
||||
Command.runWith(command, { version: "test", renderErrors: false })(args).pipe(
|
||||
Effect.exit,
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
expect(Exit.isFailure(result)).toBe(true)
|
||||
},
|
||||
)
|
||||
@@ -9,6 +9,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ServiceConfig } from "../src/services/service-config"
|
||||
import { ServiceRegistration } from "../src/services/service-registration"
|
||||
import { isolatedEnv } from "./fixture/environment"
|
||||
|
||||
test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
|
||||
@@ -313,6 +314,48 @@ test("configured managed service port overrides the channel default", async () =
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test.each([
|
||||
{ args: [], origins: ["http://192.0.2.10:3001", "https://configured.example.com"] },
|
||||
{
|
||||
args: ["--cors", "http://192.0.2.20:3001", "--cors", "https://override.example.com"],
|
||||
origins: ["http://192.0.2.20:3001", "https://override.example.com"],
|
||||
},
|
||||
])(
|
||||
"managed service applies CORS configuration with flag overrides: $args",
|
||||
async ({ args, origins }) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-cors-"))
|
||||
const config = path.join(root, "config", ServiceConfig.filename())
|
||||
const registration = path.join(root, "state", "opencode", ServiceConfig.filename())
|
||||
const cors = ["http://192.0.2.10:3001", "https://configured.example.com"]
|
||||
await fs.mkdir(path.dirname(config), { recursive: true })
|
||||
await fs.writeFile(config, JSON.stringify({ cors }))
|
||||
const owner = Bun.spawn(
|
||||
[process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service", "--port", "0", ...args],
|
||||
{ env: isolatedEnv(root), stderr: "pipe", stdout: "ignore" },
|
||||
)
|
||||
try {
|
||||
const info = await waitForInfo(registration)
|
||||
await Promise.all(
|
||||
[...new Set([...cors, ...origins, "https://unlisted.example.com"])].map(async (origin) => {
|
||||
const response = await fetch(new URL("/api/health", info.url), {
|
||||
method: "OPTIONS",
|
||||
headers: { Origin: origin, "Access-Control-Request-Method": "GET" },
|
||||
})
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe(
|
||||
origins.some((value) => value === origin) ? origin : null,
|
||||
)
|
||||
}),
|
||||
)
|
||||
expect((await Bun.file(config).json()).cors).toEqual(cors)
|
||||
} finally {
|
||||
owner.kill("SIGTERM")
|
||||
await owner.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
)
|
||||
|
||||
test("unrelated managed port occupancy reports an actionable conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-conflict-"))
|
||||
const listener = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("unrelated") })
|
||||
|
||||
@@ -1950,6 +1950,12 @@ export type VcsGetInput = {
|
||||
export type VcsGetOutput = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type VcsBaseOutput = { readonly location: Location.Info; readonly data: Vcs.Base | null }
|
||||
export type VcsBaseOperation<E = never> = (input?: VcsBaseInput) => Effect.Effect<VcsBaseOutput, E>
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
@@ -1967,6 +1973,7 @@ export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effe
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type VcsDiffOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
@@ -1974,6 +1981,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly base: VcsBaseOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
readonly branches: VcsBranchesOperation<E>
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
|
||||
@@ -244,6 +244,8 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1468,6 +1470,11 @@ const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsBase = (raw: RawClient["server.vcs"]) => (input?: VcsBaseInput) =>
|
||||
preserveEffect<VcsBaseOutput>()(
|
||||
raw["vcs.base"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusInput) =>
|
||||
preserveEffect<VcsStatusOutput>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1482,13 +1489,14 @@ const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranch
|
||||
|
||||
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
|
||||
preserveEffect<VcsDiffOutput>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["vcs.diff"]({
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
get: EndpointVcsGet(raw),
|
||||
base: EndpointVcsBase(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
branches: EndpointVcsBranches(raw),
|
||||
diff: EndpointVcsDiff(raw),
|
||||
|
||||
@@ -34,6 +34,7 @@ export { Permission } from "@opencode-ai/schema/permission"
|
||||
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
export { Project } from "@opencode-ai/schema/project"
|
||||
export { Worktree } from "@opencode-ai/schema/worktree"
|
||||
export { Vcs } from "@opencode-ai/schema/vcs"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
|
||||
@@ -240,6 +240,8 @@ import type {
|
||||
WorkspaceDestroyOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
VcsBaseOutput,
|
||||
VcsStatusInput,
|
||||
VcsStatusOutput,
|
||||
VcsBranchesInput,
|
||||
@@ -1367,7 +1369,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1496,7 +1498,7 @@ export function make(options: ClientOptions) {
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -1996,6 +1998,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
base: (input?: VcsBaseInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsBaseOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/base`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
status: (input?: VcsStatusInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsStatusOutput>(
|
||||
{
|
||||
@@ -2025,9 +2039,9 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/vcs/diff`,
|
||||
query: { location: input["location"], mode: input["mode"], context: input["context"] },
|
||||
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -419,6 +419,8 @@ export type WorkspaceDestroyResult = { destroyed: boolean }
|
||||
|
||||
export type VcsBranch = { current?: string; default?: string }
|
||||
|
||||
export type VcsBase = { name: string; ref: string; source: "reflog" | "default" }
|
||||
|
||||
export type VcsFileStatus = {
|
||||
file: string
|
||||
additions: number
|
||||
@@ -6073,6 +6075,17 @@ export type VcsGetOutput = {
|
||||
data: VcsInfo
|
||||
}
|
||||
|
||||
export type VcsBaseInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type VcsBaseOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: VcsBase | null
|
||||
}
|
||||
|
||||
export type VcsStatusInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -6110,17 +6123,26 @@ export type VcsBranchesOutput = {
|
||||
export type VcsDiffInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["location"]
|
||||
readonly mode: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["mode"]
|
||||
readonly base?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["base"]
|
||||
readonly context?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: "working" | "branch"
|
||||
readonly mode: "working" | "branch" | "committed"
|
||||
readonly base?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
@@ -724,7 +750,8 @@ export function createData(config: CreateDataInput) {
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
metadata: event.metadata,
|
||||
metadata:
|
||||
event.data.shell.metadata.background === true ? { ...event.metadata, background: true } : event.metadata,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -983,6 +1010,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 +1108,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 +1295,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 +1379,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 +1454,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 +1531,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}`)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Prompt } from "@opencode-ai/schema/prompt"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
|
||||
const Client = await import("../src/effect")
|
||||
|
||||
@@ -14,6 +15,7 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Config).toBe(Config)
|
||||
expect(Client.Model).toBe(Model)
|
||||
expect(Client.Session).toBe(Session)
|
||||
expect(Client.Vcs.Base).toBe(Vcs.Base)
|
||||
})
|
||||
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
|
||||
@@ -27,6 +27,26 @@ test("health.get decodes the readiness response", async () => {
|
||||
expect(result).toEqual({ healthy: true, version: "old", pid: 123 })
|
||||
})
|
||||
|
||||
test("vcs.base decodes nullable review-base metadata", async () => {
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = {
|
||||
name: "release",
|
||||
ref: "refs/remotes/origin/release",
|
||||
source: "reflog",
|
||||
}
|
||||
for (const data of [base, null]) {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ location, data }))),
|
||||
)
|
||||
const result = await Effect.gen(function* () {
|
||||
const client = yield* OpenCode.make({ baseUrl: "http://localhost:3000" })
|
||||
return yield* client.vcs.base({ location: { directory: AbsolutePath.make("/repo") } })
|
||||
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
|
||||
expect(result.data).toEqual(data)
|
||||
expect(result.location.directory).toBe("/repo")
|
||||
}
|
||||
})
|
||||
|
||||
test("session.get returns the decoded Effect projection", async () => {
|
||||
const httpClient = HttpClient.make((request) =>
|
||||
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(session))),
|
||||
|
||||
@@ -47,7 +47,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
|
||||
expect(Object.keys(client.pty.connect)).toEqual(["token"])
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
@@ -84,6 +84,46 @@ test("config.get returns ordered config entries for a location", async () => {
|
||||
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
})
|
||||
|
||||
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
|
||||
const requests: Request[] = []
|
||||
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
|
||||
const base = { name: "release", ref: "refs/remotes/origin/release", source: "reflog" }
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push(request)
|
||||
return Response.json({ location, data: new URL(request.url).pathname.endsWith("/base") ? base : [] })
|
||||
},
|
||||
})
|
||||
expect(await client.vcs.base({ location: { directory: "/repo" } })).toEqual({ location, data: base })
|
||||
expect(
|
||||
await client.vcs.diff({ location: { directory: "/repo" }, mode: "committed", base: base.ref, context: 1 }),
|
||||
).toEqual({ location, data: [] })
|
||||
expect(new URL(requests[0].url).pathname).toBe("/api/vcs/base")
|
||||
const query = new URL(requests[1].url).searchParams
|
||||
expect(query.get("location[directory]")).toBe("/repo")
|
||||
expect(query.get("mode")).toBe("committed")
|
||||
expect(query.get("base")).toBe(base.ref)
|
||||
expect(query.get("context")).toBe("1")
|
||||
})
|
||||
|
||||
test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "ServiceUnavailableError", service: "vcs", message: "No review base available" },
|
||||
{ status: 503 },
|
||||
),
|
||||
})
|
||||
await expect(client.vcs.diff({ mode: "committed" })).rejects.toMatchObject({
|
||||
_tag: "ServiceUnavailableError",
|
||||
service: "vcs",
|
||||
message: "No review base available",
|
||||
})
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
|
||||
@@ -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>()
|
||||
@@ -551,6 +666,36 @@ test("ignores activity snapshots from an older connection", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("projects background user shell metadata from durable shell data", () => {
|
||||
const setup = activityFixture(() => Response.json({ data: {} }))
|
||||
try {
|
||||
setup.emit({
|
||||
id: "evt_user_shell",
|
||||
created: 1,
|
||||
type: "session.shell.started",
|
||||
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "ses_refresh",
|
||||
shell: {
|
||||
id: "sh_user",
|
||||
status: "running",
|
||||
command: "pwd",
|
||||
cwd: "/project",
|
||||
shell: "/bin/sh",
|
||||
file: "/project/shell.out",
|
||||
metadata: { sessionID: "ses_refresh", background: true },
|
||||
time: { started: 1 },
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(setup.data.session.message.list("ses_refresh")).toMatchObject([
|
||||
{ type: "shell", shellID: "sh_user", status: "running", metadata: { background: true } },
|
||||
])
|
||||
} finally {
|
||||
setup.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
function activityFixture(read: () => Response | Promise<Response>) {
|
||||
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
|
||||
const api = OpenCode.make({
|
||||
|
||||
@@ -24,8 +24,8 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
|
||||
const response = yield* client
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
||||
Effect.mapError((cause) =>
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause),
|
||||
),
|
||||
)
|
||||
const text = yield* readResponseBody(response, plan)
|
||||
|
||||
Vendored
+2
@@ -1,5 +1,7 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
|
||||
import "@solidjs/start"
|
||||
|
||||
export declare module "@solidjs/start/server" {
|
||||
export type APIEvent = { request: Request }
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -482,8 +482,7 @@ function toolMessage(input: LLMRequest["messages"][number]) {
|
||||
const value = part.result.value.filter((item) => {
|
||||
if (item.type !== "file") return true
|
||||
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
media.push({ type: "file", mediaType: item.mime, data: fileData(item.uri), filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
@@ -507,7 +506,7 @@ function text(part: ContentPart) {
|
||||
function userPart(part: ContentPart): UserContent {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "media")
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -516,7 +515,7 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "media":
|
||||
return [{ type: "file", mediaType: part.mediaType, data: part.data, filename: part.filename }]
|
||||
return [{ type: "file", mediaType: part.mediaType, data: fileData(part.data), filename: part.filename }]
|
||||
case "reasoning":
|
||||
return [{ type: "reasoning", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "tool-call":
|
||||
@@ -535,6 +534,15 @@ function assistantPart(part: ContentPart): AssistantContent {
|
||||
}
|
||||
}
|
||||
|
||||
function fileData(data: Extract<ContentPart, { type: "media" }>["data"]) {
|
||||
if (typeof data !== "string") return data
|
||||
const base64 = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(data)?.[1]
|
||||
if (base64 !== undefined) return base64
|
||||
if (!URL.canParse(data)) return data
|
||||
const url = new URL(data)
|
||||
return url.protocol === "http:" || url.protocol === "https:" ? url : data
|
||||
}
|
||||
|
||||
function toolResultPart(part: ContentPart): ToolResultContent[] {
|
||||
if (part.type !== "tool-result") return []
|
||||
return [
|
||||
|
||||
+148
-158
@@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -865,16 +859,12 @@ export function configured(options?: Options) {
|
||||
aggregateID: input.aggregateID,
|
||||
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
|
||||
}
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
|
||||
Stream.map((event): LogItem => event),
|
||||
Stream.concat(Stream.make(marker)),
|
||||
)
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(Stream.concat(Stream.make(marker)))
|
||||
if (!wakes) return replay
|
||||
const live: Stream.Stream<LogItem> = Stream.fromSubscription(wakes).pipe(
|
||||
Stream.mapEffect(() => latestSequence(db, input.aggregateID)),
|
||||
Stream.filter((target) => target > sequence),
|
||||
Stream.flatMap((target) => readThrough(target)),
|
||||
Stream.map((event): LogItem => event),
|
||||
)
|
||||
return Stream.concat(replay, live)
|
||||
}),
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
export * as CommandInvocation from "./invocation.js"
|
||||
|
||||
import type { Plugin } from "@opencode-ai/plugin/effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import type { Command } from "../command.js"
|
||||
import { Location } from "../location.js"
|
||||
import { ShellSelect } from "../shell/select.js"
|
||||
|
||||
// Invocation for configured template commands; source loading and registration stay with the caller.
|
||||
export const make = Effect.fnUntraced(function* (ctx: Pick<Plugin.Context, "agent" | "session">) {
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
return Effect.fn("CommandInvocation.invoke")(function* (command: ConfigCommand.Info, input: Command.Invocation) {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined ? {} : { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, { location, processes, shell }),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError(
|
||||
(error) => new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
@@ -316,16 +316,15 @@ export const layer = (options?: Options) =>
|
||||
}
|
||||
})
|
||||
|
||||
const reload = Effect.fn("Config.reload")(() =>
|
||||
reloadLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
}),
|
||||
),
|
||||
const reload = Effect.fn("Config.reload")(
|
||||
function* () {
|
||||
const next = yield* discover()
|
||||
yield* reconcile(next)
|
||||
if (isDeepStrictEqual(configs, next)) return
|
||||
configs = next
|
||||
yield* bus.publish(Event.Updated, {})
|
||||
},
|
||||
(effect) => reloadLock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(updates).pipe(
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -292,18 +292,13 @@ function normalizeMcpTimeout(
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
||||
const recognized = Object.entries(ConfigMCP.Timeout.fields).filter(([key]) => own(value, key))
|
||||
if (Object.keys(value).length && !recognized.length) {
|
||||
invalid(path, diagnostics)
|
||||
return
|
||||
}
|
||||
recognized.forEach((key) => {
|
||||
const leaf = decodeEncoded(
|
||||
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
||||
value[key],
|
||||
[...path, key],
|
||||
diagnostics,
|
||||
)
|
||||
recognized.forEach(([key, field]) => {
|
||||
const leaf = decodeEncoded(field, value[key], [...path, key], diagnostics)
|
||||
if (leaf === undefined) return
|
||||
overlay(timeout, key, leaf, [...path, key], diagnostics)
|
||||
})
|
||||
|
||||
@@ -32,19 +32,7 @@ type PathAction =
|
||||
| typeof ReadTool.name
|
||||
| typeof EditTool.name
|
||||
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
|
||||
const agentKeys = new Set([
|
||||
"model",
|
||||
"variant",
|
||||
"request",
|
||||
"system",
|
||||
"description",
|
||||
"mode",
|
||||
"hidden",
|
||||
"color",
|
||||
"steps",
|
||||
"disabled",
|
||||
"permissions",
|
||||
])
|
||||
const agentKeys = new Set(["variant", ...Object.keys(ConfigAgent.Info.fields)])
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.config.agent",
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
export * as ConfigCommandPlugin from "./command.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { CommandInvocation } from "../../command/invocation.js"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
import { ShellSelect } from "../../shell/select.js"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
|
||||
@@ -23,7 +29,9 @@ export const Plugin = define({
|
||||
const commands = yield* loadDirectory(fs, entry.path)
|
||||
return [{ commands: Object.fromEntries(commands.map((command) => [command.name, command.info])) }]
|
||||
})
|
||||
const invoke = yield* CommandInvocation.make(ctx)
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const load = Effect.fn("ConfigCommandPlugin.load")(function* () {
|
||||
return yield* Effect.forEach(yield* config.entries(), loadEntry).pipe(Effect.map((documents) => documents.flat()))
|
||||
})
|
||||
@@ -55,7 +63,37 @@ export const Plugin = define({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) => invoke(command, input),
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const agent = command.agent === undefined ? undefined : Agent.ID.make(command.agent)
|
||||
const commandAgent = yield* Effect.gen(function* () {
|
||||
if (agent === undefined) return
|
||||
const session = yield* ctx.session.get({ sessionID: input.sessionID })
|
||||
if (session.agent !== agent) yield* ctx.session.switchAgent({ sessionID: input.sessionID, agent })
|
||||
return (yield* ctx.agent.get({ agentID: agent })).data
|
||||
})
|
||||
const model =
|
||||
command.model === undefined
|
||||
? commandAgent?.model
|
||||
: {
|
||||
id: Model.ID.make(command.model.model),
|
||||
providerID: Provider.ID.make(command.model.providerID),
|
||||
...(command.model.variant === undefined
|
||||
? {}
|
||||
: { variant: Model.VariantID.make(command.model.variant) }),
|
||||
}
|
||||
if (model !== undefined) yield* ctx.session.switchModel({ sessionID: input.sessionID, model })
|
||||
yield* ctx.session.prompt({
|
||||
...input.prompt,
|
||||
sessionID: input.sessionID,
|
||||
text: yield* evaluateTemplate(command.template, input.prompt.text, {
|
||||
location,
|
||||
processes,
|
||||
shell,
|
||||
}),
|
||||
delivery: input.delivery,
|
||||
})
|
||||
}).pipe(Effect.asVoid),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,3 +146,66 @@ function decode(directory: string, filepath: string, content: string) {
|
||||
info,
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateTemplate(
|
||||
template: string,
|
||||
input: string,
|
||||
services: {
|
||||
readonly location: Location.Info
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly shell: ShellSelect.Interface
|
||||
},
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const args = parseArguments(input)
|
||||
const placeholders = template.match(placeholderRegex) ?? []
|
||||
const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1))))
|
||||
const expanded = template.replaceAll(placeholderRegex, (_, index) => {
|
||||
const position = Number(index)
|
||||
const argIndex = position - 1
|
||||
if (argIndex >= args.length) return ""
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
: withArguments.trim()
|
||||
const matches = Array.from(text.matchAll(shellRegex))
|
||||
if (matches.length === 0) return text
|
||||
const shell = yield* services.shell.resolve({ priority: "config" })
|
||||
const outputs = yield* Effect.forEach(
|
||||
matches,
|
||||
(match) => {
|
||||
const source = match[1] ?? ""
|
||||
return services.processes
|
||||
.run(
|
||||
ChildProcess.make(shell, ShellSelect.args(shell, source), {
|
||||
cwd: services.location.directory,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
{ combineOutput: true },
|
||||
)
|
||||
.pipe(
|
||||
Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")),
|
||||
Effect.mapError((error) =>
|
||||
new Error(`Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}`),
|
||||
),
|
||||
)
|
||||
},
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const iterator = outputs[Symbol.iterator]()
|
||||
return text.replace(shellRegex, () => iterator.next().value ?? "")
|
||||
})
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, ""))
|
||||
}
|
||||
|
||||
const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
const shellRegex = /!`([^`]+)`/g
|
||||
|
||||
@@ -83,26 +83,25 @@ export const Plugin = define({
|
||||
),
|
||||
)
|
||||
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
const refresh = Effect.fn("ConfigInstructionPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
const sources = yield* Effect.all({
|
||||
global: isolate("global", globalSource()),
|
||||
project: isolate("project", projectSource()),
|
||||
})
|
||||
loaded.current =
|
||||
Array.isArray(sources.global) && Array.isArray(sources.project)
|
||||
? { type: "available", files: [...sources.global, ...sources.project] }
|
||||
: { type: "unavailable" }
|
||||
if (!file) return
|
||||
yield* Effect.logDebug("instructions rescanned", {
|
||||
file,
|
||||
instructions:
|
||||
loaded.current.type === "available" ? loaded.current.files.map((item) => item.path) : "unavailable",
|
||||
})
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
|
||||
@@ -20,14 +20,13 @@ export const Plugin = define({
|
||||
const global = yield* Global.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
const entries = new Map<string, Reference.Source>()
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
entries.set(
|
||||
draft.add(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
@@ -48,7 +47,6 @@ export const Plugin = define({
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [name, source] of entries) draft.add(name, source)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -151,26 +151,25 @@ export const Plugin = define({
|
||||
return skills
|
||||
})
|
||||
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(function* (file?: string) {
|
||||
yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
const refresh = Effect.fn("ConfigSkillPlugin.refresh")(
|
||||
function* (file?: string) {
|
||||
yield* FiberMap.clear(watches)
|
||||
const skills = new Map<Skill.ID, Skill.Info>()
|
||||
const current = sources()
|
||||
for (const source of current) {
|
||||
for (const skill of yield* load(source)) skills.set(skill.id, skill)
|
||||
}
|
||||
loaded.skills = Array.from(skills.values())
|
||||
if (file) {
|
||||
yield* Effect.logInfo("skills rescanned", {
|
||||
file,
|
||||
sources: current.map(Skill.Source.key),
|
||||
skills: loaded.skills.map((skill) => skill.id),
|
||||
})
|
||||
}
|
||||
},
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Effect Drizzle SQLite Adapter
|
||||
|
||||
This subtree is an upstream-derived Drizzle ORM fork adapted to run SQLite query
|
||||
builders over Effect's generic `SqlClient`. It is maintained source, not
|
||||
generated output.
|
||||
|
||||
## Provenance
|
||||
|
||||
The implementation is derived from Drizzle ORM's Effect SQLite driver/session,
|
||||
SQLite Effect query builders, and shared query-builder utilities. The
|
||||
corresponding upstream source families are `drizzle-orm/src/effect-sqlite`,
|
||||
`drizzle-orm/src/sqlite-core`, and `drizzle-orm/src/utils.ts`.
|
||||
|
||||
The exact upstream revision originally copied into this repository is unknown.
|
||||
The currently pinned `drizzle-orm` version is a compatibility dependency, not
|
||||
copy provenance.
|
||||
|
||||
## Local Boundary
|
||||
|
||||
The supported local entrypoint is `@opencode-ai/core/database/drizzle`, exposed
|
||||
as the `EffectDrizzleSqlite` namespace. OpenCode's database service consumes that
|
||||
facade from `database/database.ts`.
|
||||
|
||||
Material local adaptations include:
|
||||
|
||||
- a runtime-independent driver over Effect's generic `SqlClient`
|
||||
- local cache, mapping, and runtime-inspection helpers
|
||||
- suppressed statement tracing beneath the database operation boundary
|
||||
- explicit SQLite transactions and savepoints
|
||||
- native transaction delegation for Durable Object SQLite
|
||||
- deliberate query-builder variance annotations
|
||||
|
||||
Preserve these adaptations when comparing or synchronizing upstream code.
|
||||
Focused regression coverage is in `test/database-drizzle.test.ts` and
|
||||
`test/sqlite-workerd.test.ts`.
|
||||
@@ -36,14 +36,14 @@ export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Def
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { SqliteClient } from '@effect/sql-sqlite-node';
|
||||
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
|
||||
* import * as Effect from 'effect/Effect';
|
||||
* import { SqliteClient } from "@effect/sql-sqlite-node"
|
||||
* import { EffectDrizzleSqlite } from "@opencode-ai/core/database/drizzle"
|
||||
* import { Effect } from "effect"
|
||||
*
|
||||
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
|
||||
* Effect.provide(SQLiteDrizzle.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
|
||||
* );
|
||||
* const db = yield* EffectDrizzleSqlite.make({ relations }).pipe(
|
||||
* Effect.provide(EffectDrizzleSqlite.DefaultServices),
|
||||
* Effect.provide(SqliteClient.layer({ filename: "sqlite.db" })),
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
|
||||
|
||||
@@ -227,7 +227,7 @@ export class SQLiteEffectInsertBase<
|
||||
config: SQLiteInsertConfig<TTable>
|
||||
|
||||
constructor(
|
||||
private table: TTable,
|
||||
table: TTable,
|
||||
values: SQLiteInsertConfig["values"],
|
||||
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
|
||||
private effectDialect: SQLiteDialect,
|
||||
|
||||
@@ -277,11 +277,7 @@ export class SQLiteEffectPreparedQuery<
|
||||
}
|
||||
|
||||
assertUnreachable(cacheStrat)
|
||||
}).pipe(
|
||||
Effect.catch((e) => {
|
||||
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
|
||||
}
|
||||
|
||||
getQuery(): Query {
|
||||
|
||||
@@ -279,7 +279,7 @@ export class SQLiteEffectUpdateBase<
|
||||
: undefined
|
||||
on = on(
|
||||
new Proxy(
|
||||
this.config.table._.columns,
|
||||
getTableColumnsRuntime(this.config.table),
|
||||
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
|
||||
) as any,
|
||||
from &&
|
||||
|
||||
@@ -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) => {
|
||||
@@ -488,7 +481,7 @@ export function status(): Effect.Effect<Status, never, Database.Service> {
|
||||
if (runtimeState.status === "error") return runtimeState
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
return { status: "required" as const }
|
||||
}).pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
@@ -528,76 +521,75 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
const state = yield* readState(db)
|
||||
if (state?.phase === "completed") return { status: "completed" as const }
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
const migrate = Effect.gen(function* () {
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
while (true) {
|
||||
yield* tx.run(sql`
|
||||
DELETE FROM event
|
||||
WHERE rowid IN (SELECT rowid FROM event LIMIT ${EVENT_DELETE_BATCH_SIZE})
|
||||
`)
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
const deleted = (yield* tx.get<{ value: number }>(sql`SELECT changes() AS value`))?.value ?? 0
|
||||
if (deleted < EVENT_DELETE_BATCH_SIZE) break
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions" } })
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
.pipe(Effect.orDie)
|
||||
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
|
||||
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
|
||||
const cursor = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const migrated =
|
||||
cursor !== undefined
|
||||
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
|
||||
?.value ?? 0)
|
||||
: 0
|
||||
const denominator = sourceTotal + legacyTotal
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
|
||||
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
|
||||
})
|
||||
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
|
||||
const projects = new Set(
|
||||
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
|
||||
)
|
||||
while (true) {
|
||||
const state = yield* readState(db)
|
||||
const cursorValue = state?.phase === "sessions" ? state.cursor : undefined
|
||||
const nextID = yield* db.get<{ id: string; project_id: string }>(
|
||||
cursorValue === undefined
|
||||
? sql`SELECT id, project_id FROM session ORDER BY id DESC LIMIT 1`
|
||||
: sql`SELECT id, project_id FROM session WHERE id < ${cursorValue} ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
if (!nextID) break
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "sessions", cursor: nextID.id } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "sessions", cursor: nextID.id }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
const projectID = projects.has(nextID.project_id) ? nextID.project_id : Project.ID.global
|
||||
if (projectID !== nextID.project_id)
|
||||
yield* Effect.logWarning("Reassigned V1 session with missing project", {
|
||||
sessionID: nextID.id,
|
||||
projectID: nextID.project_id,
|
||||
})
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO session_v2 (
|
||||
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
|
||||
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
|
||||
@@ -612,81 +604,79 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
FROM session
|
||||
WHERE id = ${nextID.id}
|
||||
`)
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
const next = yield* tx
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
|
||||
.get()
|
||||
if (!next) return yield* Effect.die(new Error(`Failed to copy V1 session ${nextID.id}`))
|
||||
const sourceMessages = yield* tx.all<SourceMessage>(
|
||||
sql`SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const sourceParts = yield* tx.all<SourcePart>(
|
||||
sql`SELECT id, message_id, session_id, time_created, time_updated, data FROM part WHERE session_id = ${next.id}`,
|
||||
)
|
||||
const transformed = transformSession({ session: next, messages: sourceMessages, parts: sourceParts })
|
||||
yield* Effect.forEach(transformed.warnings, (warning) =>
|
||||
Effect.logWarning("Skipped V1 migration row", warning),
|
||||
)
|
||||
yield* tx.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, next.id)).run()
|
||||
yield* Effect.forEach(transformed.messages, (message) =>
|
||||
tx
|
||||
.insert(SessionMessageTable)
|
||||
.values({
|
||||
id: SessionMessage.ID.make(message.id),
|
||||
session_id: SessionSchema.ID.make(message.session_id),
|
||||
type: message.type,
|
||||
seq: message.seq,
|
||||
time_created: message.time_created,
|
||||
time_updated: message.time_updated,
|
||||
data: sql`${JSON.stringify(message.data)}`,
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
.run(),
|
||||
)
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.update(SessionTable)
|
||||
.set({ ...transformed.session, time_updated: next.time_updated })
|
||||
.where(eq(SessionTable.id, next.id))
|
||||
.run()
|
||||
yield* tx
|
||||
.insert(EventSequenceTable)
|
||||
.values({ aggregate_id: next.id, seq: transformed.watermark })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
target: EventSequenceTable.aggregate_id,
|
||||
set: { seq: transformed.watermark, owner_id: null },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
return yield* migrate
|
||||
if (runtimeState.status === "running")
|
||||
runtimeState = {
|
||||
status: "running",
|
||||
progress: {
|
||||
label: "Migrating sessions",
|
||||
numerator: (runtimeState.progress.numerator ?? 0) + 1,
|
||||
denominator,
|
||||
},
|
||||
}
|
||||
yield* Effect.yieldNow
|
||||
}
|
||||
yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.insert(KVTable)
|
||||
.values({ key: MIGRATION_STATE_KEY, value: { phase: "completed" } })
|
||||
.onConflictDoUpdate({
|
||||
target: KVTable.key,
|
||||
set: { value: { phase: "completed" }, time_updated: Date.now() },
|
||||
})
|
||||
.run()
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
return { status: "completed" as const }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
@@ -715,7 +705,7 @@ function countNextSessions(sourcePath: string | undefined) {
|
||||
if (!isNextDatabase(source)) return 0
|
||||
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
|
||||
}),
|
||||
).pipe(Effect.orElseSucceed(() => 0))
|
||||
)
|
||||
}
|
||||
|
||||
function importNextDatabase(
|
||||
|
||||
@@ -61,21 +61,23 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
}
|
||||
const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
|
||||
const overrides: FilesImpl = {
|
||||
stat: (value) => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
},
|
||||
read: (value, range) => {
|
||||
const original = lookup(value)
|
||||
if (!original) return Effect.fail(new NotFound({ path: value }))
|
||||
if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return Effect.succeed({ info: info(node), bytes: bytes.slice() })
|
||||
},
|
||||
stat: (value) =>
|
||||
Effect.suspend(() => {
|
||||
const node = lookup(value)
|
||||
return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
|
||||
}),
|
||||
read: (value, range) =>
|
||||
Effect.gen(function* () {
|
||||
const original = lookup(value)
|
||||
if (!original) return yield* new NotFound({ path: value })
|
||||
if (original.type === "directory") return yield* new WrongKind({ path: value, actual: "directory" })
|
||||
const resolved = resolveKey(value, true)
|
||||
const node = resolved === undefined ? undefined : nodes.get(resolved)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "file") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
|
||||
return { info: info(node), bytes: bytes.slice() }
|
||||
}),
|
||||
write: (value, bytes) =>
|
||||
Effect.try({
|
||||
try: () => {
|
||||
@@ -89,17 +91,17 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
},
|
||||
catch: (cause) => failed(value, cause),
|
||||
}),
|
||||
list: (value) => {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return Effect.fail(new NotFound({ path: value }))
|
||||
if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
|
||||
const entries = [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Effect.succeed(entries)
|
||||
},
|
||||
list: (value) =>
|
||||
Effect.gen(function* () {
|
||||
const target = resolveKey(value, true) ?? key(value)
|
||||
const node = nodes.get(target)
|
||||
if (!node) return yield* new NotFound({ path: value })
|
||||
if (node.type !== "directory") return yield* new WrongKind({ path: value, actual: node.type })
|
||||
return [...nodes.entries()]
|
||||
.filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
|
||||
.map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}),
|
||||
remove: (value) =>
|
||||
Effect.sync(() => {
|
||||
const target = resolveKey(value, false) ?? key(value)
|
||||
@@ -107,32 +109,33 @@ export const makeMemoryDriver = (): MemoryDriver => {
|
||||
if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
|
||||
}
|
||||
}),
|
||||
move: (from, to) => {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return Effect.fail(new NotFound({ path: from }))
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
},
|
||||
move: (from, to) =>
|
||||
Effect.gen(function* () {
|
||||
const source = resolveKey(from, false) ?? key(from)
|
||||
const node = nodes.get(source)
|
||||
if (!node) return yield* new NotFound({ path: from })
|
||||
yield* Effect.try({
|
||||
try: () => {
|
||||
const requested = resolveKey(to, false) ?? key(to)
|
||||
const destination =
|
||||
nodes.get(requested)?.type === "directory"
|
||||
? path.posix.join(requested, path.posix.basename(source))
|
||||
: requested
|
||||
if (node.type === "directory" && destination.startsWith(`${source}/`)) {
|
||||
throw new Error(`Cannot move a directory into itself: ${from}`)
|
||||
}
|
||||
const existing = nodes.get(destination)
|
||||
if (node.type === "directory" && existing && existing.type !== "directory") {
|
||||
throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
|
||||
}
|
||||
requireParent(destination)
|
||||
const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
|
||||
for (const [entry] of moved) nodes.delete(entry)
|
||||
for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
|
||||
},
|
||||
catch: (cause) => failed(from, cause),
|
||||
})
|
||||
}),
|
||||
mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
+28
-33
@@ -177,7 +177,7 @@ const layer = Layer.effect(
|
||||
if (!dotgit) return undefined
|
||||
|
||||
const cwd = path.dirname(dotgit)
|
||||
const result = yield* run(cwd, proc)(["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const result = yield* run(cwd, proc, ["rev-parse", "--git-dir", "--git-common-dir", "--show-toplevel"])
|
||||
const [gitDir, commonDir, topLevel] = result.text.split(/\r?\n/)
|
||||
if (!gitDir || !commonDir) return undefined
|
||||
|
||||
@@ -189,13 +189,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const remote = Effect.fn("Git.remote.get")(function* (repository: Repository, name = "origin") {
|
||||
const result = yield* run(repository.worktree, proc)(["remote", "get-url", name])
|
||||
const result = yield* run(repository.worktree, proc, ["remote", "get-url", name])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const roots = Effect.fn("Git.history.rootCommits")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-list", "--max-parents=0", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["rev-list", "--max-parents=0", "HEAD"])
|
||||
if (result.exitCode !== 0) return []
|
||||
return result.text
|
||||
.split("\n")
|
||||
@@ -205,13 +205,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const head = Effect.fn("Git.history.head")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["rev-parse", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["rev-parse", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.history.branch")(function* (repository: Repository) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", "--quiet", "--short", "HEAD"])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim() || undefined
|
||||
})
|
||||
@@ -220,7 +220,7 @@ const layer = Layer.effect(
|
||||
repository: Repository,
|
||||
remoteName = "origin",
|
||||
) {
|
||||
const result = yield* run(repository.worktree, proc)(["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
const result = yield* run(repository.worktree, proc, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`])
|
||||
if (result.exitCode !== 0) return undefined
|
||||
return result.text.trim().replace(new RegExp(`^refs/remotes/${remoteName}/`), "") || undefined
|
||||
})
|
||||
@@ -230,10 +230,7 @@ const layer = Layer.effect(
|
||||
directory: AbsolutePath,
|
||||
args: string[],
|
||||
) {
|
||||
const result = yield* execute(
|
||||
directory,
|
||||
proc,
|
||||
)(args).pipe(
|
||||
const result = yield* execute(directory, proc, args).pipe(
|
||||
Effect.mapError((cause) => new OperationError({ operation, directory, message: cause.message, cause })),
|
||||
)
|
||||
if (result.exitCode === 0) return
|
||||
@@ -711,31 +708,29 @@ interface Result {
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
function run(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
execute(cwd, proc)(args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return execute(cwd, proc, args).pipe(Effect.orElseSucceed(() => ({ exitCode: 1, text: "", stderr: "" })))
|
||||
}
|
||||
|
||||
function execute(cwd: string, proc: AppProcess.Interface) {
|
||||
return (args: string[]) =>
|
||||
proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
return proc
|
||||
.run(
|
||||
ChildProcess.make("git", args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.map(
|
||||
(result) =>
|
||||
({
|
||||
exitCode: result.exitCode,
|
||||
text: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}) satisfies Result,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
# GitHub Copilot AI SDK Adapters
|
||||
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
This directory contains upstream-derived AI SDK implementations adapted for
|
||||
GitHub Copilot. It is not a generic OpenAI-compatible provider.
|
||||
|
||||
Avoid making edits to these files
|
||||
## Provenance
|
||||
|
||||
- `chat/` is derived from the Vercel AI SDK
|
||||
`@ai-sdk/openai-compatible` chat implementation.
|
||||
- `responses/` is derived from the Vercel AI SDK `@ai-sdk/openai` Responses
|
||||
implementation.
|
||||
- The exact upstream revisions originally copied into this repository are
|
||||
unknown. Current dependency versions and the `VERSION` constant in
|
||||
`copilot-provider.ts` are not copy provenance.
|
||||
|
||||
## Ownership
|
||||
|
||||
Keep `chat/` and `responses/` structurally close to their upstream modules, but
|
||||
preserve the intentional Copilot adaptations: the `copilot` options and metadata
|
||||
namespace, `thinking_budget`, reasoning text and opaque reasoning, stateless
|
||||
Responses requests with encrypted reasoning, rotating response item IDs, and
|
||||
explicit function-tool strictness taking precedence over the global fallback.
|
||||
|
||||
`copilot-provider.ts` is the local adapter assembly entrypoint used by
|
||||
`plugin/provider/github-copilot.ts`. `models.ts` is OpenCode-owned catalog
|
||||
reconciliation, not vendored SDK code. Authentication, request headers, model
|
||||
routing, and integration lifecycle are also owned by the provider plugin.
|
||||
|
||||
When updating the upstream-shaped modules, compare against both source packages
|
||||
and reapply the documented Copilot adaptations. Focused regression coverage is
|
||||
in:
|
||||
|
||||
- `test/github-copilot/copilot-chat-model.test.ts`
|
||||
- `test/github-copilot/convert-to-copilot-messages.test.ts`
|
||||
- `test/github-copilot/openai-responses-language-model.test.ts`
|
||||
- `test/github-copilot/openai-responses-prepare-tools.test.ts`
|
||||
- `test/github-copilot/models.test.ts`
|
||||
- `test/plugin/provider-github-copilot.test.ts`
|
||||
|
||||
@@ -653,7 +653,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
if (isActiveText) {
|
||||
controller.enqueue({ type: "text-end", id: "txt-0" })
|
||||
controller.enqueue({
|
||||
type: "text-end",
|
||||
id: "txt-0",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
// go through all tool calls and send the ones that are not finished
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type LanguageModelV3,
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3Content,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type LanguageModelV3StreamPart,
|
||||
type SharedV3ProviderMetadata,
|
||||
type SharedV3Warning,
|
||||
@@ -27,7 +26,7 @@ import { imageGenerationOutputSchema } from "./tool/image-generation.js"
|
||||
import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input.js"
|
||||
import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason.js"
|
||||
import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types.js"
|
||||
import { prepareResponsesTools } from "./openai-responses-prepare-tools.js"
|
||||
import { prepareResponsesTools, type ResponsesHostedTool } from "./openai-responses-prepare-tools.js"
|
||||
import type { OpenAIResponsesModelId } from "./openai-responses-settings.js"
|
||||
|
||||
const webSearchCallItem = z.object({
|
||||
@@ -221,15 +220,23 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
addInclude("message.output_text.logprobs")
|
||||
}
|
||||
|
||||
// when a web search tool is present, automatically include the sources:
|
||||
const webSearchToolName = (
|
||||
tools?.find(
|
||||
(tool) =>
|
||||
tool.type === "provider" && (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"),
|
||||
) as LanguageModelV3ProviderTool | undefined
|
||||
)?.name
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
const getHostedToolName = (responseType: ResponsesHostedTool["responseType"]) => {
|
||||
if (selectedHostedTool?.responseType === responseType) return selectedHostedTool.name
|
||||
return hostedTools.find((tool) => tool.responseType === responseType)?.name ?? responseType
|
||||
}
|
||||
|
||||
if (webSearchToolName) {
|
||||
if (hostedTools.some((tool) => tool.responseType === "web_search")) {
|
||||
addInclude("web_search_call.action.sources")
|
||||
}
|
||||
|
||||
@@ -357,18 +364,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
baseArgs.service_tier = undefined
|
||||
}
|
||||
|
||||
const {
|
||||
tools: openaiTools,
|
||||
toolChoice: openaiToolChoice,
|
||||
toolWarnings,
|
||||
} = prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
strictJsonSchema,
|
||||
})
|
||||
|
||||
return {
|
||||
webSearchToolName,
|
||||
getHostedToolName,
|
||||
args: {
|
||||
...baseArgs,
|
||||
tools: openaiTools,
|
||||
@@ -379,7 +376,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doGenerate(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
const url = this.config.url({
|
||||
path: "/responses",
|
||||
modelId: this.modelId,
|
||||
@@ -526,7 +523,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -534,7 +531,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: part.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -605,7 +602,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
input: JSON.stringify({ action: part.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -613,7 +610,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
result: { status: part.status },
|
||||
})
|
||||
|
||||
@@ -645,7 +642,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -653,7 +650,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
result: {
|
||||
queries: part.queries,
|
||||
results:
|
||||
@@ -673,7 +670,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-call",
|
||||
toolCallId: part.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
input: JSON.stringify({
|
||||
code: part.code,
|
||||
containerId: part.container_id,
|
||||
@@ -684,7 +681,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
content.push({
|
||||
type: "tool-result",
|
||||
toolCallId: part.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
result: {
|
||||
outputs: part.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -746,7 +743,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
}
|
||||
|
||||
async doStream(options: LanguageModelV3CallOptions) {
|
||||
const { args: body, warnings, webSearchToolName } = await this.getArgs(options)
|
||||
const { args: body, warnings, getHostedToolName } = await this.getArgs(options)
|
||||
|
||||
const { responseHeaders, value: response } = await postJsonToApi({
|
||||
url: this.config.url({
|
||||
@@ -866,7 +863,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: webSearchToolName ?? "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
ongoingToolCalls[value.output_index] = {
|
||||
@@ -889,7 +886,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-input-start",
|
||||
id: value.item.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
})
|
||||
|
||||
controller.enqueue({
|
||||
@@ -901,7 +898,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -909,7 +906,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
input: "{}",
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -980,7 +977,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
input: JSON.stringify({ action: value.item.action }),
|
||||
providerExecuted: true,
|
||||
})
|
||||
@@ -988,7 +985,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "web_search",
|
||||
toolName: getHostedToolName("web_search"),
|
||||
result: { status: value.item.status },
|
||||
})
|
||||
} else if (value.item.type === "computer_call") {
|
||||
@@ -1022,7 +1019,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "file_search",
|
||||
toolName: getHostedToolName("file_search"),
|
||||
result: {
|
||||
queries: value.item.queries,
|
||||
results:
|
||||
@@ -1041,7 +1038,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
result: {
|
||||
outputs: value.item.outputs,
|
||||
} satisfies z.infer<typeof codeInterpreterOutputSchema>,
|
||||
@@ -1050,7 +1047,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item.id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: value.item.result,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1099,7 +1096,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-result",
|
||||
toolCallId: value.item_id,
|
||||
toolName: "image_generation",
|
||||
toolName: getHostedToolName("image_generation"),
|
||||
result: {
|
||||
result: value.partial_image_b64,
|
||||
} satisfies z.infer<typeof imageGenerationOutputSchema>,
|
||||
@@ -1135,7 +1132,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
|
||||
controller.enqueue({
|
||||
type: "tool-call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: "code_interpreter",
|
||||
toolName: getHostedToolName("code_interpreter"),
|
||||
input: JSON.stringify({
|
||||
code: value.code,
|
||||
containerId: toolCall.codeInterpreter!.containerId,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
|
||||
import {
|
||||
type LanguageModelV3CallOptions,
|
||||
type LanguageModelV3ProviderTool,
|
||||
type SharedV3Warning,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import { codeInterpreterArgsSchema } from "./tool/code-interpreter.js"
|
||||
import { fileSearchArgsSchema } from "./tool/file-search.js"
|
||||
import { webSearchArgsSchema } from "./tool/web-search.js"
|
||||
@@ -6,6 +11,28 @@ import { webSearchPreviewArgsSchema } from "./tool/web-search-preview.js"
|
||||
import { imageGenerationArgsSchema } from "./tool/image-generation.js"
|
||||
import type { OpenAIResponsesTool } from "./openai-responses-api-types.js"
|
||||
|
||||
export type ResponsesHostedTool = {
|
||||
name: string
|
||||
type: "file_search" | "web_search_preview" | "web_search" | "code_interpreter" | "image_generation"
|
||||
responseType: "file_search" | "web_search" | "code_interpreter" | "image_generation"
|
||||
}
|
||||
|
||||
export function getResponsesHostedTool(tool: LanguageModelV3ProviderTool): ResponsesHostedTool | undefined {
|
||||
switch (tool.id) {
|
||||
case "openai.file_search":
|
||||
return { name: tool.name, type: "file_search", responseType: "file_search" }
|
||||
case "openai.web_search_preview":
|
||||
return { name: tool.name, type: "web_search_preview", responseType: "web_search" }
|
||||
case "openai.web_search":
|
||||
return { name: tool.name, type: "web_search", responseType: "web_search" }
|
||||
case "openai.code_interpreter":
|
||||
return { name: tool.name, type: "code_interpreter", responseType: "code_interpreter" }
|
||||
case "openai.image_generation":
|
||||
return { name: tool.name, type: "image_generation", responseType: "image_generation" }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function prepareResponsesTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
@@ -26,6 +53,8 @@ export function prepareResponsesTools({
|
||||
| { type: "function"; name: string }
|
||||
| { type: "code_interpreter" }
|
||||
| { type: "image_generation" }
|
||||
hostedTools: ResponsesHostedTool[]
|
||||
selectedHostedTool?: ResponsesHostedTool
|
||||
toolWarnings: SharedV3Warning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
@@ -34,7 +63,49 @@ export function prepareResponsesTools({
|
||||
const toolWarnings: SharedV3Warning[] = []
|
||||
|
||||
if (tools == null) {
|
||||
return { tools: undefined, toolChoice: undefined, toolWarnings }
|
||||
return { tools: undefined, toolChoice: undefined, hostedTools: [], toolWarnings }
|
||||
}
|
||||
|
||||
const hostedTools = tools.flatMap((tool) => {
|
||||
if (tool.type !== "provider") return []
|
||||
const hostedTool = getResponsesHostedTool(tool)
|
||||
return hostedTool ? [hostedTool] : []
|
||||
})
|
||||
const selectedToolName = toolChoice?.type === "tool" ? toolChoice.toolName : undefined
|
||||
const selectedTools = selectedToolName === undefined ? [] : tools.filter((tool) => tool.name === selectedToolName)
|
||||
if (selectedTools.length > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous tool choice '${selectedToolName}': multiple tool definitions share this name`,
|
||||
})
|
||||
}
|
||||
const selectedHostedTool =
|
||||
selectedTools[0]?.type === "provider" ? getResponsesHostedTool(selectedTools[0]) : undefined
|
||||
|
||||
const ambiguousHostedResponse =
|
||||
toolChoice?.type === "none" || toolChoice?.type === "tool"
|
||||
? undefined
|
||||
: hostedTools.find(
|
||||
(tool) =>
|
||||
new Set(
|
||||
hostedTools.filter((candidate) => candidate.responseType === tool.responseType).map((item) => item.name),
|
||||
).size > 1,
|
||||
)
|
||||
if (ambiguousHostedResponse) {
|
||||
const names = new Set(
|
||||
hostedTools.filter((tool) => tool.responseType === ambiguousHostedResponse.responseType).map((tool) => tool.name),
|
||||
)
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${ambiguousHostedResponse.responseType} response for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedHostedTool) {
|
||||
const names = new Set(hostedTools.filter((tool) => tool.type === selectedHostedTool.type).map((tool) => tool.name))
|
||||
if (names.size > 1) {
|
||||
throw new UnsupportedFunctionalityError({
|
||||
functionality: `ambiguous ${selectedHostedTool.type} tool choice for hosted tools: ${[...names].join(", ")}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const openaiTools: Array<OpenAIResponsesTool> = []
|
||||
@@ -134,7 +205,7 @@ export function prepareResponsesTools({
|
||||
}
|
||||
|
||||
if (toolChoice == null) {
|
||||
return { tools: openaiTools, toolChoice: undefined, toolWarnings }
|
||||
return { tools: openaiTools, toolChoice: undefined, hostedTools, selectedHostedTool, toolWarnings }
|
||||
}
|
||||
|
||||
const type = toolChoice.type
|
||||
@@ -143,20 +214,18 @@ export function prepareResponsesTools({
|
||||
case "auto":
|
||||
case "none":
|
||||
case "required":
|
||||
return { tools: openaiTools, toolChoice: type, toolWarnings }
|
||||
case "tool":
|
||||
return { tools: openaiTools, toolChoice: type, hostedTools, selectedHostedTool, toolWarnings }
|
||||
case "tool": {
|
||||
return {
|
||||
tools: openaiTools,
|
||||
toolChoice:
|
||||
toolChoice.toolName === "code_interpreter" ||
|
||||
toolChoice.toolName === "file_search" ||
|
||||
toolChoice.toolName === "image_generation" ||
|
||||
toolChoice.toolName === "web_search_preview" ||
|
||||
toolChoice.toolName === "web_search"
|
||||
? { type: toolChoice.toolName }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
toolChoice: selectedHostedTool
|
||||
? { type: selectedHostedTool.type }
|
||||
: { type: "function", name: toolChoice.toolName },
|
||||
hostedTools,
|
||||
selectedHostedTool,
|
||||
toolWarnings,
|
||||
}
|
||||
}
|
||||
default: {
|
||||
const _exhaustiveCheck: never = type
|
||||
throw new UnsupportedFunctionalityError({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { FileSystem } from "../filesystem.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError } from "../image.js"
|
||||
import { DecodeError, ResizerUnavailableError, SizeError, type Limits } from "../image.js"
|
||||
|
||||
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
|
||||
|
||||
@@ -33,12 +33,7 @@ export const make = Effect.gen(function* () {
|
||||
return Effect.fn("Image.Photon.normalize")(function* (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
limits: {
|
||||
readonly autoResize: boolean
|
||||
readonly maxWidth: number
|
||||
readonly maxHeight: number
|
||||
readonly maxBase64Bytes: number
|
||||
},
|
||||
limits: Readonly<Limits>,
|
||||
) {
|
||||
const photon = yield* loadPhoton
|
||||
const decoded = yield* Effect.try({
|
||||
|
||||
@@ -2,13 +2,13 @@ export * as InstructionBuiltIns from "./builtins.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { SessionSchema } from "../session/schema.js"
|
||||
import { Instructions } from "./index.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: (sessionID: SessionSchema.ID) => Effect.Effect<Instructions.List>
|
||||
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
|
||||
|
||||
@@ -378,117 +378,109 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
const settle = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<Credential.OAuth, unknown>) {
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(attempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const settleCommand = Effect.fnUntraced(function* (attemptID: AttemptID, exit: Exit.Exit<string, unknown>) {
|
||||
return yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const attempt = yield* SynchronizedRef.modify(commandAttempts, (current) => {
|
||||
const match = current.get(attemptID)
|
||||
if (!match || match.status !== "pending" || match.persisting) return [undefined, current]
|
||||
const next = Exit.isSuccess(exit)
|
||||
? { ...match, persisting: true }
|
||||
: {
|
||||
status: "failed" as const,
|
||||
integrationID: match.integrationID,
|
||||
message: message(exit.cause),
|
||||
time: match.time,
|
||||
removeAt: now + terminalRetention,
|
||||
}
|
||||
return [match, new Map(current).set(attemptID, next)]
|
||||
})
|
||||
if (!attempt) return
|
||||
if (Exit.isFailure(exit)) {
|
||||
yield* close(attempt.scope)
|
||||
return
|
||||
}
|
||||
|
||||
const persistence = yield* createCredential({
|
||||
const persistence = yield* createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label,
|
||||
value: Credential.Key.make({ type: "key", key: exit.value }),
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
status: "complete",
|
||||
integrationID: attempt.integrationID,
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}),
|
||||
)
|
||||
})
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
: {
|
||||
status: "failed",
|
||||
integrationID: attempt.integrationID,
|
||||
message: message(persistence.cause),
|
||||
time: attempt.time,
|
||||
removeAt: settledAt + terminalRetention,
|
||||
}
|
||||
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* close(attempt.scope)
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
const scrub = Effect.fnUntraced(function* () {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export function buildLocationServiceMap(
|
||||
...inner,
|
||||
get: (ref: Location.Ref) => inner.get(canonical(ref)),
|
||||
contextEffect: (ref: Location.Ref) => inner.contextEffect(canonical(ref)),
|
||||
contextEffectOption: (ref: Location.Ref) => inner.contextEffectOption(canonical(ref)),
|
||||
invalidate: (ref: Location.Ref) => inner.invalidate(canonical(ref)),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
@@ -29,6 +26,7 @@ import {
|
||||
} from "@modelcontextprotocol/sdk/types.js"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { McpStdio } from "./stdio.js"
|
||||
|
||||
const DEFAULT_STARTUP_TIMEOUT = 30_000
|
||||
@@ -41,10 +39,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 +157,7 @@ export interface Connection {
|
||||
readonly callTool: (input: {
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<CallToolResult, Error>
|
||||
readonly onClose: (callback: () => void) => void
|
||||
/** Registers a callback fired when the server emits an MCP logging notification. */
|
||||
@@ -301,12 +296,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 +387,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 +398,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: () => {} },
|
||||
|
||||
@@ -3,6 +3,7 @@ export * as Mcp from "./index.js"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { McpEvent } from "@opencode-ai/schema/mcp-event"
|
||||
import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
@@ -153,6 +154,7 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly server: ServerName | string
|
||||
readonly name: string
|
||||
readonly args?: Record<string, unknown>
|
||||
readonly sessionID?: Session.ID
|
||||
}) => Effect.Effect<ToolResult, NotFoundError | ToolCallError>
|
||||
readonly instructions: () => Effect.Effect<ServerInstructions[]>
|
||||
readonly prompts: () => Effect.Effect<Prompt[]>
|
||||
@@ -473,11 +475,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 +514,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 +545,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 +559,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 +570,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 +594,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 +610,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 +633,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 +675,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 +739,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 +764,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`))
|
||||
|
||||
|
||||
@@ -292,20 +292,11 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.undefined),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(item.request, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (result?.effect !== "allow") continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -2,9 +2,9 @@ export * as PermissionSaved from "./saved.js"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { Database } from "../database/database.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Project } from "../project.js"
|
||||
import { PermissionTable } from "./sql.js"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"
|
||||
import type { Project } from "@opencode-ai/schema/project"
|
||||
import { Timestamps } from "../database/schema.sql.js"
|
||||
import { Project } from "../project.js"
|
||||
import { ProjectTable } from "../project/sql.js"
|
||||
import type { PermissionSaved } from "./saved.js"
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Added, Handoff, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Added, Handoff, PersistentPty, ReadLines, Removed, type ReadResult } from "@opencode-ai/schema/persistent-pty"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
@@ -26,19 +26,9 @@ export { Handoff } from "@opencode-ai/schema/persistent-pty"
|
||||
export const Options = Schema.Struct({ handoff: Schema.optional(Handoff) })
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Info = Pty.Info & {
|
||||
readonly sessionID: Session.ID
|
||||
readonly foregroundProcess: string | null
|
||||
readonly size: { readonly cols: number; readonly rows: number }
|
||||
readonly output: { readonly head: number; readonly tail: number }
|
||||
}
|
||||
export type Info = PersistentPty.Info
|
||||
|
||||
export type Snapshot = {
|
||||
readonly info: Info
|
||||
readonly text: string
|
||||
readonly checkpoint: Uint8Array
|
||||
readonly cursor: { readonly x: number; readonly y: number }
|
||||
}
|
||||
export type Snapshot = PersistentPty.Snapshot
|
||||
|
||||
export type Attachment = {
|
||||
readonly info: Info
|
||||
@@ -161,15 +151,7 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const create = Effect.fn("PersistentPty.create")(function* (
|
||||
sessionID: Session.ID,
|
||||
input: {
|
||||
readonly command?: string
|
||||
readonly args: readonly string[]
|
||||
readonly cwd?: string
|
||||
readonly title: string
|
||||
readonly env: Readonly<Record<string, string>>
|
||||
readonly cols?: number
|
||||
readonly rows?: number
|
||||
},
|
||||
input: Parameters<Interface["create"]>[1],
|
||||
) {
|
||||
const response = yield* request(
|
||||
daemon,
|
||||
@@ -338,14 +320,7 @@ export const configured = (options: Options = {}) =>
|
||||
|
||||
const attach = Effect.fn("PersistentPty.attach")(function* (
|
||||
id: Pty.ID,
|
||||
input: {
|
||||
readonly cursor: number
|
||||
readonly attachmentID: string
|
||||
readonly role: Role
|
||||
readonly takeover?: boolean
|
||||
readonly onEvent: (event: StreamEvent) => void
|
||||
readonly onEnd: () => void
|
||||
},
|
||||
input: Parameters<Interface["attach"]>[1],
|
||||
) {
|
||||
yield* get(id)
|
||||
const attachment = yield* daemon
|
||||
|
||||
@@ -111,7 +111,7 @@ const layer = Layer.effect(
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void).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]
|
||||
|
||||
@@ -84,7 +84,9 @@ export const Plugin = define({
|
||||
})
|
||||
|
||||
function append(template: string, input: string) {
|
||||
return [template, input.trim()].filter(Boolean).join("\n\n")
|
||||
const value = input.trim()
|
||||
if (template.includes("$ARGUMENTS")) return template.replaceAll("$ARGUMENTS", () => value)
|
||||
return [template, value].filter(Boolean).join("\n\n")
|
||||
}
|
||||
|
||||
function parseArguments(input: string) {
|
||||
|
||||
@@ -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) =>
|
||||
@@ -369,9 +369,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
|
||||
},
|
||||
vcs: {
|
||||
get: () => response(vcs.info()),
|
||||
base: () => response(vcs.base()),
|
||||
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
|
||||
status: () => response(vcs.status()),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
|
||||
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
|
||||
transform: vcs.transform,
|
||||
reload: vcs.reload,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
import { Provider } from "../provider.js"
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models.dev",
|
||||
@@ -37,7 +37,7 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, model))
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, structuredClone(model)))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ export const load = Effect.fn("PluginModule.load")(function* (
|
||||
const npm = yield* Npm.Service
|
||||
const entrypoint = path.isAbsolute(operation.target)
|
||||
? pathToFileURL(operation.target).href
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
|
||||
: (yield* npm.add(operation.target, { subpaths: ["server", ""] })).entrypoint
|
||||
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
|
||||
// Bun currently ignores query parameters when caching file:// imports.
|
||||
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
|
||||
|
||||
@@ -2,12 +2,12 @@ export * as PlanPlugin from "./plan.js"
|
||||
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../agent.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { SessionEvent } from "../session/event.js"
|
||||
|
||||
const plan = Agent.ID.make("plan")
|
||||
|
||||
|
||||
@@ -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,
|
||||
"",
|
||||
|
||||
@@ -76,11 +76,13 @@ to every project for that user. Project configuration can live in any directory
|
||||
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
|
||||
in a monorepo.
|
||||
|
||||
When OpenCode starts, it searches from the current directory up to the project
|
||||
root. It merges direct `opencode.json(c)` files from root to current directory,
|
||||
During ordinary project discovery, OpenCode searches the current Location
|
||||
directory and every ancestor through the filesystem root, including directories
|
||||
above the detected project or repository root. It merges direct
|
||||
`opencode.json(c)` files from the farthest ancestor to the current directory,
|
||||
then does the same for `.opencode/opencode.json(c)` files. This means every
|
||||
`.opencode` config overrides every direct config. Global configuration has the
|
||||
lowest precedence.
|
||||
discovered `.opencode` config overrides every discovered direct config. Global
|
||||
filesystem configuration has lower precedence than these discovered documents.
|
||||
|
||||
Common configuration fields include `model`, `default_agent`, `permissions`,
|
||||
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user