Compare commits

..

4 Commits

Author SHA1 Message Date
Brendan Allan c81643d17f chore(server): sync websocket transport with latest v2 2026-08-31 23:43:37 +08:00
Brendan Allan b9c7009873 chore(server): sync latest v2 socket fixes 2026-08-31 22:58:14 +08:00
Brendan Allan b3742959be chore(server): update websocket transport to v2 2026-08-31 22:58:10 +08:00
Brendonovich b276356986 feat(server): expose the HTTP API over WebSocket RPC 2026-08-27 05:22:37 +00:00
278 changed files with 5049 additions and 11682 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+20 -16
View File
@@ -510,10 +510,9 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `metadata` (carries usage). Hold both in state so `onHalt` can emit exactly
// one finish after both chunks have had a chance to arrive.
readonly finishReason: FinishReasonDetails | undefined
readonly usage: Usage | undefined
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
// can emit exactly one finish after both chunks have had a chance to arrive.
readonly pendingFinish: { readonly reason: FinishReasonDetails; readonly usage?: Usage } | undefined
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -693,9 +692,12 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
},
},
[],
@@ -703,11 +705,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
return [
{
...state,
usage,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
},
[],
] as const
@@ -731,18 +736,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.finishReason) return []
if (!state.pendingFinish) return []
const normalized = (() => {
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.finishReason,
...state.pendingFinish.reason,
normalized,
},
usage: state.usage,
usage: state.pendingFinish.usage,
})
return events
}
@@ -766,8 +771,7 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
finishReason: undefined,
usage: undefined,
pendingFinish: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
+2 -11
View File
@@ -609,27 +609,18 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error)) {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message:
typeof event.error.message === "string" && event.error.message.length > 0
? event.error.message
: typeof event.error.status === "string" && event.error.status.length > 0
? event.error.status
: "Gemini provider error",
message: event.error.message,
status: typeof event.error.code === "number" ? event.error.code : undefined,
rawBody: body,
}),
}),
)
}
if ("error" in event)
return Effect.fail(
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
)
const nextState = {
...state,
promptFeedback: event.promptFeedback ?? state.promptFeedback,
+43 -58
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type HostedToolReplayItem = {
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -373,7 +373,7 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
export interface ProviderAdapter {
export interface Extension {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface ProviderAdapter {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
}
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
const BASE: Extension = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,9 +397,6 @@ export interface ParserState {
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined
// Item ids are response-scoped identities. Keep completed ids tombstoned so
// reconnect replay cannot reopen fragments already emitted downstream.
readonly completedMessages: ReadonlySet<string>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
}
@@ -485,12 +482,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -510,17 +507,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return lowered
})
@@ -529,13 +526,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
"tool-result",
)
})
@@ -543,33 +540,30 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
if (item.type === "text") return { type: "input_text" as const, text: item.text }
return yield* lowerMessageMedia(
{ type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
request,
adapter,
extension,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
// Text/json/error results are encoded as a plain string for backward
// compatibility with existing cassettes and provider expectations.
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
// Preserve the narrowed array element type when compiled through a consumer package.
const content: ReadonlyArray<Content> = part.result.value
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -577,13 +571,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -650,7 +644,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: adapter.restoreHostedToolItem?.(part.result.value)
: extension.lowerHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -664,11 +658,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
continue
}
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -681,11 +677,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, adapter),
output: yield* lowerToolResultOutput(part, request, extension),
})
}
}
@@ -737,28 +733,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
extension: Extension,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, adapter),
input: yield* lowerMessages(request, extension),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
adapter.name,
extension.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -772,7 +768,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
})
// =============================================================================
@@ -955,16 +951,12 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id
if (state.completedMessages.has(itemID)) return [state, NO_EVENTS]
const phase = messagePhase(item.phase)
const completedMessages = new Set(state.completedMessages)
if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id)
// A new message closes earlier messages, including ones that never streamed.
const events: LLMEvent[] = []
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
completedMessages.add(id)
const openPhase = state.message?.id === id ? state.message.phase : undefined
return Lifecycle.textEnd(
lifecycle,
@@ -977,7 +969,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1094,12 +1085,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
const completedMessages = new Set(state.completedMessages)
completedMessages.add(item.id)
if (state.message !== undefined && state.message.id !== item.id)
return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult
const message = state.message
const message = state.message?.id === item.id ? state.message : undefined
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1112,13 +1098,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
completedMessages,
message: undefined,
message: message ? undefined : state.message,
},
events,
] satisfies StepResult
@@ -1422,9 +1408,9 @@ export const step = (state: ParserState, input: Event) => {
* The provider-neutral Open Responses protocol. Provider-specific Responses
* implementations compose this baseline with their own tools and event variants.
*/
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
export const initial = (request: LLMRequest, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1432,7 +1418,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
lifecycle: Lifecycle.initial(),
outputItems: {},
message: undefined,
completedMessages: new Set<string>(),
reasoningItems: {},
})
@@ -86,11 +86,11 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
@@ -125,9 +125,9 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
adapter,
extension,
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
@@ -204,7 +204,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const adapter = {
const extension = {
id: ADAPTER,
name: NAME,
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, adapter),
initial: (request) => OpenResponses.initial(request, extension),
step,
terminal: OpenResponses.terminal,
},
@@ -631,45 +631,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("retains metadata usage that arrives before messageStop", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }],
["messageStop", { stopReason: "end_turn" }],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
expect(response.usage).toMatchObject({ inputTokens: 5, outputTokens: 2, totalTokens: 7 })
}),
)
it.effect("rejects metadata-only streams as incomplete with HTTP context", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(eventStreamBody(["metadata", { usage: { inputTokens: 5, outputTokens: 2, totalTokens: 7 } }])),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
http: {
status: 200,
headers: { "content-type": "application/vnd.amazon.eventstream" },
},
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -62,62 +62,6 @@ describe("provider error retention", () => {
)
}
it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () =>
Effect.gen(function* () {
const body = JSON.stringify({
error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } },
trace: { opaque: "outer" },
})
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(
fixedResponse(sseEvents(body), {
headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" },
}),
),
Effect.flip,
)
expect(error.message).toBe("RESOURCE_EXHAUSTED")
expect(error.reason._tag).toBe("RateLimit")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("rejects a malformed non-record Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("Invalid google/gemini stream event")
expect(error.reason.body).toBe(body)
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("rejects and retains an explicit null Gemini error", () =>
Effect.gen(function* () {
const body = JSON.stringify({ error: null, trace: { opaque: "outer" } })
const error = yield* LLMClient.generate(
LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }),
).pipe(
Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.reason.body).toBe(body)
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } })
expect(error.reason.http?.url).toStartWith("https://provider.test/")
}),
)
it.effect("retains malformed provider frames and the original decode cause", () =>
Effect.gen(function* () {
const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'
@@ -82,32 +82,6 @@ describe("Open Responses completed item text", () => {
expect(response.events.filter(LLMEvent.is.textStart)).toEqual([])
}),
)
it.effect("assembles a done-only message once across replayed item events", () =>
Effect.gen(function* () {
const item = {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Recovered" }],
}
const response = yield* generate(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" },
{ type: "response.output_item.done", item },
{ type: "response.output_item.added", item },
{ type: "response.output_item.done", item },
completed,
)
expect(response.text).toBe("Recovered")
expect(response.message.content).toEqual([
{
type: "text",
text: "Recovered",
providerMetadata: { "openai-compatible": { itemId: "msg_1" } },
},
])
expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1)
}),
)
})
describe("Open Responses completed item reasoning", () => {
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("preserves non-empty done-only message content without replaying duplicates", () =>
Effect.gen(function* () {
const text = {
type: "message",
id: "msg_text",
content: [{ type: "output_text", text: "Done-only text." }],
}
const refusal = {
type: "message",
id: "msg_refusal",
content: [{ type: "refusal", refusal: "Done-only refusal." }],
}
const events = yield* collect(
{ type: "response.output_item.done", item: text },
{ type: "response.output_item.done", item: text },
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] },
},
{ type: "response.output_item.done", item: refusal },
{ type: "response.output_item.done", item: refusal },
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_text",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-end",
id: "msg_text",
text: "Done-only text.",
providerMetadata: { "openai-compatible": { itemId: "msg_text" } },
},
{
type: "text-start",
id: "msg_refusal",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
{
type: "text-end",
id: "msg_refusal",
text: "Done-only refusal.",
providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } },
},
])
}),
)
it.effect("treats a repeated message lifecycle as replay", () =>
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -289,44 +233,9 @@ describe("Open Responses basic-item lifecycles", () => {
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } },
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"])
}),
)
it.effect("ignores a stale done-only message while another message is active", () =>
Effect.gen(function* () {
const events = yield* collect(
{ 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_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] },
},
{
type: "response.output_item.done",
item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] },
},
completed,
)
expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([
{
type: "text-start",
id: "msg_1",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
{ type: "text-delta", id: "msg_1", text: "Draft" },
{
type: "text-end",
id: "msg_1",
text: "Final",
providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } },
},
])
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -1,16 +1,5 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)
@@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
.locator('[data-slot="session-mobile-view-navigation"]')
.getByRole("button", { name: "More options", exact: true })
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
const overlay = page.locator('[data-slot="mobile-status-overlay"]')
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
await more.click()
@@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr
if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } })
if (dismissal === "escape") await page.keyboard.press("Escape")
if (dismissal === "drag") {
const handle = drawer.locator('[data-slot="mobile-drawer-handle"]')
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
const bounds = await handle.boundingBox()
expect(bounds).not.toBeNull()
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
+2 -2
View File
@@ -9,13 +9,13 @@
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
"purpose": "any"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
"purpose": "any"
}
],
"theme_color": "#080808",
@@ -1,7 +1,3 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}
+3 -1
View File
@@ -114,8 +114,10 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}
-90
View File
@@ -1,90 +0,0 @@
[data-slot="mobile-drawer-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-drawer-backdrop-in 240ms ease-out;
}
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-drawer-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-drawer-content"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-drawer-content"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-drawer-content"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-drawer-content"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-drawer-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-drawer-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
@keyframes mobile-drawer-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-drawer-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-drawer-content"][data-transitioning],
[data-slot="mobile-drawer-content"][data-closing] {
transition: none;
}
[data-slot="mobile-drawer-overlay"],
[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
-47
View File
@@ -1,47 +0,0 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import "./mobile-drawer.css"
export function MobileDrawer(
props: ParentProps<{
open: boolean
onOpenChange: (open: boolean) => void
onContentPresentChange?: (present: boolean) => void
returnFocus?: () => HTMLElement | undefined
closeOnOutsideFocus?: boolean
}>,
) {
return (
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
onContentPresentChange={props.onContentPresentChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
closeOnOutsideFocus={props.closeOnOutsideFocus}
>
{props.children}
</Drawer>
)
}
export const MobileDrawerTrigger = Drawer.Trigger
export function MobileDrawerContent(props: ParentProps) {
const language = useLanguage()
return (
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
<div data-slot="mobile-drawer-handle" aria-hidden="true">
<span />
</div>
{props.children}
</Drawer.Content>
</Drawer.Portal>
)
}
export const MobileDrawerLabel = Drawer.Label
export const MobileDrawerClose = Drawer.Close
@@ -1,34 +0,0 @@
[data-slot="mobile-panel"] {
display: flex;
min-height: 0;
flex-direction: column;
}
[data-slot="mobile-panel-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-panel-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-panel-close"][data-component="button-v2"] {
height: 44px;
flex-shrink: 0;
}
[data-slot="mobile-panel-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
+23 -21
View File
@@ -1,8 +1,7 @@
import Drawer from "@corvu/drawer"
import type { ParentProps } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
import "./status/status-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<MobileDrawer
<Drawer
open={props.open}
onOpenChange={props.onOpenChange}
returnFocus={props.returnFocus}
side="bottom"
finalFocusEl={props.returnFocus?.()}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
<MobileDrawerContent>
<div data-slot="mobile-panel" data-corvu-no-drag>
<div data-slot="mobile-panel-header">
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
<MobileDrawerClose
as={Button}
variant="ghost"
data-slot="mobile-panel-close"
aria-label={language.t("common.close")}
>
{language.t("common.close")}
</MobileDrawerClose>
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-status-overlay" />
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
<div data-slot="mobile-status-header" data-corvu-no-drag>
<Drawer.Label>{props.title}</Drawer.Label>
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
{language.t("common.close")}
</Drawer.Close>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
)
}
@@ -1,3 +1,109 @@
[data-slot="mobile-status-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-status-backdrop-in 240ms ease-out;
}
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-status-backdrop-out 200ms ease-in forwards;
}
[data-slot="mobile-status-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
flex-direction: column;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-overlay);
outline: none;
app-region: no-drag;
}
[data-slot="mobile-status-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-status-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-status-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-status-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-status-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-status-header"] {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-inline-start: 8px;
padding-block-end: 8px;
}
[data-slot="mobile-status-header"] h2 {
margin: 0;
font-size: 14px;
font-weight: 530;
line-height: var(--line-height-base);
}
[data-slot="mobile-status-close"] {
min-height: 44px;
flex-shrink: 0;
padding-inline: 12px;
border-radius: 6px;
color: var(--v2-text-text-base);
font-size: 13px;
line-height: var(--line-height-compact);
}
@media (hover: hover) {
[data-slot="mobile-status-close"]:hover {
background: var(--v2-overlay-simple-overlay-hover);
}
}
[data-slot="mobile-status-close"]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: -2px;
}
[data-slot="mobile-status-content"] {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
touch-action: pan-y;
}
[data-slot="mobile-status-loading"] {
display: flex;
min-height: 56px;
@@ -7,3 +113,33 @@
font-size: 13px;
line-height: var(--line-height-base);
}
@keyframes mobile-status-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-status-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-status-drawer"][data-transitioning],
[data-slot="mobile-status-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-status-overlay"],
[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
@@ -1,7 +1,6 @@
import { lazy, Suspense } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { MobilePanelDrawer } from "../mobile-panel-drawer"
import "./status-drawer.css"
const Body = lazy(async () => {
const { StatusPopoverBody } = await import("./body")
+82 -2
View File
@@ -14,13 +14,63 @@
var(--v2-background-bg-layer-02);
}
[data-slot="mobile-tabs-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background: var(--v2-overlay-simple-overlay-scrim);
animation: mobile-tabs-backdrop-in 240ms ease-out;
}
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: mobile-tabs-backdrop-out 200ms ease-in forwards;
}
/* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */
[data-slot="mobile-tabs-drawer"] {
box-sizing: border-box;
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 51;
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
margin-block-start: 8px;
max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px));
padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px));
border-radius: 16px 16px 0 0;
background: var(--v2-background-bg-deep);
box-shadow: var(--v2-elevation-overlay);
outline: none;
}
[data-slot="mobile-tabs-drawer"][data-transitioning] {
transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
[data-slot="mobile-tabs-drawer"][data-closing] {
transition-duration: 200ms;
}
[data-slot="mobile-tabs-drawer"][data-closed] {
visibility: hidden;
pointer-events: none;
}
[data-slot="mobile-tabs-drag-handle"] {
display: flex;
height: 28px;
flex-shrink: 0;
align-items: center;
justify-content: center;
touch-action: none;
}
[data-slot="mobile-tabs-drag-handle"] span {
width: 32px;
height: 4px;
border-radius: 999px;
background: var(--v2-border-border-strong);
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -29,6 +79,36 @@
flex-direction: column;
}
@keyframes mobile-tabs-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes mobile-tabs-backdrop-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-slot="mobile-tabs-drawer"][data-transitioning],
[data-slot="mobile-tabs-drawer"][data-closing] {
transition: none;
}
[data-slot="mobile-tabs-overlay"],
[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) {
animation: none;
}
}
[data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] {
display: flex;
flex-direction: column;
+22 -12
View File
@@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence"
import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import Drawer from "@corvu/drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { projectForSession } from "@/shell/layout/helpers"
@@ -415,7 +415,7 @@ export function Titlebar(props: {
<Show
when={!mobile()}
fallback={
<MobileDrawer
<Drawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,9 +423,11 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<MobileDrawerTrigger
<Drawer.Trigger
data-slot="mobile-tabs-trigger"
aria-expanded={mobileTabs.open}
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
aria-label={language.t("titlebar.tabs")}
>
@@ -465,11 +467,15 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</MobileDrawerTrigger>
<MobileDrawerContent>
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
<div data-slot="mobile-tabs-drawer-list">
</Drawer.Trigger>
<Drawer.Portal forceMount>
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
<span />
</div>
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -487,6 +493,7 @@ export function Titlebar(props: {
</div>
<button
type="button"
data-corvu-no-drag
data-action="mobile-tabs-new-session"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
onClick={() => {
@@ -497,7 +504,10 @@ export function Titlebar(props: {
<Icon name="plus" />
{language.t("command.session.new")}
</button>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<div
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
data-corvu-no-drag
>
<button
type="button"
data-action="mobile-tabs-home"
@@ -536,9 +546,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
}
>
<Show
-1
View File
@@ -59,7 +59,6 @@ test.each(["dev", "beta", "prod"])("serves %s app icons", async (channel) => {
async function check(channel: string, read: (path: string) => Promise<Uint8Array>) {
const html = new TextDecoder().decode(await read("/index.html"))
const actual: typeof manifest = JSON.parse(new TextDecoder().decode(await read("/site.webmanifest")))
expect(actual.icons.every((icon) => icon.purpose === "maskable")).toBe(true)
expect(actual).toEqual({
...manifest,
icons: manifest.icons.map((icon) => ({ ...icon, src: `/icons/${channel}${icon.src}` })),
-15
View File
@@ -1,7 +1,6 @@
import { Argument, Flag, GlobalFlag } from "effect/unstable/cli"
import { Schema } from "effect"
import { Spec } from "../framework/spec"
import { Updater } from "../services/updater"
export const PrintLogs = GlobalFlag.setting("print-logs")({
flag: Flag.boolean("print-logs").pipe(
@@ -57,20 +56,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
},
commands: [
Spec.make("upgrade", {
description: "Upgrade OpenCode to the latest or a specific version",
params: {
target: Argument.string("target").pipe(
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
Argument.optional,
),
method: Flag.choice("method", Updater.methods).pipe(
Flag.withAlias("m"),
Flag.withDescription("Installation method to use"),
Flag.optional,
),
},
}),
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
Spec.make("api", {
description: "Make a request to the running server",
@@ -1,38 +0,0 @@
import { intro, log, outro, spinner } from "@clack/prompts"
import { Effect, Option } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION} → ${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
)
+5 -7
View File
@@ -17,7 +17,6 @@ import { CpuProfile } from "./cpu-profile"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
upgrade: () => import("./commands/handlers/upgrade"),
acp: () => import("./commands/handlers/acp"),
api: () => import("./commands/handlers/api"),
auth: {
@@ -99,13 +98,12 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
}),
]),
),
Effect.provide(
Observability.layer({
-2
View File
@@ -4,7 +4,6 @@ import fs from "node:fs"
import { readFile } from "node:fs/promises"
import path from "node:path"
import { ReadStream } from "node:tty"
import { OPENCODE_VERSION } from "./version"
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
@@ -138,7 +137,6 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+5 -6
View File
@@ -30,13 +30,12 @@ export const run = Effect.fnUntraced(function* (options: Options) {
return yield* processEffect(options).pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
),
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
],
}),
]),
),
Effect.provide(NodeServices.layer),
)
+1 -1
View File
@@ -15,7 +15,7 @@ export function action(current: string, latest: string, policy: Policy): Action
return policy === "notify" ? "notify" : "upgrade"
}
export function parseReleaseVersion(input: string) {
function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+8 -12
View File
@@ -5,21 +5,19 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { action, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
export interface Interface {
readonly check: () => Effect.Effect<void>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -112,9 +110,7 @@ export const layer = Layer.effect(
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -142,7 +138,7 @@ export const layer = Layer.effect(
}
return yield* run(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
)
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
})
@@ -177,7 +173,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check, method, latest, upgrade })
return Service.of({ check })
}),
)
+2 -16
View File
@@ -486,14 +486,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.prompt = { paste: "compact" }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
draft.mini = { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true }
})
}),
)
@@ -501,14 +494,7 @@ test("updates a config draft while preserving JSONC comments", async () => {
expect(config).toEqual({
animations: true,
prompt: { paste: "compact" },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
mini: { thinking: "hide", shell_output: "hide", turn_summary: "hide", splash: "hide", mono: true },
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
-36
View File
@@ -1,36 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Command } from "effect/unstable/cli"
import { Commands } from "../../src/commands/commands"
import upgrade from "../../src/commands/handlers/upgrade"
import { Updater } from "../../src/services/updater"
const record = (event: unknown) => console.log(`EVENT ${JSON.stringify(event)}`)
await Effect.runPromise(
Command.runWith(Commands.commands.upgrade.spec.pipe(Command.withHandler(upgrade)), { version: "test" })(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
method: () =>
Effect.sync(() => {
record("method")
return Updater.methods.find((method) => method === (process.env.UPGRADE_TEST_METHOD ?? "npm"))
}),
latest: () =>
Effect.suspend(() => {
record("latest")
return process.env.UPGRADE_TEST_LATEST_ERROR
? Effect.fail(new Error("Update check failed"))
: Effect.succeed("0.0.0-beta-new")
}),
upgrade: (method, version) =>
Effect.suspend(() => {
record({ method, version })
return process.env.UPGRADE_TEST_INSTALL_ERROR ? Effect.fail(new Error("Permission denied")) : Effect.void
}),
}),
Effect.provide(NodeServices.layer),
),
)
-2
View File
@@ -9,7 +9,6 @@ import {
type InteractiveStdin,
usingInteractiveStdin,
} from "../src/mini-host"
import { OPENCODE_VERSION } from "../src/version"
import { tmpdir } from "./fixture/tmpdir"
const model = { providerID: "openai", modelID: "gpt-5" }
@@ -146,7 +145,6 @@ describe("Mini CLI host", () => {
const input = host({ stdin: stream(true), cleanup() {} }, directory.path)
expect(input.paths).toEqual({ home: directory.path })
expect(input.version).toBe(OPENCODE_VERSION)
expect(input.platform).toBe(process.platform)
expect(typeof input.files.readText).toBe("function")
const file = path.join(directory.path, "attachment.txt")
-227
View File
@@ -1,227 +0,0 @@
import { NodeServices } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { expect, test } from "bun:test"
import { Effect, FileSystem, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync } from "node:fs"
import path from "node:path"
import { Updater } from "../src/services/updater"
import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeServices.layer)
declare const OPENCODE_CLI_NAME: string | undefined
function fixture(
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
error?: AppProcess.AppProcessError
} = () => ({}),
) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const global = Global.make({
home: path.join(root, "home"),
data: path.join(root, "data"),
cache: path.join(root, "cache"),
config: path.join(root, "config"),
state: path.join(root, "state"),
tmp: path.join(root, "tmp"),
bin: path.join(root, "bin"),
log: path.join(root, "log"),
repos: path.join(root, "repos"),
})
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provideService(Global.Service, global),
Effect.provideService(
AppProcess.Service,
AppProcess.Service.of({
...spawner,
run: (command) =>
Effect.suspend(() => {
if (command._tag !== "StandardCommand") return Effect.die("Unexpected piped install command")
commands.push([command.command, ...command.args])
const result = respond(command)
if (result.error) return Effect.fail(result.error)
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
...result,
})
}),
runStream: () => Stream.die("Unexpected streaming install command"),
}),
),
)
return { updater, commands, global, fs }
})
}
const installs = [
{ method: "npm", command: ["npm", "install", "--global", "@opencode-ai/cli@2.3.4-beta.1"] },
{
method: "pnpm",
command: ["pnpm", "add", "--global", "--allow-build=@opencode-ai/cli", "@opencode-ai/cli@2.3.4-beta.1"],
},
{ method: "yarn", command: ["yarn", "global", "add", "@opencode-ai/cli@2.3.4-beta.1"] },
] as const
installs.forEach(({ method, command }) => {
it.live(`${method} installs the explicit V2 package version without a leading v`, () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* test.updater.upgrade(method, "v2.3.4-beta.1")
expect(test.commands).toEqual([[...command]])
}),
)
})
;[0, 1].forEach((exitCode) => {
it.live(`bun isolates and removes its install cache after exit ${exitCode}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
expect(command.command).toBe("bun")
expect(existsSync(command.args[4])).toBe(true)
return { exitCode, stderr: Buffer.from("bun install failed") }
})
const result = yield* test.updater.upgrade("bun", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const cache = test.commands[0]?.[5]
expect(cache).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["bun", "install", "--global", "--trust", "--cache-dir", cache, "@opencode-ai/cli@2.3.4-beta.1"],
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(exitCode === 0 ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe("bun install failed")
}),
)
})
;["success", "download", "install"].forEach((failure) => {
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => {
const installer = command.command === "curl" ? command.args[2] : command.args[0]
expect(existsSync(path.dirname(installer))).toBe(true)
return {
exitCode: command.command === (failure === "download" ? "curl" : failure === "install" ? "bash" : "") ? 1 : 0,
stderr: Buffer.from(`${failure} failed`),
}
})
const result = yield* test.updater.upgrade("curl", "v2.3.4-beta.1").pipe(Effect.flip, Effect.option)
const installer = test.commands[0]?.[3]
expect(installer).toStartWith(path.join(test.global.cache, "update-"))
expect(test.commands).toEqual([
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
...(failure === "download" ? [] : [["bash", installer, "--version", "2.3.4-beta.1", "--no-modify-path"]]),
])
expect(yield* test.fs.readDirectory(test.global.cache)).toEqual([])
expect(result._tag).toBe(failure === "success" ? "None" : "Some")
if (result._tag === "Some") expect(result.value.message).toBe(`${failure} failed`)
}),
)
})
it.live("invalid version targets never execute a command or create a cache", () =>
Effect.gen(function* () {
const test = yield* fixture()
yield* Effect.forEach(Updater.methods, (method) =>
Effect.forEach(
["", "latest", "2.3", "01.2.3", "vv2.3.4", "2.3.4; echo unsafe", "--global", "v2.3.4\n--force"],
(version) =>
Effect.gen(function* () {
const error = yield* test.updater.upgrade(method, version).pipe(Effect.flip)
expect(error.message).toBe(`Invalid version: ${version}`)
}),
),
)
expect(test.commands).toEqual([])
expect(yield* test.fs.exists(test.global.cache)).toBe(false)
}),
)
it.live("install failures expose stderr and process errors do not report success", () =>
Effect.gen(function* () {
const failed = yield* fixture(() => ({ exitCode: 1, stderr: Buffer.from(" registry denied access\n") }))
const error = yield* failed.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(error.message).toBe("registry denied access")
const missing = yield* fixture(() => ({ error: new AppProcess.AppProcessError({ command: "npm" }) }))
const unavailable = yield* missing.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
expect(unavailable.message).toBe("Failed to update with npm")
expect(failed.commands).toHaveLength(1)
expect(missing.commands).toHaveLength(1)
}),
)
;(["npm", "pnpm", "bun", "yarn", undefined] as const).forEach((method) => {
it.live(`method detection identifies ${method ?? "an unknown installation"} using the V2 package`, () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === method ? "@opencode-ai/cli@2.3.4" : "opencode-ai@1.0.0"),
}))
expect(yield* test.updater.method()).toBe(method)
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["pnpm", "list", "-g", "--depth=0", "@opencode-ai/cli"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
])
}),
)
})
it.live("method detection tolerates unavailable package managers", () =>
Effect.gen(function* () {
const test = yield* fixture((command) =>
command.command === "yarn"
? { stdout: Buffer.from("@opencode-ai/cli@2.3.4") }
: { error: new AppProcess.AppProcessError({ command: command.command }) },
)
expect(yield* test.updater.method()).toBe("yarn")
expect(test.commands).toHaveLength(4)
}),
)
test("Node distribution honors the compile-time CLI name", async () => {
const child = Bun.spawn(
[
process.execPath,
"test",
import.meta.path,
"--define",
'OPENCODE_CLI_NAME="opencode2-node"',
"--test-name-pattern",
"^Node distribution resolves the published npm package$",
],
{ cwd: path.join(import.meta.dir, ".."), stdout: "ignore", stderr: "pipe" },
)
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
expect(code, stderr).toBe(0)
expect(stderr).toContain("1 pass")
})
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
it.live("Node distribution resolves the published npm package", () =>
Effect.gen(function* () {
const test = yield* fixture((command) => ({
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
}))
expect(yield* test.updater.method()).toBe("npm")
yield* test.updater.upgrade("npm", "v2.3.4")
yield* test.updater.upgrade("pnpm", "v2.3.4")
expect(test.commands).toEqual([
["npm", "list", "-g", "--depth=0", "opencode-node"],
["pnpm", "list", "-g", "--depth=0", "opencode-node"],
["bun", "pm", "ls", "-g"],
["yarn", "global", "list"],
["npm", "install", "--global", "opencode-node@2.3.4"],
["pnpm", "add", "--global", "--allow-build=opencode-node", "opencode-node@2.3.4"],
])
}),
)
}
-109
View File
@@ -1,109 +0,0 @@
import { describe, expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("upgrade command", () => {
test("is registered in root help and documents its options", async () => {
const root = await cli(["--help"], {}, "../src/index.ts")
const help = await cli(["upgrade", "--help"], {}, "../src/index.ts")
expect(root.exitCode).toBe(0)
expect(root.stdout).toContain("upgrade")
expect(help.exitCode).toBe(0)
expect(help.stdout).toContain("[<target>]")
expect(help.stdout).toContain("--method")
expect(help.stdout).toContain("-m")
})
test("detects the installation method and resolves the latest version", async () => {
const result = await cli([])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method", "latest", { method: "npm", version: "0.0.0-beta-new" }])
expect(result.stdout).toContain("Upgrade complete")
})
test("accepts an explicit version and method without detection or a version lookup", async () => {
const result = await cli(["v0.0.0-beta-target", "--method", "pnpm"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "pnpm", version: "v0.0.0-beta-target" }])
expect(result.stdout).toContain("0.0.0-beta-old → 0.0.0-beta-target")
})
test("accepts the short method flag and an explicit major upgrade", async () => {
const result = await cli(["2.0.0", "-m", "bun"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual([{ method: "bun", version: "2.0.0" }])
})
test("skips the already installed version", async () => {
const result = await cli(["v0.0.0-beta-old"])
expect(result.exitCode).toBe(0)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("already installed")
})
test("requires an explicit method when detection fails", async () => {
const result = await cli([], { UPGRADE_TEST_METHOD: "unknown" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method"])
expect(result.stdout).toContain("Pass --method")
})
test("rejects unsupported methods before attempting an upgrade", async () => {
const result = await cli(["--method", "brew"])
expect(result.exitCode).not.toBe(0)
expect(result.events).toEqual([])
})
test("reports version lookup failures without installing", async () => {
const result = await cli([], { UPGRADE_TEST_LATEST_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.events).toEqual(["method", "latest"])
expect(result.stdout).toContain("Update check failed")
})
test("reports installation failures with a nonzero exit code", async () => {
const result = await cli([], { UPGRADE_TEST_INSTALL_ERROR: "1" })
expect(result.exitCode).toBe(1)
expect(result.stdout).toContain("Upgrade failed")
expect(result.stdout).toContain("Permission denied")
expect(result.stdout).not.toContain("Upgrade complete")
})
})
async function cli(args: string[], env: Record<string, string> = {}, entry = "fixture/upgrade.ts") {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-upgrade-"))
try {
const child = Bun.spawn(
[process.execPath, "--define", 'OPENCODE_VERSION="0.0.0-beta-old"', path.join(import.meta.dir, entry), ...args],
{
cwd: path.join(import.meta.dir, ".."),
env: {
...process.env,
OPENCODE_TEST_HOME: root,
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
...env,
},
stdout: "pipe",
stderr: "pipe",
},
)
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
const events = stdout
.split("\n")
.filter((line) => line.startsWith("EVENT "))
.map((line) => JSON.parse(line.slice(6)))
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
return { stdout, stderr, exitCode, events }
} finally {
await rm(root, { recursive: true, force: true })
}
}
+34
View File
@@ -6,6 +6,7 @@ Private generation target for clients derived directly from OpenCode's authorita
- `@opencode-ai/client`: zero-Effect Promise client using `fetch`.
- `@opencode-ai/client/effect`: rich Effect network client using an environment-provided `HttpClient`.
- `@opencode-ai/client/effect/websocket`: native Effect RPC over a single scoped WebSocket.
The generated surface includes every standard HTTP group from Server's concrete API. The build compiler reads `@opencode-ai/server/api`; the generated Effect runtime imports a client-local projection built from Protocol, with a generation-equivalence test preventing transport drift. Custom transports such as the PTY WebSocket connection remain outside the generic HTTP client. Run `bun run generate` after changing the contract and `bun run check:generated` to detect committed-output drift.
@@ -25,3 +26,36 @@ yield *
})
yield * client.sessions.prompt({ sessionID, prompt: Prompt.make({ text: "Hello" }) })
```
## WebSocket RPC
The additive `/api/rpc` transport derives its operation contracts from Protocol's HTTP schemas; existing HTTP clients are unchanged. Use operation identifiers directly, with decoded `params`, `query`, and `payload` fields where declared. Numeric queries are numbers, not HTTP strings. Optional `location: { directory, workspace? }` selects per-call location context; session-specific operations retain their existing session location rules.
```ts
import { OpenCodeRpc } from "@opencode-ai/client/effect/websocket"
import { Effect, Redacted, Stream } from "effect"
declare const token: string // Base64 of "opencode:<server password>", not the raw password.
const program = Effect.gen(function* () {
const client = yield* OpenCodeRpc.make({
url: "wss://opencode.example/api/rpc",
authToken: Redacted.make(token),
})
const events = yield* client["event.subscribe"]({}).pipe(
Stream.runForEach((event) => Effect.log(event.type)),
Effect.forkScoped,
)
const sessions = yield* client["session.list"]({ query: {} })
// Both requests share the connection; interrupting events cancels only that subscription.
return sessions
})
Effect.runPromise(Effect.scoped(program))
```
Keep the scope open while consuming streams. Scope closure closes the socket and cancels outstanding work. Connection failures are surfaced rather than replaying potentially mutating requests; create a new scoped client to reconnect. Browsers use their native WebSocket constructor, with an optional `webSocketConstructor` override for other runtimes. Authentication uses the server's existing `auth_token` upgrade mechanism; treat the resulting URL as sensitive and do not log it.
Streaming operations return native Effect Streams of the original typed items, not SSE text. No-content operations return `void`. `fs.read` takes `params: { path: "relative/file" }` plus `query: {}` and returns `{ content: Uint8Array, mime: string }`, with bytes encoded as base64 on the wire. Raw `pty.connect` and `persistentPty.connect` remain on their existing WebSocket routes.
From `packages/server`, run `bun run script/benchmark-rpc.ts` for an isolated loopback comparison of HTTP and RPC session-list calls. It uses an in-memory database, temporary configuration, and schema-decoding clients at concurrency 1 and 16. This measures transport overhead, not end-to-end desktop/web speed; the apps still use HTTP until explicitly switched.
+1
View File
@@ -23,6 +23,7 @@
"./solid": "./src/solid/index.ts",
"./effect": "./src/effect/index.ts",
"./effect/api": "./src/effect/api.ts",
"./effect/websocket": "./src/effect/websocket.ts",
"./effect/service": "./src/effect/service.ts"
},
"scripts": {
+35
View File
@@ -0,0 +1,35 @@
export * as OpenCodeRpc from "./websocket.js"
import { Group, type Rpcs } from "@opencode-ai/protocol/rpc"
import { Effect, Redacted, Schedule, Scope } from "effect"
import { RpcClient, RpcClientError, RpcSerialization } from "effect/unstable/rpc"
import { Socket } from "effect/unstable/socket"
export interface Options {
/** Full WebSocket endpoint, for example wss://opencode.example/api/rpc. */
readonly url: string | URL
/** Existing server auth_token, sent only during the WebSocket upgrade. */
readonly authToken?: Redacted.Redacted<string>
readonly webSocketConstructor?: Socket.WebSocketConstructor["Service"]
}
export type Client = RpcClient.RpcClient<Rpcs, RpcClientError.RpcClientError>
/** One scoped connection multiplexes all unary calls and streaming subscriptions. */
export const make: (options: Options) => Effect.Effect<Client, never, Scope.Scope> = Effect.fnUntraced(
function* (options) {
const url = new URL(options.url)
if (options.authToken) url.searchParams.set("auth_token", Redacted.value(options.authToken))
const socket = yield* Socket.makeWebSocket(url.toString()).pipe(
Effect.provideService(
Socket.WebSocketConstructor,
options.webSocketConstructor ?? ((url, protocols) => new WebSocket(url, protocols)),
),
)
const protocol = yield* RpcClient.makeProtocolSocket({ retryPolicy: Schedule.recurs(0) }).pipe(
Effect.provideService(Socket.Socket, socket),
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json),
)
return yield* RpcClient.make(Group).pipe(Effect.provideService(RpcClient.Protocol, protocol))
},
)
@@ -28,6 +28,12 @@ describe("public import boundaries", () => {
expect(within(network.all, core)).toEqual([])
expect(within(network.all, server)).toEqual([])
const rpc = await bundleInputs("@opencode-ai/client/effect/websocket", "browser")
expect(within(rpc.eager, effect).length).toBeGreaterThan(0)
expect(within(rpc.eager, protocol).length).toBeGreaterThan(0)
expect(within(rpc.all, core)).toEqual([])
expect(within(rpc.all, server)).toEqual([])
const promiseService = await bundleInputs("@opencode-ai/client/service", "bun")
expect(within(promiseService.all, effect)).toEqual([])
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from "bun:test"
import { createServer } from "node:http"
import { NodeHttpServer } from "@effect/platform-node"
import { ClientApi } from "@opencode-ai/protocol/client"
import { SessionNotFoundError } from "@opencode-ai/protocol/errors"
import { Group, fromEndpoint } from "@opencode-ai/protocol/rpc"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
import { Deferred, Effect, Fiber, Redacted, Stream } from "effect"
import { RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { OpenCodeRpc } from "../src/effect/websocket.js"
test("one scoped socket multiplexes unary calls and cancellable typed streams", async () => {
const sockets: WebSocket[] = []
await Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const stopped = yield* Deferred.make<void>()
const group = RpcGroup.make(
fromEndpoint(ClientApi.groups["server.health"].endpoints["health.get"]),
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.list"]),
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.get"]),
fromEndpoint(ClientApi.groups["server.session"].endpoints["session.remove"]),
fromEndpoint(ClientApi.groups["server.event"].endpoints["event.subscribe"]),
fromEndpoint(ClientApi.groups["server.fs"].endpoints["fs.read"]),
)
const app = yield* RpcServer.toHttpEffectWebsocket(group).pipe(
Effect.provide(
group.toLayer({
"health.get": () => Effect.succeed({ healthy: true, version: "test", pid: 0 }),
"session.list": () => Effect.succeed({ data: [], cursor: {} }),
"session.get": ({ params }) =>
Effect.fail(new SessionNotFoundError({ sessionID: params.sessionID, message: "missing" })),
"session.remove": () => Effect.void,
"fs.read": () => Effect.succeed({ content: new Uint8Array([0, 255, 128]), mime: "application/octet-stream" }),
"event.subscribe": () =>
Stream.fromEffect(Deferred.succeed(started, undefined)).pipe(
Stream.map(() => ({ id: Event.ID.make("evt_connected"), type: "server.connected" as const, data: {} })),
Stream.concat(Stream.never),
Stream.ensuring(Deferred.succeed(stopped, undefined)),
),
}),
),
Effect.provideService(RpcSerialization.RpcSerialization, RpcSerialization.json),
)
const server = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
yield* server.serve(app)
if (server.address._tag !== "TcpAddress") return yield* Effect.die("Expected TCP listener")
yield* Effect.scoped(
Effect.gen(function* () {
const client = yield* OpenCodeRpc.make({
url: `ws://127.0.0.1:${server.address._tag === "TcpAddress" ? server.address.port : 0}/api/rpc`,
authToken: Redacted.make("test-token"),
webSocketConstructor: (url, protocols) => {
expect(new URL(url).searchParams.get("auth_token")).toBe("test-token")
const socket = new WebSocket(url, protocols)
sockets.push(socket)
return socket
},
})
const received = yield* Deferred.make<void>()
const events = yield* client["event.subscribe"]({}).pipe(
Stream.runForEach((event) => {
expect(event.type).toBe("server.connected")
return Deferred.succeed(received, undefined)
}),
Effect.forkScoped,
)
yield* Deferred.await(started)
yield* Deferred.await(received)
const [health, sessions, file] = yield* Effect.all(
[
client["health.get"]({}),
client["session.list"]({ query: {} }),
client["fs.read"]({ params: { path: "a.bin" }, query: {} }),
],
{ concurrency: "unbounded" },
)
expect(health).toEqual({ healthy: true, version: "test", pid: 0 })
expect(sessions).toEqual({ data: [], cursor: {} })
expect(file.content).toEqual(new Uint8Array([0, 255, 128]))
const error = yield* client["session.get"]({ params: { sessionID: Session.ID.make("ses_missing") } }).pipe(
Effect.flip,
)
expect(error).toBeInstanceOf(SessionNotFoundError)
expect(yield* client["session.remove"]({ params: { sessionID: Session.ID.make("ses_test") } })).toBeUndefined()
yield* Fiber.interrupt(events)
yield* Deferred.await(stopped)
expect((yield* client["health.get"]({})).healthy).toBe(true)
expect(sockets).toHaveLength(1)
}),
)
}).pipe(Effect.scoped, Effect.timeout("10 seconds"), Effect.runPromise)
expect(sockets[0]!.readyState).toBe(WebSocket.CLOSED)
})
// The derived client keeps operation-specific request and response types.
type Client = Effect.Success<ReturnType<typeof OpenCodeRpc.make>>
type HasRawPty = "pty.connect" extends keyof Client ? true : false
const noRawPty: HasRawPty = false
type Tags = RpcGroup.Rpcs<typeof Group>["_tag"]
const fileTag: Tags = "fs.read"
test("raw PTY is not part of the typed client", () => {
expect(noRawPty).toBe(false)
expect(fileTag).toBe("fs.read")
})
+5 -18
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,22 +60,9 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
Nested records are the shorthand for ordinary namespaces. Use `Namespace.make` when a namespace needs a description:
```ts
const runtime = CodeMode.make({
tools: {
orders: Namespace.make({
description: "Purchases, fulfillment, and shipment tracking",
tools: { lookup: lookupOrder },
}),
},
})
```
Namespace descriptions are optional and participate in search matching for every descendant tool. Names still come
from record keys, so the wrapper does not repeat `orders`. Dots in keys create nested paths; other characters use
bracket notation, such as `tools.context7["resolve-library-id"](...)`.
Dots in tool names create namespaces: `{ "issues.list": tool }` and `{ issues: { list: tool } }` both expose
`tools.issues.list(...)`. Other characters use bracket notation, such as
`tools.context7["resolve-library-id"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -163,7 +150,7 @@ and `CodeMode.toolExpression(path)` supply the exact callable forms.
The synchronous `search(...)` built-in is always available. It supports exact-path lookup, namespace-scoped search,
empty-query browsing, and pagination, and returns callable paths with full signatures. Search counts toward
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
`maxToolCalls`.
## Execution Limits
-1
View File
@@ -1,5 +1,4 @@
export * as CodeMode from "./codemode.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
-24
View File
@@ -1,24 +0,0 @@
import type { Tools } from "./tools.js"
/** A tool namespace with optional model-visible metadata. */
export type Namespace<R = never> = {
readonly _tag: "CodeModeNamespace"
readonly description?: string
readonly tools: Tools<R>
}
/** Options for declaring one CodeMode namespace. */
export type Options<R = never> = {
readonly description?: string
readonly tools: Tools<R>
}
export const isNamespace = <R = never>(value: Namespace<R> | Tools<R>): value is Namespace<R> =>
Object.hasOwn(value, "_tag") && value._tag === "CodeModeNamespace"
/** Declares a namespace when descriptions or other namespace metadata are needed. */
export const make = <R = never>(options: Options<R>): Namespace<R> => ({
_tag: "CodeModeNamespace",
...(options.description === undefined ? {} : { description: options.description }),
tools: options.tools,
})
+1 -1
View File
@@ -53,6 +53,7 @@ export const fromSpec = (options: Options): Result => {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
@@ -98,7 +99,6 @@ export const fromSpec = (options: Options): Result => {
auth: options.auth,
headers: options.headers ?? {},
}
const segments = operationPath(method, path, operationValue, used, namespaces)
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(
+3 -1
View File
@@ -461,7 +461,9 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
)
const used = new Set<string>()
return {
+25 -34
View File
@@ -8,7 +8,6 @@ import {
inputTypeScript,
outputTypeScript,
} from "./tool-schema.js"
import { isNamespace, type Namespace } from "./namespace.js"
import { isTool, type Tool } from "./tool.js"
import type { Tools } from "./tools.js"
import {
@@ -278,7 +277,6 @@ export const copyOut = (value: unknown, mode: CopyOutMode): unknown => {
// Dots in tool names are namespace separators; the last tool for a canonical path wins.
type ToolNode<R> = {
tool?: Tool<R>
namespace?: Namespace<R>
readonly children: Map<string, ToolNode<R>>
}
@@ -294,10 +292,7 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
else insert(current, value)
}
}
insert(root, tools)
@@ -307,33 +302,29 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
const canonicalSegments = (path: ReadonlyArray<string>): ReadonlyArray<string> =>
path.flatMap((segment) => segment.split("."))
type VisibleTool<R> = {
readonly path: string
readonly tool: Tool<R>
readonly namespaces: ReadonlyArray<Namespace<R>>
}
const flattenTools = <R>(
node: ToolNode<R>,
path: ReadonlyArray<string> = [],
namespaces: ReadonlyArray<Namespace<R>> = [],
): Array<VisibleTool<R>> => {
const next = node.namespace === undefined ? namespaces : [...namespaces, node.namespace]
return [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool, namespaces: next }]),
...Array.from(node.children).flatMap(([name, child]) => flattenTools(child, [...path, name], next)),
]
}
): Array<{ path: string; tool: Tool<R> }> => [
...(node.tool === undefined ? [] : [{ path: path.join("."), tool: node.tool }]),
...Array.from(node.children, ([name, child]) => flattenTools(child, [...path, name])).flat(),
]
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
})
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visibleTools = <R>(tools: Tools<R>) =>
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
flattenTools(toolTrie(tools))
.sort((left, right) => compareText(left.path, right.path))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -429,13 +420,12 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
searchText: [
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -443,13 +433,14 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
}
}
+1 -1
View File
@@ -127,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
+7 -4
View File
@@ -1,6 +1,4 @@
import { Effect, Schema } from "effect"
import type { Namespace } from "./namespace.js"
import type { Tools } from "./tools.js"
/**
* JSON Schema subset for model-visible signatures. CodeMode does not validate values against
@@ -52,8 +50,13 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
export const isTool = <R = never>(value: Tool<R> | Namespace<R> | Tools<R> | undefined): value is Tool<R> =>
value !== undefined && Object.hasOwn(value, "_tag") && value._tag === "CodeModeTool"
// Object.hasOwn: an inherited _tag must not classify a namespace as a Tool.
export const isTool = <R = never>(value: unknown): value is Tool<R> =>
typeof value === "object" &&
value !== null &&
"_tag" in value &&
Object.hasOwn(value, "_tag") &&
value._tag === "CodeModeTool"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+1 -2
View File
@@ -1,6 +1,5 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
readonly [name: string]: Tool<R> | Tools<R>
}
+4 -35
View File
@@ -25,12 +25,8 @@ const happyPathSpec = async (): Promise<Document> => {
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const toolAt = (tools: OpenAPI.Tools, name: string) =>
name
.split(".")
.reduce<
Tool.Tool<HttpClient.HttpClient> | OpenAPI.Tools | undefined
>((current, segment) => (current !== undefined && !Tool.isTool(current) ? current[segment] : undefined), tools)
const toolAt = (tools: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
const recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -282,30 +278,6 @@ describe("OpenAPI.fromSpec", () => {
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
for (const unsupported of [false, true]) {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: operation },
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
"/last": { get: operation },
},
},
})
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
expect(result.skipped).toEqual(
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
)
}
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({
@@ -343,10 +315,7 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
responses: { 200: { description: "Success" } },
},
},
@@ -979,7 +948,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
const healthInput = isRecord(health) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+16 -22
View File
@@ -344,34 +344,28 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf keeps siblings and parenthesized union members in order", () => {
test("allOf renders intersections with parenthesized union members", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
})
test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => {
for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown")
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown")
expect(
jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty),
).toBe("unknown")
}
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
expect(
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
jsonSchemaToTypeScript({
allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }],
}),
).toBe("unknown")
expect(
jsonSchemaToTypeScript({
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
}),
).toBe("string")
})
})
+1 -43
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Namespace, Tool } from "../src/index.js"
import { CodeMode, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,48 +177,6 @@ describe("blocked member names on tool paths", () => {
})
})
describe("namespace metadata", () => {
const tools = {
api: Namespace.make({
description: "Manage the workspace",
tools: {
users: Namespace.make({
description: "Directory and account administration",
tools: { list: echo("List users", "users") },
}),
status: echo("Read service status", "ok"),
},
}),
plain: { read: echo("Read plain data", "plain") },
}
const runtime = CodeMode.make({ tools })
test("the wrapper does not add a segment to callable paths", async () => {
expect(runtime.catalog().map((tool) => tool.path)).toEqual(["api.status", "api.users.list", "plain.read"])
expect(await value(runtime, `return await tools.api.users.list({})`)).toBe("users")
})
test("search matches descriptions from every enclosing namespace", async () => {
const workspace = await value(runtime, `return search({ query: "workspace" })`)
expect((workspace as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.status",
"tools.api.users.list",
])
const directory = await value(runtime, `return search({ query: "account administration" })`)
expect((directory as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
"tools.api.users.list",
])
})
test("a namespace description is optional", async () => {
const optional = CodeMode.make({
tools: { api: Namespace.make({ tools: { read: echo("Read data", "read") } }) },
})
expect(await value(optional, `return await tools.api.read({})`)).toBe("read")
})
})
describe("empty segments", () => {
test("tool names with empty segments are rejected at make", () => {
for (const name of ["", "a..b", "trail.", ".lead"]) {
+13 -4
View File
@@ -1,11 +1,20 @@
import { buildLocationServiceMap } from "../location-services.js"
import { LocationServiceMap } from "../location-service-map.js"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
export function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
return LayerNode.compile(root, {
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
})
export function build<A, E>(root: LayerNode.Node<A, E, any>, replacements: LayerNode.Replacements = []) {
// Only build the location service map if it's actually needed
if (!LayerNode.hasUnbound(root, LocationServiceMap.node) || hasReplacement(replacements, LocationServiceMap.node))
return LayerNode.compile(root, replacements)
const locationMap = buildLocationServiceMap(replacements)
const locationMapNode = makeGlobalNode({ service: LocationServiceMap.Service, layer: locationMap, deps: [] })
return LayerNode.compile(root, replacements.concat([[LocationServiceMap.node, locationMapNode]]))
}
function hasReplacement(replacements: LayerNode.Replacements, node: LayerNode.Node<unknown, unknown, any>) {
return replacements.some(([source]) => source.name === node.name)
}
export * as AppNodeBuilder from "./app-node-builder.js"
+16 -10
View File
@@ -55,7 +55,6 @@ import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
export * as Instance from "./instance.js"
export { Service, byLocationNode, type Interface } from "./instance/service.js"
const nodes = [
Location.node,
@@ -111,9 +110,9 @@ const nodes = [
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
export const graph = LayerNode.group(nodes)
export const graph = LayerNode.group<typeof nodes>(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
@@ -142,23 +141,29 @@ export interface Options {
// source still honors explicit plugin operations from wellknown and
// host-injected config.
const vanillaReplacements: LayerNode.Replacements = [
Config.node.replace(Config.configured({ project: false, global: false })),
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: false, global: false })),
[Config.node, Config.configured({ project: false, global: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
]
// One instance is one compiled, fresh copy of the graph standing on a directory.
export function layer(ref: Location.Ref, options: Options = {}) {
const startedAt = performance.now()
// Ordered: vanilla defaults, then caller replacements (which win over the
// defaults), then instance bindings (which win over everything).
const replacements: LayerNode.Replacements = [
// defaults), then bound pairs (which win over everything).
const allReplacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
]
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
// those back out.
const location = LayerNode.hoist(graph, Node.tags.values.global, allReplacements)
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
return LayerNode.compile(location.node).pipe(
Layer.fresh,
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -166,5 +171,6 @@ export function layer(ref: Location.Ref, options: Options = {}) {
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
-42
View File
@@ -1,42 +0,0 @@
export * as Instance from "./service.js"
export type { Services } from "../instance.js"
import { Context, Effect, Layer, Option, Scope } from "effect"
import type { Session } from "@opencode-ai/schema/session"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import type { Services } from "../instance.js"
import { LocationServiceMap } from "../location-service-map.js"
/** Selects Session capabilities; implementations own caching and lifetime. */
export interface Interface {
readonly provide: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, Exclude<R, Services>>
/** Borrow a cached instance without initializing one when it is absent. */
readonly provideIfLoaded: (
session: Session.Info,
) => <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<Option.Option<A>, E, Exclude<R, Services>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Instance") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
return Service.of({
provide: (session) => Effect.provide(locations.get(session.location)),
provideIfLoaded: (session) => (effect) =>
// Scope the borrowed reference without replacing the caller's Scope.
Effect.scopedWith((scope) =>
Effect.gen(function* () {
const context = yield* locations.contextEffectOption(session.location).pipe(Scope.provide(scope))
if (Option.isNone(context)) return Option.none()
return Option.some(yield* effect.pipe(Effect.provide(context.value)))
}),
),
})
}),
)
export const byLocationNode = makeGlobalNode({ service: Service, layer, deps: [LocationServiceMap.node] })
+3 -3
View File
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
}
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
const index = input.indexOf("/")
const [providerID, ...modelID] = input.split("/")
return {
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
+3 -9
View File
@@ -112,7 +112,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
const makeLayer = (options: Options = {}) =>
export const configured = (options: Options = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
@@ -361,14 +361,8 @@ const makeLayer = (options: Options = {}) =>
}),
)
export const layer = makeLayer()
export const configured = (options?: Options) =>
makeGlobalNode({
service: Service,
layer: options === undefined ? layer : makeLayer(options),
deps: [Bus.node, Global.node],
})
export const node = configured()
export const layer = configured()
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
+3 -6
View File
@@ -80,8 +80,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
// Keep the instance graph's inferred types independent of Session handles.
const context: Plugin.Context = {
return {
app,
location: locationInfo(),
options: {},
@@ -207,8 +206,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.subscribe()
.pipe(
Stream.filter(
(event): event is EventManifest.ServerEvent | RpcEvent =>
EventManifest.isServer(event) || isRpcEvent(event),
(event): event is EventManifest.ServerEvent | RpcEvent => EventManifest.isServer(event) || isRpcEvent(event),
),
),
},
@@ -451,8 +449,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
wait: (input) => runtime.session.wait(input.sessionID),
context: (input) => runtime.session.context(input.sessionID),
},
}
return context
} satisfies Plugin.Context
})
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
+1 -1
View File
@@ -62,7 +62,7 @@ const require = <A, E, R>(cell: Cell, f: (runtime: Interface) => Effect.Effect<A
const defaultCell = makeCell()
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
export const layerWithCell = (cell: Cell) =>
Layer.succeed(
Service,
Service.of({
@@ -11,9 +11,6 @@ truth. Follow links from that page when the question needs more detail. Fetch
<https://opencode.ai/v2/docs/> first when you need to discover the relevant
documentation page.
A machine-readable documentation index is available at
<https://opencode.ai/v2/llms.txt>.
## Version policy
Always answer for OpenCode V2 unless the user explicitly asks about V1,
@@ -155,8 +152,6 @@ before answering. Refer to this guide when the user wants to build a plugin. It
covers hooks, transforms, tools, plugin context capabilities, and package
entrypoints. Plugins can also extend the TUI; for those, fetch the
[CLI plugin guide](https://opencode.ai/v2/docs/build/plugins/cli).
For custom methods and events shared with other plugins or clients, fetch the
[RPC guide](https://opencode.ai/v2/docs/build/plugins/rpc).
## [Service](https://opencode.ai/v2/docs/troubleshooting#check-the-background-service)
@@ -225,16 +220,6 @@ exposes typed Effects, Streams, and decoded OpenCode schema values. Its
`Service` API can discover, start, stop, and authenticate with the local
background service from a Node application.
## [SDK](https://opencode.ai/v2/docs/build/sdk)
For questions about embedding OpenCode directly in an application, fetch the
full [SDK guide](https://opencode.ai/v2/docs/build/sdk) before answering. The SDK
hosts OpenCode in the application without opening an HTTP listener.
Use the [Effect SDK guide](https://opencode.ai/v2/docs/build/sdk/effect) for
Effect applications. For Cloudflare Durable Objects, use the
[Cloudflare SDK guide](https://opencode.ai/v2/docs/build/sdk/cloudflare).
## [Troubleshooting](https://opencode.ai/v2/docs/troubleshooting)
OpenCode runs a client and a background server. Start by determining whether a
+11 -10
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@@ -10,7 +10,6 @@ import { Location } from "./location.js"
import { SessionMessage } from "./session/message.js"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Bus } from "./bus.js"
import { Instance } from "./instance/service.js"
import { Database } from "./database/database.js"
import { SessionProjector } from "./session/projector.js"
import { SessionMessageTable } from "./session/sql.js"
@@ -239,16 +238,20 @@ const layer = Layer.effect(
const global = yield* Global.Service
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const instances = yield* Instance.Service
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const sessions = yield* Session.make()
const sessions = yield* Session.make((ref) => locations.get(ref))
const admission = yield* SessionInbox.Service
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
})
if (!(yield* RcMap.has(locations.rcMap, location))) return
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
instances.provideIfLoaded(session),
Effect.provide(locations.get(location)),
)
})
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -400,7 +403,7 @@ const layer = Layer.effect(
prompt: (input) => sessions.forSession(input.sessionID).prompt(input),
generate: Effect.fn("Session.generate")(function* (input) {
const session = yield* result.get(input.sessionID)
const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
const generate = yield* SessionGenerate.Service.pipe(Effect.provide(locations.get(session.location)))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
@@ -409,7 +412,7 @@ const layer = Layer.effect(
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(instances.provide(session))
}).pipe(Effect.provide(locations.get(session.location)))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
@@ -465,8 +468,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const latest = yield* result.get(input.sessionID)
const source = yield* fs.stat(latest.location.directory).pipe(Effect.orElseSucceed(() => undefined))
// Active runners must hand off at a step boundary to retain their continuation.
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
if (!source || source.type !== "Directory") {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
@@ -532,7 +534,6 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
Instance.byLocationNode,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
+8 -7
View File
@@ -4,7 +4,7 @@ import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Job } from "../job.js"
import { Instance } from "../instance/service.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
import { SessionRunCoordinator } from "./run-coordinator.js"
@@ -35,7 +35,7 @@ export interface Interface {
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
/** Routes execution from a Session ID to its selected instance's runner. */
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionExecution") {}
type InterruptReason = "user" | "shutdown"
@@ -48,12 +48,12 @@ export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?:
return { type: "failed" as const, error: toSessionError(failure) }
}
/** Process-local execution: drains run in this process using the selected instance. */
/** Process-local execution: drains run in this process, routed through the Session's Location graph. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const instances = yield* Instance.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const jobs = yield* Job.Service
const db = (yield* Database.Service).db
@@ -90,7 +90,7 @@ export const layer = Layer.effect(
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
instances.provide(session),
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -159,7 +159,8 @@ export const layer = Layer.effect(
// promotable here or was fully delivered and needs no resumption.
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (next === undefined) return interrupted
if (next.delivery === "steer" || SessionInbox.isControl(next)) yield* coordinator.wake(sessionID, "steer")
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
@@ -172,7 +173,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, Instance.byLocationNode, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+3 -6
View File
@@ -47,9 +47,6 @@ export {
*/
export type Promotable = "input" | "steer"
export const isControl = (item: Pick<Item, "type">): item is Pick<Compaction | Move, "type"> =>
item.type === "compaction" || item.type === "move"
const decodeUser = Schema.decodeUnknownSync(UserPayload)
const encodeUser = Schema.encodeSync(UserPayload)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
@@ -508,7 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
Effect.gen(function* () {
const steers = yield* pendingSteers(db, sessionID)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex(isControl)
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -520,10 +517,10 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued || isControl(queued)) return 0
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex(isControl)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
return (
promoted +
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
+1 -1
View File
@@ -594,7 +594,7 @@ const layer = Layer.effectDiscard(
id: event.data.inboxID,
sessionID: event.data.sessionID,
})
if (SessionInbox.isControl(input)) return
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage(
db,
event,
+10 -12
View File
@@ -54,8 +54,8 @@ const layer = Layer.effect(
if (!force && !continuing) {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
if (!pending) return DrainResult.Complete()
if (promotable === "steer" && pending.delivery === "queue" && !SessionInbox.isControl(pending))
return DrainResult.Complete()
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
}
yield* plugins.flush
yield* settleStaleToolCalls(sessionID)
@@ -96,7 +96,7 @@ const layer = Layer.effect(
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@@ -128,16 +128,15 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
const ready = yield* restore(
return yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const scope = entering && !continuing ? promotable : "steer"
const promoted = yield* SessionInbox.promote(db, bus, sessionID, scope)
if (promoted === 0) {
// Cancellation during preparation can expose a control instead of input.
const next = yield* SessionInbox.nextPromotable(db, sessionID, scope)
if (next && SessionInbox.isControl(next)) return undefined
}
const promoted = yield* SessionInbox.promote(
db,
bus,
sessionID,
entering && !continuing ? promotable : "steer",
)
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -146,7 +145,6 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
+19 -10
View File
@@ -1,11 +1,11 @@
export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Schema, Scope } from "effect"
import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import { Event } from "@opencode-ai/schema/event"
import { Bus } from "../bus.js"
import { Instance } from "../instance/service.js"
import { Location } from "../location.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
@@ -33,19 +33,26 @@ import { SessionRevert } from "./revert.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
export type Services =
| PluginSupervisor.Service
| Reference.Service
| SessionPrompt.Service
| SessionRevert.Service
| Shell.Service
| Skill.Service
type PromptRequest = SessionPrompt.Input & {
id?: SessionMessage.ID
resume?: boolean
}
/**
* Build once in the host Scope: `const sessions = yield* Session.make()`.
* Build once in the host Scope: `const sessions = yield* Session.make(servicesFor)`.
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
*/
export const make = Effect.fn("Session.make")(function* () {
export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer<Services>) {
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const instances = yield* Instance.Service
const execution = yield* SessionExecution.Service
const admission = yield* SessionInbox.Service
const scope = yield* Scope.Scope
@@ -167,7 +174,7 @@ export const make = Effect.fn("Session.make")(function* () {
const preparation = yield* SessionPrompt.Service
const references = yield* Reference.Service
return { item: yield* preparation.prepare({ sessionID, messageID, input }), references }
}).pipe(instances.provide(session)),
}).pipe(Effect.provide(servicesFor(session.location))),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
@@ -198,7 +205,7 @@ export const make = Effect.fn("Session.make")(function* () {
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(instances.provide(session))
}).pipe(Effect.provide(servicesFor(session.location)))
const started = yield* shell
.create({
command: input.command,
@@ -249,7 +256,7 @@ export const make = Effect.fn("Session.make")(function* () {
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
) {
const session = yield* get(sessionID)
const skills = yield* Skill.Service.pipe(instances.provide(session))
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
@@ -348,12 +355,14 @@ export const make = Effect.fn("Session.make")(function* () {
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
return yield* SessionRevert.Service.use((revert) =>
revert.stage({ session, messageID: input.messageID, files: input.files }),
).pipe(instances.provide(session))
).pipe(Effect.provide(servicesFor(session.location)))
})
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
const session = yield* get(sessionID)
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(instances.provide(session))
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
Effect.provide(servicesFor(session.location)),
)
return yield* execution.wake(sessionID)
})
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
+2 -2
View File
@@ -22,8 +22,8 @@ const globalLayer = Layer.succeed(Global.Service, Global.Service.of(global))
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Agent.node, Bus.node, Location.node]), [
Global.node.replace(globalLayer),
Location.node.replace(locationLayer),
[Global.node, globalLayer],
[Location.node, locationLayer],
]) as unknown as Layer.Layer<unknown, never>,
)
@@ -21,7 +21,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
[Bus.node, Bus.configured({ persist: true })],
]),
)
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
+10 -10
View File
@@ -100,14 +100,12 @@ const tail = (bus: Bus.Interface, input: { aggregateID: string; after?: number }
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Location.node]), [
Location.node.replace(locationLayer),
Bus.node.replace(Bus.configured({ persist: true })),
[Location.node, locationLayer],
[Bus.node, Bus.configured({ persist: true })],
]),
)
const itWithoutLocation = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
]),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
)
const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
@@ -633,7 +631,8 @@ describe("Bus", () => {
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
@@ -641,7 +640,7 @@ describe("Bus", () => {
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}),
),
],
])
yield* Effect.gen(function* () {
@@ -1319,7 +1318,7 @@ describe("Bus", () => {
it.effect("log replays across configured read pages", () =>
Effect.gen(function* () {
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })),
[Bus.node, Bus.configured({ persist: true, logReadPageSize: 2 })],
])
yield* Effect.gen(function* () {
@@ -1352,7 +1351,8 @@ describe("Bus", () => {
const releaseRead = yield* Deferred.make<void>()
const firstRead = yield* Ref.make(true)
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(
[
Bus.node,
Bus.configured({
persist: true,
beforeAggregateRead: () =>
@@ -1363,7 +1363,7 @@ describe("Bus", () => {
}),
),
}),
),
],
])
yield* Effect.gen(function* () {
+4 -4
View File
@@ -25,7 +25,7 @@ const locationLayer = Layer.succeed(
)
const catalogLayer = AppNodeBuilder.build(
LayerNode.group([Catalog.node, Bus.node, Credential.node, Integration.node]),
[Location.node.replace(locationLayer)],
[[Location.node, locationLayer]],
)
const it = testEffect(catalogLayer)
@@ -48,7 +48,7 @@ describe("Catalog", () => {
it.effect("derives availability from active credentials without changing provider state", () => {
const integrationID = Integration.ID.make("test")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(locationLayer)]),
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [[Location.node, locationLayer]]),
)
return Effect.gen(function* () {
@@ -78,7 +78,7 @@ describe("Catalog", () => {
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
Location.node.replace(locationLayer),
[Location.node, locationLayer],
]),
)
@@ -108,7 +108,7 @@ describe("Catalog", () => {
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
Location.node.replace(locationLayer),
[Location.node, locationLayer],
]),
)
+1 -1
View File
@@ -35,7 +35,7 @@ describe("CodeMode", () => {
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(Tool.node, [
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
]),
),
),
@@ -85,7 +85,7 @@ describe("CodeModeInstructions", () => {
execute: () => Effect.succeed({ output: "zeta" }),
}
const layer = AppNodeBuilder.build(Tool.node, [
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
])
return Effect.gen(function* () {
+11 -10
View File
@@ -43,10 +43,10 @@ const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Command.node, Bus.node, FSUtil.node, AppProcess.node, Location.node, ShellSelect.node]),
[
Mcp.node.replace(emptyMcpLayer),
Config.node.replace(emptyConfigLayer),
Location.node.replace(testLocationLayer),
ShellSelect.node.replace(shellLayer),
[Mcp.node, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
],
),
)
@@ -340,16 +340,17 @@ describeNative("ConfigCommandPlugin native watcher", () => {
ShellSelect.node,
]),
[
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
ShellSelect.node.replace(shellLayer),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
],
),
),
+4 -3
View File
@@ -40,12 +40,13 @@ const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
llmClient.replace(
[
llmClient,
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
),
Config.node.replace(config),
],
[Config.node, config],
]),
),
)
+12 -11
View File
@@ -55,12 +55,12 @@ function testLayer(
),
)
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Config.node.replace(Config.configured(options)),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })),
Credential.node.replace(credentialNode),
WellKnown.node.replace(wellknownNode),
Watcher.node.replace(watcher),
[Config.node, Config.configured(options)],
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory, home: path.join(globalDirectory, "home") })],
[Credential.node, credentialNode],
[WellKnown.node, wellknownNode],
[Watcher.node, watcher],
])
// Merge the watcher layer by reference so Watcher.Test resolves to the same
// memoized instance the built graph uses.
@@ -311,15 +311,16 @@ describe("Config", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
]),
),
)
+7 -4
View File
@@ -28,13 +28,13 @@ import { testEffect } from "../lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
Global.node.replace(tempGlobalLayer),
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
]),
)
const refreshNpm = makeGlobalNode({
@@ -65,7 +65,10 @@ const refreshNpm = makeGlobalNode({
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
),
)
+7 -6
View File
@@ -86,13 +86,14 @@ const discover = (directory: string, global: string) =>
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
Location.node.replace(
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
Watcher.node.replace(Watcher.testLayer),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
]),
),
)
+2 -2
View File
@@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))),
Global.node.replace(Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })),
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
]),
),
)
@@ -44,9 +44,7 @@ describe("ConfigToolOutputPlugin.Plugin", () => {
}
yield* Effect.die(new Error("Timed out waiting for tool output config reload"))
}).pipe(
Effect.provide(
AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]),
),
Effect.provide(AppNodeBuilder.build(ToolOutput.node, [[Global.node, Global.layerWith({ data: tmp.path })]])),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
@@ -147,20 +147,6 @@ describe("cross-spawn spawner", () => {
})
describe("stderr", () => {
fx.live(
"captures both streams across backpressure",
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("o".repeat(256 * 1024)); process.stderr.write("e".repeat(256 * 1024))',
)
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["o".repeat(256 * 1024), "e".repeat(256 * 1024)])
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"captures stderr output",
Effect.gen(function* () {
@@ -213,30 +199,6 @@ describe("cross-spawn spawner", () => {
)
})
describe("delayed output consumption", () => {
for (const combined of [false, true]) {
fx.live(
`retains ${combined ? "combined" : "separate"} output after process completion`,
Effect.gen(function* () {
const handle = yield* js(
'require("node:fs").writeSync(1, "stdout\\n"); require("node:fs").writeSync(2, "stderr\\n")',
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
if (combined) {
const output = yield* decodeByteStream(handle.all)
expect(output).toContain("stdout")
expect(output).toContain("stderr")
return
}
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["stdout", "stderr"])
}),
)
}
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
@@ -13,218 +13,131 @@ class OtherError {
readonly _tag = "OtherError"
}
// Keep intentionally invalid expressions out of runtime execution.
const contracts = (tag: LayerNode.Tag<"app"> | LayerNode.Tag<"other">, flag: boolean) => {
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({})
}),
)
const a = make({ service: A, layer: aLayer, deps: [] })
const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const ab = make({ name: "a-and-b", layer: Layer.mergeAll(aLayer, Layer.succeed(B, {})), deps: [] })
const failing = make({ service: A, layer: Layer.effect(A, Effect.fail(new LayerError())), deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
const inputA = LayerNode.unbound(A, tags.values.app)
const group = LayerNode.group([a, b])
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>) => LayerNode.compile(root) as Layer.Layer<A, E>
const aLayer = Layer.succeed(A, A.of({}))
const bLayer = Layer.effect(B, Effect.as(A, B.of({})))
const cLayer = Layer.effect(
C,
Effect.gen(function* () {
yield* A
yield* B
return C.of({})
}),
)
const failingA = Layer.effect(A, Effect.fail(new LayerError()))
const a = make({ service: A, layer: aLayer, deps: [] })
const b = make({ service: B, layer: bLayer, deps: [a] })
const c = make({ service: C, layer: cLayer, deps: [a, b] })
const failing = make({ service: A, layer: failingA, deps: [] })
const dependent = make({ service: B, layer: bLayer, deps: [failing] })
const inputA = LayerNode.unbound(A, tags.values.app)
const inputDependent = make({ service: B, layer: bLayer, deps: [inputA] })
make({ name: "manual-a", layer: aLayer, deps: [] })
// @ts-expect-error A node must have a service or name
make({ layer: aLayer, deps: [] })
// @ts-expect-error Service and name are mutually exclusive
make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error An explicit tagged contract requires a corresponding runtime tag
LayerNode.make<typeof aLayer, readonly [], typeof tags.values.app>({ service: A, layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
const erasedLayer: Layer.Any = bLayer
// @ts-expect-error Erasing a Layer's contract cannot hide its inputs and errors
make({ service: B, layer: erasedLayer, deps: [] })
make({ name: "manual-a", layer: aLayer, deps: [] })
LayerNode.compile(c) satisfies Layer.Layer<C, never, never>
LayerNode.compile(dependent) satisfies Layer.Layer<B, LayerError, never>
LayerNode.compile(group) satisfies Layer.Layer<A | B, never, never>
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<never>
// @ts-expect-error An empty graph cannot supply arbitrary services
LayerNode.compile(LayerNode.group([])) satisfies Layer.Layer<A>
LayerNode.compile(inputA, { replacements: [inputA.replace(a)] }) satisfies Layer.Layer<A, never, never>
// @ts-expect-error A is a private dependency, not a root output
LayerNode.compile(c) satisfies Layer.Layer<A | C>
// @ts-expect-error Dependency failures are not erased
LayerNode.compile(dependent) satisfies Layer.Layer<B>
// @ts-expect-error A node must have a service or name
make({ layer: aLayer, deps: [] })
const replacements: LayerNode.Replacements = [a.replace(aLayer), a.replace(ab), failing.replace(a)]
const replacement: LayerNode.Replacement = a.replace(Layer.mergeAll(aLayer, Layer.succeed(B, {})))
LayerNode.compile(a, { replacements: [...replacements, replacement] })
inputA.replace(a)
a.replace(a)
// @ts-expect-error Closed layer replacements must provide every source output
ab.replace(aLayer)
// @ts-expect-error Node replacements must provide every source output
ab.replace(a)
// @ts-expect-error Replacement must provide A
a.replace(Layer.succeed(B, {}))
// @ts-expect-error Node replacement must provide A
a.replace(b)
// @ts-expect-error Raw layers with inputs are not closed
a.replace(Layer.effect(A, Effect.as(B, A.of({}))))
// @ts-expect-error Replacement cannot introduce a new error
a.replace(Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Service and name are mutually exclusive
make({ service: A, name: "a", layer: aLayer, deps: [] })
// @ts-expect-error B requires A
make({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error C requires A and B
make({ service: C, layer: cLayer, deps: [a] })
const closed = build(LayerNode.group([c]))
const closedWithError = build(LayerNode.group([dependent]))
const checkClosed: Layer.Layer<C, never, never> = closed
const checkError: Layer.Layer<B, LayerError, never> = closedWithError
void checkClosed
void checkError
LayerNode.compile(a, [[a, Layer.succeed(A, A.of({}))]])
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.succeed(A, A.of({})), deps: [] })]])
// @ts-expect-error Replacement must provide A
LayerNode.compile(a, [[a, Layer.succeed(B, B.of({}))]])
// @ts-expect-error Node replacement must provide A
const invalidNodeReplacement = () => LayerNode.compile(a, [[a, b]])
void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
const invalidNodeErrorReplacement = () =>
// @ts-expect-error Node replacement cannot introduce a new error
a.replace(failing)
// @ts-expect-error Existing errors do not authorize unrelated replacement errors
failing.replace(Layer.effect(A, Effect.fail(new OtherError())))
// @ts-expect-error Every alternative of a node replacement must supply A
a.replace(flag ? a : b)
// @ts-expect-error Every alternative of a raw-layer replacement must supply A
a.replace(flag ? aLayer : Layer.succeed(B, {}))
// @ts-expect-error A valid alternative cannot hide a new error in another alternative
a.replace(flag ? a : failing)
a.replace(flag ? a : ab)
failing.replace(flag ? a : failing)
// @ts-expect-error Storing replacements must not erase their validation
const invalidStored: LayerNode.Replacements = [a.replace(b)]
// @ts-expect-error Raw tuples cannot be stored as opaque replacements
const rawStored: LayerNode.Replacements = [[a, aLayer]]
// @ts-expect-error Raw tuples cannot be supplied to compile
LayerNode.compile(a, { replacements: [[a, aLayer]] })
// @ts-expect-error Replacements are not structurally forgeable
const forged: LayerNode.Replacement = { source: a, target: a }
// @ts-expect-error Groups are not replaceable nodes
group.replace(a)
// @ts-expect-error Groups cannot be replacement targets
a.replace(group)
// @ts-expect-error Groups cannot be widened to nodes
const groupNode: LayerNode.Node<A | B, never, typeof tags.values.app> = group
// @ts-expect-error Graphs are opaque
const forgedGraph: LayerNode.Graph<A> = { name: "a" }
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
const aContract: LayerNode.Node<A, never, typeof tags.values.app> = a
aContract.replace(aLayer)
// @ts-expect-error A method cannot be rebound to a declaration with a stronger contract
a.replace.call(ab, aLayer)
const detached = a.replace
// @ts-expect-error Replacement authority requires its checked receiver
detached(aLayer)
// @ts-expect-error Output narrowing cannot forget B before replacement
const narrowedOutput: LayerNode.Node<A, never, typeof tags.values.app> = ab
// @ts-expect-error Output widening cannot add B before replacement
const widenedOutput: LayerNode.Node<A | B, never, typeof tags.values.app> = a
// @ts-expect-error Error widening cannot authorize a new replacement error
const widenedError: LayerNode.Node<A, LayerError, typeof tags.values.app> = a
// @ts-expect-error Error narrowing cannot forget an existing failure
const narrowedError: LayerNode.Node<A, never, typeof tags.values.app> = failing
// @ts-expect-error Tag widening cannot authorize replacement across tags
const widenedTag: LayerNode.Node<A, never, LayerNode.Tag | undefined> = a
const unionTag = LayerNode.unbound(A, tag)
// @ts-expect-error Tag narrowing cannot forget a possible tag
const narrowedTag: LayerNode.Node<A, never, typeof tags.values.app> = unionTag
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
class TagC extends Context.Service<TagC, {}>()("test/TagC") {}
const outputProjection: LayerNode.Graph<A, never, typeof tags.values.app> = group
// @ts-expect-error Graph output projection cannot invent a service
const widenedGraph: LayerNode.Graph<A | B, never, typeof tags.values.app> = a
// @ts-expect-error A projected Graph has no replacement authority
outputProjection.replace(aLayer)
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
const request = scopedTags.make("request")
const global = scopedTags.make("global")
const globalA = global({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
const requestA = request({ service: TagA, layer: Layer.succeed(TagA, TagA.of({})), deps: [] })
const requestB = request({ service: TagB, layer: Layer.succeed(TagB, TagB.of({})), deps: [] })
const tagBLayer = Layer.effect(TagB, Effect.as(TagA, TagB.of({})))
const tagCLayer = Layer.effect(
TagC,
Effect.gen(function* () {
yield* TagA
yield* TagB
return TagC.of({})
}),
)
const choice = flag ? a : b
// @ts-expect-error Choosing one dependency does not provide both services
make({ service: C, layer: cLayer, deps: [choice] })
// @ts-expect-error A conditional root promises only outputs present in every alternative
LayerNode.compile(LayerNode.group([choice])) satisfies Layer.Layer<A | B>
const conditional = make({ name: "conditional", layer: flag ? aLayer : Layer.succeed(B, {}), deps: [] })
LayerNode.compile(conditional) satisfies Layer.Layer<never>
// @ts-expect-error A conditional implementation does not acquire both branches
LayerNode.compile(conditional) satisfies Layer.Layer<A | B>
LayerNode.compile(LayerNode.group([flag ? a : ab])) satisfies Layer.Layer<A>
const dynamic: Array<typeof a> = []
// @ts-expect-error An unbounded array may contain no roots
LayerNode.compile(LayerNode.group(dynamic)) satisfies Layer.Layer<A>
request({ service: TagB, layer: tagBLayer, deps: [globalA] })
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestB] })
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA, requestB])] })
const decorated = b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.void)))
LayerNode.compile(decorated) satisfies Layer.Layer<B>
b.replace(decorated)
// @ts-expect-error A layer mapper cannot be rebound to a weaker declaration
ab.mapLayer.call(a, (layer) => layer)
// @ts-expect-error mapLayer cannot add an input requirement
b.mapLayer((layer) => layer.pipe(Layer.tap(() => C)))
// @ts-expect-error mapLayer cannot grow the error channel
b.mapLayer((layer) => layer.pipe(Layer.tap(() => Effect.fail(new OtherError()))))
// @ts-expect-error mapLayer cannot drop an output
ab.mapLayer(() => aLayer)
// @ts-expect-error Unbound declarations have no implementation to map
inputA.mapLayer((layer: Layer.Layer<A>) => layer)
// @ts-expect-error Tag configuration can only reference declared tags
LayerNode.tags({ request: ["missing"], global: [] })
const scopedTags = LayerNode.tags({ request: ["global"], global: [] })
const request = scopedTags.make("request")
const global = scopedTags.make("global")
const globalA = global({ service: A, layer: aLayer, deps: [] })
const requestA = request({ service: A, layer: aLayer, deps: [] })
const requestB = request({ service: B, layer: Layer.succeed(B, {}), deps: [] })
request({ service: B, layer: bLayer, deps: [globalA] })
request({ service: C, layer: cLayer, deps: [globalA, requestB] })
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA, requestB])] })
LayerNode.compile(LayerNode.group([globalA, requestB]), { shared: scopedTags.values.global }) satisfies Layer.Layer<
A | B
>
// @ts-expect-error Tag configuration can only reference declared tags
LayerNode.tags({ request: ["missing"], global: [] })
// @ts-expect-error Shared tags must be branded
LayerNode.compile(globalA, { shared: "global" })
// @ts-expect-error Replacement targets must keep the source tag
globalA.replace(requestA)
// @ts-expect-error Replacement targets must keep the source tag in either direction
requestA.replace(globalA)
// @ts-expect-error Every alternative must keep the source tag
globalA.replace(flag ? globalA : requestA)
// @ts-expect-error Providing only A leaves B missing
request({ service: C, layer: cLayer, deps: [globalA] })
// @ts-expect-error Providing only B leaves A missing
request({ service: C, layer: cLayer, deps: [requestB] })
// @ts-expect-error Duplicate A providers still leave B missing
request({ service: C, layer: cLayer, deps: [globalA, requestA] })
// @ts-expect-error A group with only A still leaves B missing
request({ service: C, layer: cLayer, deps: [LayerNode.group([globalA])] })
// @ts-expect-error Global cannot depend on request
global({ service: B, layer: bLayer, deps: [requestA] })
// @ts-expect-error Groups preserve their child tags
global({ service: B, layer: bLayer, deps: [LayerNode.group([requestA])] })
// @ts-expect-error An unrelated dependency cannot satisfy TagA
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
const globalScopedA = makeGlobalNode({ service: A, layer: aLayer, deps: [] })
const locationScopedA = makeLocationNode({ service: A, layer: aLayer, deps: [] })
makeGlobalNode({ service: B, layer: bLayer, deps: [globalScopedA] })
makeLocationNode({ service: B, layer: bLayer, deps: [globalScopedA] })
makeLocationNode({ service: B, layer: bLayer, deps: [locationScopedA] })
// @ts-expect-error Global nodes cannot depend on location nodes
makeGlobalNode({ service: B, layer: bLayer, deps: [locationScopedA] })
// @ts-expect-error B requires A
makeLocationNode({ service: B, layer: bLayer, deps: [] })
// @ts-expect-error Providing only TagA leaves TagB missing
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
void [
invalidStored,
rawStored,
forged,
groupNode,
forgedGraph,
narrowedOutput,
widenedOutput,
widenedError,
narrowedError,
widenedTag,
narrowedTag,
widenedGraph,
]
}
// @ts-expect-error Providing only TagB leaves TagA missing
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
test("layer node type contracts compile", () => {
void contracts
})
// @ts-expect-error Duplicate TagA providers still leave TagB missing
request({ service: TagC, layer: tagCLayer, deps: [globalA, requestA] })
// @ts-expect-error A group with only TagA still leaves TagB missing
request({ service: TagC, layer: tagCLayer, deps: [LayerNode.group([globalA])] })
// @ts-expect-error Global cannot depend on request
global({ service: TagB, layer: tagBLayer, deps: [requestA] })
// @ts-expect-error Groups preserve their child tags
global({ service: TagB, layer: tagBLayer, deps: [LayerNode.group([requestA])] })
class ScopedA extends Context.Service<ScopedA, {}>()("test/ScopedA") {}
class ScopedB extends Context.Service<ScopedB, {}>()("test/ScopedB") {}
const scopedA = Layer.succeed(ScopedA, ScopedA.of({}))
const scopedB = Layer.effect(ScopedB, Effect.as(ScopedA, ScopedB.of({})))
const globalScopedA = makeGlobalNode({ service: ScopedA, layer: scopedA, deps: [] })
const locationScopedA = makeLocationNode({ service: ScopedA, layer: scopedA, deps: [] })
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [globalScopedA] })
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
// @ts-expect-error Global nodes cannot depend on location nodes
makeGlobalNode({ service: ScopedB, layer: scopedB, deps: [locationScopedA] })
// @ts-expect-error ScopedB requires ScopedA
makeLocationNode({ service: ScopedB, layer: scopedB, deps: [] })
test("type exploration compiles", () => {})
@@ -1,21 +1,19 @@
import { describe, expect, test } from "bun:test"
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } from "effect"
import { Context, Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { testEffect } from "../../lib/effect"
class Value extends Context.Service<Value, { readonly value: string }>()("test/LayerNodeValue") {}
class Greeting extends Context.Service<Greeting, { readonly value: string }>()("test/LayerNodeGreeting") {}
class Left extends Context.Service<Left, { readonly value: string }>()("test/LayerNodeLeft") {}
class Right extends Context.Service<Right, { readonly value: string }>()("test/LayerNodeRight") {}
class Memo extends Context.Service<Memo, Layer.MemoMap>()("test/LayerNodeMemo") {}
class Support extends Context.Service<Support, {}>()("test/LayerNodeSupport") {}
class Locations extends Context.Service<Locations, LayerMap.LayerMap<string, Value | Right, "failed location">>()(
"test/LayerNodeLocations",
) {}
class Database extends Context.Service<Database, { readonly name: string }>()("test/GraphDatabase") {}
class Users extends Context.Service<Users, { readonly list: Effect.Effect<string[]> }>()("test/GraphUsers") {}
class App extends Context.Service<App, { readonly run: Effect.Effect<string[]> }>()("test/GraphApp") {}
const it = testEffect(Layer.empty)
const tags = LayerNode.tags({ app: [] })
const make = tags.make("app")
const build = <A, E>(root: LayerNode.Node<A, E, any>, replacements?: readonly LayerNode.Replacement[]) =>
LayerNode.compile(root, replacements) as Layer.Layer<A, E>
const valueLayer = Layer.succeed(Value, Value.of({ value: "production" }))
const greetingLayer = Layer.effect(
Greeting,
@@ -25,443 +23,240 @@ const value = make({ service: Value, layer: valueLayer, deps: [] })
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
describe("layer node", () => {
it.effect("builds an untagged graph", () =>
Effect.gen(function* () {
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
const result = yield* Greeting.pipe(Effect.provide(LayerNode.compile(LayerNode.group([greeting]))))
expect(result.value).toBe("hello production")
}),
)
it.effect("exposes roots but hides transitive dependencies", () =>
Effect.gen(function* () {
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting])))
expect(Context.get(context, Greeting).value).toBe("hello production")
expect(Option.isNone(Context.getOption(context, Value))).toBe(true)
}),
)
it.effect("replaces exact declarations, not sibling names or native layer identities", () =>
Effect.gen(function* () {
const sibling = make({ service: Value, layer: valueLayer, deps: [] })
const target = make({ name: "different-name", layer: Layer.succeed(Value, { value: "replaced" }), deps: [] })
const left = make({
service: Left,
layer: Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
),
deps: [value],
})
const right = make({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
),
deps: [sibling],
})
const context = yield* Layer.build(
LayerNode.compile(LayerNode.group([left, right]), { replacements: [value.replace(target)] }),
)
expect(Context.get(context, Left).value).toBe("replaced")
expect(Context.get(context, Right).value).toBe("production")
}),
)
it.effect("requires reachable unbound nodes to be replaced", () =>
Effect.gen(function* () {
const unbound = LayerNode.unbound(Value, tags.values.app)
const root = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
expect(() => LayerNode.compile(root)).toThrow("Unbound layer node: test/LayerNodeValue")
const result = yield* Greeting.pipe(
Effect.provide(LayerNode.compile(root, { replacements: [unbound.replace(value)] })),
)
expect(result.value).toBe("hello production")
}),
)
it.effect("replaces every use of a declaration with a stored closed-layer replacement", () =>
Effect.gen(function* () {
const replacements: LayerNode.Replacements = [value.replace(Layer.succeed(Value, { value: "replacement" }))]
const right = make({
service: Right,
layer: Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
),
deps: [value],
})
const context = yield* Layer.build(LayerNode.compile(LayerNode.group([greeting, right]), { replacements }))
expect(Context.get(context, Greeting).value).toBe("hello replacement")
expect(Context.get(context, Right).value).toBe("replacement")
}),
)
it.effect("uses the last replacement and ignores unreachable unbound defaults and cycles", () =>
Effect.gen(function* () {
const unbound = LayerNode.unbound(Value, tags.values.app)
const unused = make({ service: Value, layer: valueLayer, deps: [] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [
value.replace(unbound),
unbound.replace(unused),
unused.replace(unbound),
value.replace(Layer.succeed(Value, { value: "last" })),
],
}),
),
)
expect(result.value).toBe("hello last")
}),
)
it.effect("resolves target chains independently of replacement order and treats self-replacement as identity", () =>
Effect.gen(function* () {
const middle = make({ service: Value, layer: Layer.succeed(Value, { value: "middle" }), deps: [] })
const target = make({ service: Value, layer: Layer.succeed(Value, { value: "target" }), deps: [] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [target.replace(target), middle.replace(target), value.replace(middle)],
}),
),
)
expect(result.value).toBe("hello target")
}),
)
test("rejects reachable replacement and dependency cycles", () => {
const other = make({ service: Value, layer: valueLayer, deps: [] })
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(other), other.replace(value)] })).toThrow(
"Cycle detected in layer graph",
test("builds an untagged graph", async () => {
const value = LayerNode.make({ service: Value, layer: valueLayer, deps: [] })
const greeting = LayerNode.make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(LayerNode.compile(LayerNode.group([greeting]))),
)
const dependent = make({
service: Value,
layer: Layer.effect(
Value,
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("builds a dependency graph", async () => {
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(build(LayerNode.group([greeting]))))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("exposes roots but hides transitive dependencies", () => {
const layer = build(LayerNode.group([greeting]))
const check: Layer.Layer<Greeting> = layer
void check
})
test("preserves branch-specific implementations across roots", async () => {
const firstValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "first" })), deps: [] })
const secondValue = make({ service: Value, layer: Layer.succeed(Value, Value.of({ value: "second" })), deps: [] })
const leftLayer = Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
)
const rightLayer = Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
)
const left = make({ service: Left, layer: leftLayer, deps: [firstValue] })
const right = make({ service: Right, layer: rightLayer, deps: [secondValue] })
const layer = build(LayerNode.group([left, right]))
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["first", "second"])
})
test("requires unbound nodes to be replaced before compilation", async () => {
const unbound = LayerNode.unbound(Value, tags.values.app)
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [unbound] })
const tree = LayerNode.group([greeting])
expect(() => LayerNode.compile(tree)).toThrow("Unbound layer node: test/LayerNodeValue")
const layer = LayerNode.compile(tree, [[unbound, value]]) as Layer.Layer<Greeting>
const program = Effect.map(Greeting, (item) => item.value).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toBe("hello production")
})
test("replaces a node with a closed layer", async () => {
const replacement = Layer.succeed(Value, Value.of({ value: "simulation" }))
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [[value, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("hello simulation")
})
test("replaces every use of the same layer", async () => {
const leftLayer = Layer.effect(
Left,
Effect.map(Value, (item) => Left.of({ value: item.value })),
)
const rightLayer = Layer.effect(
Right,
Effect.map(Value, (item) => Right.of({ value: item.value })),
)
const left = make({ service: Left, layer: leftLayer, deps: [value] })
const right = make({ service: Right, layer: rightLayer, deps: [value] })
const replacement = Layer.succeed(Value, Value.of({ value: "replaced" }))
const layer = build(LayerNode.group([left, right]), [[value, replacement]])
const program = Effect.gen(function* () {
return [(yield* Left).value, (yield* Right).value]
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["replaced", "replaced"])
})
test("does not acquire an unused replacement", async () => {
let acquisitions = 0
const other = make({ service: Left, layer: Layer.succeed(Left, Left.of({ value: "other" })), deps: [] })
const replacement = Layer.effect(
Left,
Effect.sync(() => {
acquisitions++
return Left.of({ value: "replacement" })
}),
)
await Effect.runPromise(
Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([greeting]), [[other, replacement]])),
),
deps: [greeting],
)
expect(acquisitions).toBe(0)
})
test("replaces a node without acquiring its dependencies", async () => {
let acquisitions = 0
const dependencyLayer = Layer.effect(
Value,
Effect.sync(() => {
acquisitions++
return Value.of({ value: "dependency" })
}),
)
const dependency = make({ service: Value, layer: dependencyLayer, deps: [] })
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const replacement = make({
service: Greeting,
layer: Layer.succeed(Greeting, Greeting.of({ value: "replacement" })),
deps: [],
})
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
"Cycle detected in layer graph",
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(build(LayerNode.group([original]), [[original, replacement]])),
)
expect(await Effect.runPromise(program)).toBe("replacement")
expect(acquisitions).toBe(0)
})
test("applies later replacements inside earlier replacement nodes", async () => {
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
const program = Effect.map(Greeting, (item) => item.value).pipe(
Effect.provide(
build(LayerNode.group([original]), [
[original, replacement],
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
]),
),
)
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
})
test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const database = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
deps: [],
})
const users = location({
service: Users,
layer: Layer.effect(
Users,
Effect.gen(function* () {
const db = yield* Database
return Users.of({ list: Effect.succeed([db.name]) })
}),
),
deps: [database],
})
const app = location({
service: App,
layer: Layer.effect(
App,
Effect.gen(function* () {
const service = yield* Users
return App.of({ run: service.list })
}),
),
deps: [users],
})
const result = LayerNode.hoist(LayerNode.group([app]), tags.values.global)
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
kind: "group",
dependencies: [],
})
expect(result.hoisted.dependencies).toEqual([database])
const layer = LayerNode.compile(result.node).pipe(
Layer.provide(LayerNode.compile(result.hoisted)),
) as unknown as Layer.Layer<App>
const program = Effect.gen(function* () {
const app = yield* App
return yield* app.run
}).pipe(Effect.provide(layer))
expect(await Effect.runPromise(program)).toEqual(["Alice"])
})
test("rejects conflicting hoisted implementations", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const first = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "first" })),
deps: [],
})
const second = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "second" })),
deps: [],
})
const left = location({
service: Users,
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
deps: [first],
})
const right = location({
service: App,
layer: Layer.effect(App, Effect.as(Database, App.of({ run: Effect.succeed([]) }))),
deps: [second],
})
expect(() => LayerNode.hoist(LayerNode.group([left, right]), tags.values.global)).toThrow(
"Tag global has conflicting implementations for test/GraphDatabase",
)
})
it.effect("does not acquire replaced dependencies or unused replacement targets", () =>
Effect.gen(function* () {
const acquired: string[] = []
const dependency = make({
service: Value,
layer: Layer.effect(
Value,
Effect.sync(() => {
acquired.push("old dependency")
return Value.of({ value: "dependency" })
}),
),
deps: [],
})
const original = make({ service: Greeting, layer: greetingLayer, deps: [dependency] })
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(original, {
replacements: [
original.replace(Layer.succeed(Greeting, { value: "replacement" })),
value.replace(
Layer.effect(
Value,
Effect.sync(() => {
acquired.push("unused target")
return Value.of({ value: "unused" })
}),
),
),
],
}),
),
)
expect(result.value).toBe("replacement")
expect(acquired).toEqual([])
}),
)
test("treats dependency groups as transparent while hoisting", () => {
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const database = global({
service: Database,
layer: Layer.succeed(Database, Database.of({ name: "Alice" })),
deps: [],
})
const users = location({
service: Users,
layer: Layer.effect(Users, Effect.as(Database, Users.of({ list: Effect.succeed([]) }))),
deps: [LayerNode.group([database])],
})
const result = LayerNode.hoist(LayerNode.group([users]), tags.values.global)
it.effect("mapLayer preserves dependency wiring and replacement traversal", () =>
Effect.gen(function* () {
const acquired: string[] = []
const decorated = greeting.mapLayer((layer) =>
layer.pipe(
Layer.tap((context) =>
Effect.sync(() => {
acquired.push(Context.get(context, Greeting).value)
}),
),
),
)
const result = yield* Greeting.pipe(
Effect.provide(
LayerNode.compile(greeting, {
replacements: [
greeting.replace(decorated),
value.replace(Layer.succeed(Value, { value: "mapped dependency" })),
],
}),
),
)
expect(result.value).toBe("hello mapped dependency")
expect(acquired).toEqual(["hello mapped dependency"])
}),
)
it.effect("memoizes shared wiring instead of expanding a diamond into a tree", () =>
Effect.gen(function* () {
const acquisitions: string[] = []
const shared = value.mapLayer((layer) =>
layer.pipe(Layer.tap(() => Effect.sync(() => acquisitions.push("shared")))),
)
const left = make({ name: "left", layer: Layer.empty, deps: [shared] })
const right = make({ name: "right", layer: Layer.empty, deps: [shared] })
yield* Layer.build(LayerNode.compile(LayerNode.group([left, right])))
expect(acquisitions).toEqual(["shared"])
}),
)
it.effect("preserves declared memo-service outputs rather than filtering them as build metadata", () =>
Effect.gen(function* () {
const supplied = yield* Layer.makeMemoMap
const memo = make({
service: Layer.CurrentMemoMap,
layer: Layer.succeed(Layer.CurrentMemoMap, supplied),
deps: [],
})
const observer = make({ service: Memo, layer: Layer.effect(Memo, Layer.CurrentMemoMap), deps: [memo] })
expect(yield* Memo.pipe(Effect.provide(LayerNode.compile(observer)))).toBe(supplied)
}),
)
it.effect("rejects one implementation wired to different effective dependencies in either memo domain", () =>
Effect.gen(function* () {
const other = make({ service: Value, layer: Layer.succeed(Value, { value: "other" }), deps: [] })
const sibling = make({ service: Greeting, layer: greetingLayer, deps: [other] })
const root = LayerNode.group([greeting, sibling])
expect(() => LayerNode.compile(root)).toThrow("wired to different dependencies")
expect(() => LayerNode.compile(root, { shared: tags.values.app })).toThrow("wired to different dependencies")
const result = yield* Greeting.pipe(
Effect.provide(LayerNode.compile(root, { replacements: [value.replace(other)] })),
)
expect(result.value).toBe("hello other")
}),
)
it.effect("starts dependencies in parallel and nested group roots in order", () =>
Effect.gen(function* () {
const valueStarted = yield* Deferred.make<void>()
const greetingStarted = yield* Deferred.make<void>()
const firstStarted = yield* Deferred.make<void>()
const releaseFirst = yield* Deferred.make<void>()
const events: string[] = []
const value = make({
service: Value,
layer: Layer.effect(
Value,
Effect.gen(function* () {
yield* Deferred.succeed(valueStarted, undefined)
yield* Deferred.await(greetingStarted)
return Value.of({ value: "value" })
}),
),
deps: [],
})
const greeting = make({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.gen(function* () {
yield* Deferred.succeed(greetingStarted, undefined)
yield* Deferred.await(valueStarted)
return Greeting.of({ value: "greeting" })
}),
),
deps: [],
})
const first = make({
service: Left,
layer: Layer.effect(
Left,
Effect.gen(function* () {
yield* Value
yield* Greeting
events.push("first started")
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(releaseFirst)
events.push("first finished")
return Left.of({ value: "first" })
}),
),
deps: [value, greeting],
})
const second = make({
service: Right,
layer: Layer.effect(
Right,
Effect.sync(() => {
expect(events).toEqual(["first started", "first finished"])
events.push("second started")
return Right.of({ value: "second" })
}),
),
deps: [],
})
const fiber = yield* Layer.build(LayerNode.compile(LayerNode.group([LayerNode.group([first]), second]))).pipe(
Effect.forkChild,
)
yield* Deferred.await(firstStarted)
expect(events).toEqual(["first started"])
yield* Deferred.succeed(releaseFirst, undefined)
const context = yield* Fiber.join(fiber)
expect(events).toEqual(["first started", "first finished", "second started"])
expect(Context.get(context, Left).value).toBe("first")
expect(Context.get(context, Right).value).toBe("second")
}),
)
;[false, true].forEach((topLevel) => {
it.effect(
`LayerMap isolates builds and retains resources ${topLevel ? "with" : "without"} a top-level global owner`,
() =>
Effect.gen(function* () {
const acquired = { global: 0, local: 0, support: 0 }
const released: string[] = []
const startup: string[] = []
yield* Effect.gen(function* () {
const memoMap = yield* Layer.makeMemoMap
const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global")
const location = tags.make("location")
const support = LayerNode.make({
service: Support,
layer: Layer.effect(
Support,
Effect.acquireRelease(
Effect.sync(() => {
acquired.support++
return Support.of({})
}),
() =>
Effect.sync(() => {
released.push("support")
}),
),
),
deps: [],
})
const value = global({
service: Value,
layer: Layer.effect(
Value,
Effect.andThen(
Support,
Effect.acquireRelease(
Effect.sync(() => {
startup.push("global")
return Value.of({ value: `global-${++acquired.global}` })
}),
(value) =>
Effect.sync(() => {
released.push(value.value)
}),
),
),
),
deps: [support],
})
const local = location({
service: Greeting,
layer: Layer.effect(
Greeting,
Effect.gen(function* () {
yield* Value
return yield* Effect.acquireRelease(
Effect.sync(() => Greeting.of({ value: `local-${++acquired.local}` })),
(value) =>
Effect.sync(() => {
released.push(value.value)
}),
)
}),
),
deps: [LayerNode.group([value])],
})
const root = location({
service: Right,
layer: Layer.effect(
Right,
Effect.gen(function* () {
const local = yield* Greeting
if (local.value === "local-2") return yield* Effect.fail("failed location" as const)
return Right.of(local)
}),
),
deps: [local],
})
// Every key builds the same compiled Layer, not a new graph per lookup.
const compiled = LayerNode.compile(LayerNode.group([value, root]), { shared: tags.values.global })
const locations = location({
service: Locations,
layer: Layer.effect(
Locations,
Effect.gen(function* () {
startup.push("map")
expect(Option.getOrUndefined(yield* Effect.serviceOption(Layer.CurrentMemoMap))).toBe(memoMap)
return yield* LayerMap.make((_: string) => compiled, { idleTimeToLive: Duration.infinity })
}),
),
deps: [],
})
const scope = yield* Effect.scope
const context = yield* Layer.buildWithMemoMap(
LayerNode.compile(LayerNode.group([locations, ...(topLevel ? [value] : [])]), {
shared: tags.values.global,
}),
memoMap,
scope,
)
expect(startup).toEqual(topLevel ? ["map", "global"] : ["map"])
const map = Context.get(context, Locations)
const first = yield* map.contextEffect("first").pipe(Effect.scoped)
expect(Option.getOrUndefined(Context.getOption(context, Value))).toBe(
topLevel ? Context.get(first, Value) : undefined,
)
expect(Option.isNone(Context.getOption(first, Greeting))).toBe(true)
expect(Context.get(first, Right).value).toBe("local-1")
expect(yield* map.contextEffect("failed").pipe(Effect.scoped, Effect.flip)).toBe("failed location")
expect(released).toEqual(["local-2"])
expect(Context.get(yield* map.contextEffect("first").pipe(Effect.scoped), Right)).toBe(
Context.get(first, Right),
)
const second = yield* map.contextEffect("second").pipe(Effect.scoped)
expect(Context.get(second, Value)).toBe(Context.get(first, Value))
expect(Context.get(second, Right)).not.toBe(Context.get(first, Right))
expect(acquired).toEqual({ global: 1, local: 3, support: 1 })
yield* map.invalidate("first")
expect(released).toEqual(["local-2", "local-1"])
expect(Context.get(yield* map.contextEffect("second").pipe(Effect.scoped), Right)).toBe(
Context.get(second, Right),
)
const rebuilt = yield* map.contextEffect("first").pipe(Effect.scoped)
expect(Context.get(rebuilt, Right).value).toBe("local-4")
expect(Context.get(rebuilt, Value)).toBe(Context.get(first, Value))
expect(acquired).toEqual({ global: 1, local: 4, support: 1 })
expect(released).not.toContain("global-1")
}).pipe(Effect.scoped)
expect(released.toSorted()).toEqual(["global-1", "local-1", "local-2", "local-3", "local-4", "support"])
}),
)
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
kind: "group",
dependencies: [],
})
})
})
@@ -1,23 +1,20 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer, Option } from "effect"
import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { Node } from "@opencode-ai/util/effect/app-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { buildLocationServiceMap } from "@opencode-ai/core/location-services"
import type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "../../fixture/tmpdir"
import { testEffect } from "../../lib/effect"
class Value extends Context.Service<Value, { readonly value: string }>()("test/TagValue") {}
class Result extends Context.Service<Result, { readonly value: string }>()("test/TagResult") {}
class CycleA extends Context.Service<CycleA, {}>()("test/NodeBuildA") {}
class CycleB extends Context.Service<CycleB, { readonly directory: AbsolutePath }>()("test/NodeBuildB") {}
const it = testEffect(Layer.empty)
describe("node build", () => {
test("does not build a location service map when the graph does not require it", async () => {
const result = Node.makeGlobalNode({
@@ -34,7 +31,7 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("detects cycles through a replaced location service map", () => {
test("detects cycles through a replaced location service map", async () => {
const a = Node.makeGlobalNode({
service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@@ -48,49 +45,31 @@ describe("node build", () => {
),
deps: [a],
})
const mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap()))
const mapLayer = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const service = yield* CycleB
return yield* LayerMap.make(
(ref: Location.Ref) =>
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: service.directory, canonical: service.directory },
}),
),
{ idleTimeToLive: "1 minute" },
)
}) as unknown as Effect.Effect<LayerMap.LayerMap<Location.Ref, LocationServices, LocationError>, never, CycleB>,
)
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow(
"Cycle detected in layer graph",
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
"Cycle detected in layer tree",
)
})
it.effect("supplies the lazy map when only a replacement introduces the dependency", () =>
Effect.gen(function* () {
const original = Node.makeGlobalNode({
service: Result,
layer: Layer.succeed(Result, { value: "original" }),
deps: [],
})
const replacement = Node.makeGlobalNode({
service: Result,
layer: Layer.effect(Result, Effect.as(LocationServiceMap.Service, Result.of({ value: "has map" }))),
deps: [LocationServiceMap.node],
})
const result = yield* Result.pipe(Effect.provide(AppNodeBuilder.build(original, [original.replace(replacement)])))
expect(result.value).toBe("has map")
}),
)
it.effect("caller replacements override the lazy default without building any locations", () =>
Effect.gen(function* () {
const acquisitions: string[] = []
const override = buildLocationServiceMap().pipe(
Layer.tap(() =>
Effect.sync(() => {
acquisitions.push("caller map")
}),
),
)
const context = yield* Layer.build(
AppNodeBuilder.build(LocationServiceMap.node, [LocationServiceMap.node.replace(override)]),
)
expect(Context.get(context, LocationServiceMap.Service)).toBeDefined()
expect(acquisitions).toEqual(["caller map"])
}),
)
test("shares top-level project even when the location service map is built first", async () => {
test("shares top-level project with location services", async () => {
await using tmp = await tmpdir()
let acquisitions = 0
const projectLayer = Layer.effect(
@@ -105,8 +84,8 @@ describe("node build", () => {
}),
)
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [
Project.node.replace(projectLayer),
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
[Project.node, projectLayer],
])
const program = Effect.gen(function* () {
yield* Project.Service
+2 -2
View File
@@ -21,8 +21,8 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
)
return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
Location.node.replace(activeLocation),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
[Location.node, activeLocation],
[Environment.node, transformEnvironmentFiles(transformFiles)],
]),
)
}
+20 -14
View File
@@ -77,15 +77,16 @@ describe("FileSystemSearch", () => {
workspaceID: Workspace.ID.make("wrk_test"),
})
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
),
),
),
Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))),
],
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
@@ -102,7 +103,8 @@ describe("FileSystemSearch", () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
@@ -112,8 +114,8 @@ describe("FileSystemSearch", () => {
),
),
),
),
Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
@@ -135,15 +137,17 @@ describe("FileSystemSearch", () => {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
),
Ripgrep.node.replace(
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -165,7 +169,7 @@ describe("FileSystemSearch", () => {
grep: () => Effect.succeed([]),
}),
),
),
],
])
yield* Effect.gen(function* () {
@@ -204,15 +208,17 @@ describe("FileSystemSearch", () => {
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
Location.node.replace(
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
),
Ripgrep.node.replace(
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -228,7 +234,7 @@ describe("FileSystemSearch", () => {
grep: () => Effect.succeed([]),
}),
),
),
],
])
yield* Effect.gen(function* () {
@@ -6,7 +6,7 @@ import { Deferred, Duration, Effect, Fiber, Layer, Option, Schedule, Stream } fr
import { Config } from "@opencode-ai/core/config"
import { ConfigLocationWatcherPlugin } from "@opencode-ai/core/config/plugin/location-watcher"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { makeLocationNode, type LocationNode } from "@opencode-ai/util/effect/app-node"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -129,7 +129,7 @@ function provide(
vcs?: Location.Interface["vcs"],
watcher?: Layer.Layer<Watcher.Service>,
config: Layer.Layer<Config.Service> = configLayer,
plugins: typeof pluginNode = pluginNode,
plugins: LocationNode<PluginSupervisor.Service> = pluginNode,
) {
const locationLayer = Layer.succeed(
Location.Service,
@@ -138,10 +138,10 @@ function provide(
const built = AppNodeBuilder.build(
LayerNode.group([LocationWatcher.node, LocationWatcherPolicy.node, Bus.node, Config.node]),
[
Config.node.replace(config),
Location.node.replace(locationLayer),
PluginSupervisor.node.replace(plugins),
...(watcher ? ([Watcher.node.replace(watcher)] as const) : []),
[Config.node, config],
[Location.node, locationLayer],
[PluginSupervisor.node, plugins],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
],
)
return Effect.provide(built)
@@ -154,7 +154,7 @@ function withTmp<A, E, R>(
init?: (directory: string) => Promise<void>
watcher?: Layer.Layer<Watcher.Service>
config?: Layer.Layer<Config.Service>
plugins?: typeof pluginNode
plugins?: LocationNode<PluginSupervisor.Service>
},
) {
return Effect.acquireRelease(
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(testGlobal)] })
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
@@ -26,9 +26,9 @@ export const promptLocationNode = makeGlobalNode({
SessionPrompt.layer.pipe(
Layer.provideMerge(
Layer.mergeAll(
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), {
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
}),
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), [
[Bus.node, Layer.succeed(Bus.Service, bus)],
]),
Layer.succeed(FSUtil.Service, fs),
Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }),
Layer.mock(Reference.Service, { refresh: () => Effect.void }),
+1 -1
View File
@@ -20,7 +20,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
type ConfigInput = typeof Info.Encoded
+3 -3
View File
@@ -34,7 +34,7 @@ const instances = Layer.effect(
(ref: Location.Ref) =>
Instance.layer(ref, {
plugins: path.basename(ref.directory) === "thread-a" ? [agentPlugin("thread-a-plugin", "thread-a-agent")] : [],
replacements: [Global.node.replace(tempGlobalLayer)],
replacements: [[Global.node, tempGlobalLayer]],
}),
{ idleTimeToLive: Duration.infinity },
),
@@ -42,8 +42,8 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
+6 -5
View File
@@ -23,13 +23,14 @@ import { Bus } from "../src/bus"
// Config the host hands the vanilla instance explicitly: a value and an
// explicit plugin removal, both of which must survive discovery: false.
const hostConfig: LayerNode.Replacements = [
Config.node.replace(
[
Config.node,
Config.configured({
project: false,
global: false,
content: JSON.stringify({ shell: "vanilla-host", plugins: ["-opencode.tool.shell"] }),
}),
),
],
]
// Same directory contents, two instances: one vanilla, one with discovery.
@@ -42,7 +43,7 @@ const instances = Layer.effect(
// "bare" exercises the vanilla defaults themselves: no caller Config.
discovery: name !== "vanilla" && name !== "bare",
// Caller replacements win over the vanilla defaults.
replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])],
replacements: [[Global.node, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
})
},
{ idleTimeToLive: Duration.infinity },
@@ -51,8 +52,8 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
]),
)
@@ -33,18 +33,19 @@ const instructionLayer = (input: {
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
Global.node.replace(
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
),
Location.node.replace(input.locationServiceLayer),
Watcher.node.replace(watcher),
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
],
),
watcher,
+1 -1
View File
@@ -24,7 +24,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
[Bus.node, Bus.configured({ persist: true })],
]),
)
@@ -30,8 +30,8 @@ const locationLayer = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ config: temporary, tmp: temporary })),
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
]),
)
+1 -1
View File
@@ -27,7 +27,7 @@ const failingCredentialNode = makeGlobalNode({
deps: [],
})
const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
)
function eventually<A, E, R>(
@@ -13,16 +13,15 @@ import { it } from "./lib/effect"
const provide = (directory: string, workspaceID?: Workspace.ID) =>
Effect.provide(
LayerNode.compile(FileSystem.node, {
replacements: [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
),
],
}),
]),
)
const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+3 -3
View File
@@ -51,12 +51,12 @@ import { Tool } from "../src/tool"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
Global.node.replace(tempGlobalLayer),
[Global.node, tempGlobalLayer],
]),
)
const activityLocations = Layer.effect(
@@ -77,7 +77,7 @@ const activityLocations = Layer.effect(
)
const itWithActivity = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]), [
LocationServiceMap.node.replace(activityLocations),
[LocationServiceMap.node, activityLocations],
]),
)
+10 -11
View File
@@ -13,21 +13,20 @@ import { it } from "./lib/effect"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, {
replacements: [
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
),
],
}),
]),
)
}
+1 -1
View File
@@ -23,7 +23,7 @@ const projectLayer = Layer.succeed(
}),
}),
)
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)]))
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]]))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>

Some files were not shown because too many files have changed in this diff Show More