Compare commits

...
39 Commits
Author SHA1 Message Date
Kit Langton caf7194c09 chore(core): refresh queued control base 2026-08-31 20:15:38 -04:00
Kit Langton 6dd1733bbf fix(core): preserve continuation across chained moves
Carry unfinished model work across consecutive Location handoffs without resetting the logical step allowance. Keep idle moves and queued prompt admission unchanged. Cover steered and queued second moves, preserved tool history, and durable event ordering.
2026-08-31 20:12:47 -04:00
opencode-agent[bot]andBrendonovich 663c2dc1ce fix(app): raise composer only in dark mode (#46503)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 08:12:09 +08:00
Kit Langton 01eda4c178 refactor(codemode): name only supported operations (#46082) 2026-08-31 20:00:13 -04:00
Kit Langton a6b49b3f74 fix(shell): preserve output from fast-exiting commands
Capture child stdout and stderr eagerly before lazy Effect readers attach. Preserve the bounded post-exit drain and process cleanup policies, with delayed-consumption and backpressure regressions.
2026-08-31 19:52:08 -04:00
opencode-agent[bot]andrekram1-node 5b2276666f test(tui): stop Windows image preview test crashes (#46479)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 18:31:47 -05:00
Kit Langton cc0cc59700 fix(ai): preserve done-only response messages (#46064) 2026-08-31 19:24:33 -04:00
Kit Langton 57a9decefe refactor(codemode): simplify input conflict detection (#46465) 2026-08-31 19:24:07 -04:00
Kit Langton c0220ddd8b refactor(codemode): reject unresolved intersections before rendering (#46468) 2026-08-31 19:23:52 -04:00
Kit Langton b31defc0a5 refactor(util): slice the final filename segment (#46466) 2026-08-31 19:23:35 -04:00
Kit Langton e7d42f83e6 refactor(core): slice model references at the first slash (#46467) 2026-08-31 19:23:21 -04:00
opencode-agent[bot]andBrendonovich db768c4886 refactor(app): share mobile drawer primitive (#46453)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-01 07:03:50 +08:00
Aiden Cline 9553187ba6 fix(core): allow patch file-to-directory replacements (#46476) 2026-08-31 16:24:04 -05:00
Aiden Cline d04257eeb4 feat(codemode): add inline namespace metadata (#46464) 2026-08-31 16:01:14 -05:00
opencode-agent[bot]andrekram1-node d68f425c17 feat(cli): port the upgrade command to v2 (#46183)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-31 16:00:22 -05:00
Aiden Cline 49dd2cea34 refactor(ai): clarify responses adapters (#46469) 2026-08-31 15:36:57 -05:00
Simon Klee fac875dba0 mini v2 v2 (#46410) 2026-08-31 22:03:27 +02:00
Kit Langton 566ca864a0 refactor(util): reuse private patch buffers (#46459) 2026-08-31 15:11:54 -04:00
Dax Raad 5df9cecf03 fix(tui): remove plugin current marker 2026-08-31 14:31:03 -04:00
Dax Raad a68fe8a97d fix(tui): toggle plugin on dialog submit 2026-08-31 14:28:19 -04:00
Dax Raad c17c104827 fix(tui): toggle internal plugin controls 2026-08-31 14:25:06 -04:00
Dax Raad 5d4cc4a804 feat(tui): hide internal plugins by default 2026-08-31 14:25:06 -04:00
Kit Langton 1f04baa684 test: migrate fixture layer replacements (#46458)
Update the Core compile options and Server replacement values to the current LayerNode API. Preserve test expectations, replacement targets, and layer lifetimes.
2026-08-31 14:16:40 -04:00
Kit Langton 37f4f4ebda test(server): update instance fixture replacement syntax 2026-08-31 14:11:33 -04:00
Kit Langton 85e955bba7 chore(core): merge v2 into queued controls
Merge v2 at 3e9b009642 while preserving queued-control dispatch behavior and regression tests. Update the execution test fixture to the opaque LayerNode replacement API.
2026-08-31 14:09:07 -04:00
Kit Langton 3e9b009642 feat(core): add session-aware instance selection (#46442) 2026-08-31 13:46:33 -04:00
Kit Langton 36ac35a7c8 refactor(util): make layer graphs opaque and composable
Replace exposed layer graph assembly with opaque declarations, checked substitutions, and lifetime-aware compilation. Preserve deep replacement, ordered startup, and Effect-owned resource lifetimes; migrate callers and verify source and published package contracts.
2026-08-31 13:46:27 -04:00
Kit Langton 197d28e033 fix(tui): pin sidebar headings without scrollbar flashes (#46449)
Keep the title and workspace label above scrollable sidebar details. Disable the unused horizontal scrollbar and place the automatic vertical scrollbar in the reserved gutter so tab changes do not flash or shift the sidebar.
2026-08-31 13:45:55 -04:00
opencode-agent[bot]andkitlangton fcce2d7cc9 test(tui): await dialog text selection (#46143)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-08-31 13:44:00 -04:00
Kit Langton 204c085a1c refactor(core): share inbox control classification 2026-08-31 13:30:51 -04:00
Dax Raad ec0dcb3da9 docs: improve build documentation discovery 2026-08-31 12:52:59 -04:00
Kit Langton afcaed59c9 fix(core): preserve queued controls during prompt promotion 2026-08-31 12:38:10 -04:00
Kit Langton afd7492018 fix(tui): reduce cached transcript remount work (#46145)
Configure custom Markdown renderers before assigning content and share a reactive message-position index across assistant footers. Preserve completion ordering and historical footer metrics, with regression coverage for prepend, same-length refresh, and revert.
2026-08-31 12:15:39 -04:00
Kit Langton 9517ff1054 fix(core): preserve active session continuation when moving 2026-08-31 12:14:45 -04:00
Kit Langton 1ced747051 fix(ai): handle message-less Gemini errors (#46069) 2026-08-31 12:14:31 -04:00
opencode-agent[bot]andDavid 43819dc376 fix(app): restore maskable pwa icons (#46434)
Co-authored-by: David <1879069+iamdavidhill@users.noreply.github.com>
2026-09-01 00:11:07 +08:00
Kit Langton e15dd8ecd3 fix(ai): require Bedrock message stop for finish (#46065) 2026-08-31 12:03:22 -04:00
Dax 6a38cacc1d docs: improve plugin guide readability (#46342) 2026-08-31 11:48:04 -04:00
Kit Langton d609752891 refactor(codemode): avoid merging root definitions twice (#46081) 2026-08-31 11:40:17 -04:00
265 changed files with 11680 additions and 4004 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": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec 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",
+16 -20
View File
@@ -510,9 +510,10 @@ interface ParserState {
readonly tools: ToolStream.State<number>
readonly finishedTools: ReadonlySet<number>
// Bedrock splits the finish into `messageStop` (carries `stopReason`) and
// `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
// `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
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
@@ -692,12 +693,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
return [
{
...state,
pendingFinish: {
reason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
usage: state.pendingFinish?.usage,
finishReason: {
normalized: mapFinishReason(event.messageStop.stopReason),
raw: event.messageStop.stopReason,
},
},
[],
@@ -705,14 +703,11 @@ const step = (state: ParserState, event: BedrockEvent) =>
}
if (event.metadata) {
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.pendingFinish?.usage
const usage = mapUsage(event.metadata.usage, state.providerMetadataKey) ?? state.usage
return [
{
...state,
pendingFinish: {
reason: state.pendingFinish?.reason ?? { normalized: "stop" },
usage,
},
usage,
},
[],
] as const
@@ -736,18 +731,18 @@ const step = (state: ParserState, event: BedrockEvent) =>
const framing = BedrockEventStream.framing(ADAPTER)
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
if (!state.pendingFinish) return []
if (!state.finishReason) return []
const normalized = (() => {
if (state.pendingFinish.reason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.pendingFinish.reason.normalized
if (state.finishReason.normalized === "stop" && state.hasToolCalls) return "tool-calls"
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
reason: {
...state.pendingFinish.reason,
...state.finishReason,
normalized,
},
usage: state.pendingFinish.usage,
usage: state.usage,
})
return events
}
@@ -771,7 +766,8 @@ export const protocol = Protocol.make({
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
tools: ToolStream.empty<number>(),
finishedTools: new Set<number>(),
pendingFinish: undefined,
finishReason: undefined,
usage: undefined,
hasToolCalls: false,
lifecycle: Lifecycle.initial(),
reasoningSignatures: {},
+11 -2
View File
@@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
}
const step = (state: ParserState, event: GeminiEvent) => {
if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") {
if (ProviderShared.isRecord(event.error)) {
const body = ProviderShared.encodeJson(event)
return Effect.fail(
new AIError({
reason: classifyProviderFailure({
message: event.error.message,
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",
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,
+58 -43
View File
@@ -176,14 +176,14 @@ export const InputItem = Schema.Union([
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
export type HostedToolReplayItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| HostedToolReplayItem
| {
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 Extension {
export interface ProviderAdapter {
readonly id: string
readonly name: string
readonly lowerMedia?: (input: {
@@ -381,10 +381,10 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
}
const BASE: Extension = { id: ADAPTER, name: NAME }
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
export interface ParserState {
readonly id: string
@@ -397,6 +397,9 @@ 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>>
}
@@ -482,12 +485,12 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
target: "message" | "tool-result",
) {
const media = ProviderShared.normalizeMedia(part)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
const providerMedia = adapter.lowerMedia?.({ part, media, request })
if (providerMedia) return providerMedia
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
@@ -507,17 +510,17 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
const lowerUserContent = Effect.fnUntraced(function* (
part: LLMRequest["messages"][number]["content"][number],
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media") return yield* lowerMessageMedia(part, request, extension)
return yield* ProviderShared.unsupportedContent(extension.name, "user", ["text", "media"])
if (part.type === "media") return yield* lowerMessageMedia(part, request, adapter)
return yield* ProviderShared.unsupportedContent(adapter.name, "user", ["text", "media"])
})
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, extension: Extension) {
const lowered = yield* lowerMedia(part, request, extension, "message")
const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request: LLMRequest, adapter: ProviderAdapter) {
const lowered = yield* lowerMedia(part, request, adapter, "message")
if (lowered.type === "input_video")
return yield* ProviderShared.invalidRequest(`${extension.name} user messages do not support input_video`)
return yield* ProviderShared.invalidRequest(`${adapter.name} user messages do not support input_video`)
return lowered
})
@@ -526,13 +529,13 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part: MediaPart, request:
const lowerToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
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,
extension,
adapter,
"tool-result",
)
})
@@ -540,30 +543,33 @@ const lowerToolResultContentItem = Effect.fnUntraced(function* (
const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (
item: Content,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
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,
extension,
adapter,
)
})
const lowerToolResultOutput = Effect.fnUntraced(function* (
part: ToolResultPart,
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
// 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, extension))
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
) {
const input: LoweredInputItem[] = []
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
@@ -571,13 +577,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "system") {
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
})
continue
}
if (message.role === "user") {
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, extension))
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
if (content.length > 0) input.push({ role: "user", content })
continue
}
@@ -644,7 +650,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
? undefined
: Schema.is(HostedToolItem)(part.result.value)
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
: adapter.restoreHostedToolItem?.(part.result.value)
if (id !== undefined && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
@@ -658,13 +664,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
content: yield* Effect.forEach(content, (item) => lowerHostedToolResultContentItem(item, request, adapter)),
})
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
"text",
"reasoning",
"tool-call",
@@ -677,11 +681,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent(extension.name, "tool", ["tool-result"])
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
input.push({
type: "function_call_output",
call_id: part.id,
output: yield* lowerToolResultOutput(part, request, extension),
output: yield* lowerToolResultOutput(part, request, adapter),
})
}
}
@@ -733,28 +737,28 @@ const allowedToolChoice = (request: LLMRequest) => {
}
}
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAdapter")(function* (
request: LLMRequest,
extension: Extension,
adapter: ProviderAdapter,
) {
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return {
model: request.model.id,
input: yield* lowerMessages(request, extension),
input: yield* lowerMessages(request, adapter),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) =>
lowerTool(
extension.name,
adapter.name,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
stream: true as const,
max_output_tokens: generation?.maxTokens,
temperature: generation?.temperature,
@@ -768,7 +772,7 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
return yield* decodeBody(yield* fromRequestWithAdapter(request, BASE_ADAPTER))
})
// =============================================================================
@@ -951,12 +955,16 @@ 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,
@@ -969,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
{
...state,
lifecycle,
completedMessages,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
@@ -1085,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id !== undefined) {
const message = state.message?.id === item.id ? state.message : undefined
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 itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? message?.phase : itemPhase
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
@@ -1098,13 +1112,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 =
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
{
...state,
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
message: message ? undefined : state.message,
completedMessages,
message: undefined,
},
events,
] satisfies StepResult
@@ -1408,9 +1422,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, extension: Extension = BASE): ParserState => ({
id: extension.id,
name: extension.name,
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
id: adapter.id,
name: adapter.name,
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
@@ -1418,6 +1432,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
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 extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
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.fromRequestWithExtension(
const body = yield* OpenResponses.fromRequestWithAdapter(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
extension,
adapter,
)
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, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
+5 -5
View File
@@ -36,15 +36,15 @@ const XAIResponsesBody = Schema.Struct({
stream: Schema.Literal(true),
})
const extension = {
const adapter = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
restoreHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
const HOSTED_TOOLS = {
@@ -78,7 +78,7 @@ export const protocol = Protocol.make({
},
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
initial: (request) => OpenResponses.initial(request, adapter),
step,
terminal: OpenResponses.terminal,
},
@@ -631,10 +631,45 @@ 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,6 +62,62 @@ 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,6 +82,32 @@ 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,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => {
])
}),
)
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
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", () =>
Effect.gen(function* () {
const events = yield* collect(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
@@ -233,9 +289,44 @@ 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", "Second"])
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" } },
},
])
}),
)
;[undefined, "fc_1"].forEach((id) => {
@@ -1,5 +1,16 @@
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-status-overlay"]')
const overlay = page.locator('[data-slot="mobile-drawer-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-status-drag-handle"]')
const handle = drawer.locator('[data-slot="mobile-drawer-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": "any"
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "maskable"
}
],
"theme_color": "#080808",
@@ -1,3 +1,7 @@
[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);
}
+1 -3
View File
@@ -114,10 +114,8 @@ 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"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
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
@@ -0,0 +1,90 @@
[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
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,34 @@
[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;
}
+20 -22
View File
@@ -1,7 +1,8 @@
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 "./status/status-drawer.css"
import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
import "./mobile-panel-drawer.css"
export function MobilePanelDrawer(
props: ParentProps<{
@@ -13,32 +14,29 @@ export function MobilePanelDrawer(
) {
const language = useLanguage()
return (
<Drawer
<MobileDrawer
open={props.open}
onOpenChange={props.onOpenChange}
side="bottom"
finalFocusEl={props.returnFocus?.()}
returnFocus={props.returnFocus}
// Menu focus handoff must not dismiss the drawer during its opening transition.
closeOnOutsideFocus={false}
>
{/* 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-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")}>
<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")}
</Drawer.Close>
</MobileDrawerClose>
</div>
<div data-slot="mobile-status-content" data-corvu-no-drag>
{props.children}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
<div data-slot="mobile-panel-content">{props.children}</div>
</div>
</MobileDrawerContent>
</MobileDrawer>
)
}
@@ -1,109 +1,3 @@
[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;
@@ -113,33 +7,3 @@
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,6 +1,7 @@
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")
+2 -82
View File
@@ -14,63 +14,13 @@
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;
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);
margin-block-start: 8px;
}
[data-slot="mobile-tabs-drawer-list"] {
@@ -79,36 +29,6 @@
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;
+12 -22
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 Drawer from "@corvu/drawer"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-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={
<Drawer
<MobileDrawer
open={mobileTabs.open}
onOpenChange={(open) => setMobileTabs("open", open)}
onContentPresentChange={(present) => {
@@ -423,11 +423,9 @@ export function Titlebar(props: {
setMobileTabs("settings", false)
openSettings()
}}
side="bottom"
>
<Drawer.Trigger
<MobileDrawerTrigger
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")}
>
@@ -467,15 +465,11 @@ export function Titlebar(props: {
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
</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>
</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">
<TitlebarTabStrip
orientation="vertical"
tabs={tabsStore}
@@ -493,7 +487,6 @@ 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={() => {
@@ -504,10 +497,7 @@ 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"
data-corvu-no-drag
>
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
<button
type="button"
data-action="mobile-tabs-home"
@@ -546,9 +536,9 @@ export function Titlebar(props: {
</button>
</div>
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer>
</div>
</MobileDrawerContent>
</MobileDrawer>
}
>
<Show
+1
View File
@@ -59,6 +59,7 @@ 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,6 +1,7 @@
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(
@@ -56,6 +57,20 @@ 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",
@@ -0,0 +1,38 @@
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),
)
+7 -5
View File
@@ -17,6 +17,7 @@ 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: {
@@ -98,12 +99,13 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
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 } : {}),
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 } : {}),
),
],
]),
}),
),
Effect.provide(
Observability.layer({
+2
View File
@@ -4,6 +4,7 @@ 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"
@@ -137,6 +138,7 @@ export function createMiniHost(input: {
argv: process.argv.slice(2),
}
return {
version: OPENCODE_VERSION,
terminal: { stdin: input.terminal.stdin },
platform: process.platform,
stdout: {
+6 -5
View File
@@ -30,12 +30,13 @@ 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]), [
[
Global.node,
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
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 } : {}),
),
],
]),
}),
),
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"
}
function parseReleaseVersion(input: string) {
export function parseReleaseVersion(input: string) {
if (input.length > 256) return
const match = input.trim().match(versionPattern)
if (!match) return
+12 -8
View File
@@ -5,19 +5,21 @@ 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, type Policy } from "./updater-action"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
declare const OPENCODE_CLI_NAME: string | undefined
type Method = "npm" | "pnpm" | "bun" | "yarn" | "curl"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
const packageName =
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node"
? OPENCODE_CLI_NAME
: "@opencode-ai/cli"
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@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") {}
@@ -110,7 +112,9 @@ export const layer = Layer.effect(
return data.version
})
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
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 target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
@@ -138,7 +142,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}`))
})
@@ -173,7 +177,7 @@ export const layer = Layer.effect(
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
)
return Service.of({ check })
return Service.of({ check, method, latest, upgrade })
}),
)
+16 -2
View File
@@ -486,7 +486,14 @@ 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", mono: true }
draft.mini = {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
}
})
}),
)
@@ -494,7 +501,14 @@ 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", mono: true },
mini: {
thinking: "hide",
shell_output: "hide",
turn_summary: "hide",
splash: "hide",
work_spinner: "block-low-comet",
mono: true,
},
})
expect(await Bun.file(path.join(directory.path, "cli.json")).text()).toContain("// Keep this comment")
})
+36
View File
@@ -0,0 +1,36 @@
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,6 +9,7 @@ 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" }
@@ -145,6 +146,7 @@ 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
@@ -0,0 +1,227 @@
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
@@ -0,0 +1,109 @@
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 })
}
}
+18 -5
View File
@@ -26,7 +26,7 @@ Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source locat
## Quick Start
```ts
import { CodeMode, Tool } from "@opencode-ai/codemode"
import { CodeMode, Namespace, Tool } from "@opencode-ai/codemode"
import { Effect, Schema } from "effect"
const lookupOrder = Tool.make({
@@ -60,9 +60,22 @@ only shape the model-visible signature. Without `output`, the signature uses `Pr
Descriptions and schemas are model-visible contracts. Authorization belongs in `execute`.
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"](...)`.
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"](...)`.
### `CodeMode.execute` and `CodeMode.make`
@@ -150,7 +163,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`.
`maxToolCalls`. Search also matches descriptions from enclosing `Namespace` values.
## Execution Limits
+1
View File
@@ -1,4 +1,5 @@
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
@@ -0,0 +1,24 @@
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,7 +53,6 @@ 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(),
@@ -99,6 +98,7 @@ 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(
+1 -3
View File
@@ -461,9 +461,7 @@ export const operationInput = (
const fields = [...parameters.value, ...requestBody.value.fields]
const conflicts = new Set(
[...Map.groupBy(fields, (field) => field.name)]
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
.map(([name]) => name),
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
)
const used = new Set<string>()
return {
+34 -25
View File
@@ -8,6 +8,7 @@ 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 {
@@ -277,6 +278,7 @@ 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>>
}
@@ -292,7 +294,10 @@ const toolTrie = <R>(tools: Tools<R>): ToolNode<R> => {
current = child
}
if (isTool<R>(value)) current.tool = value
else insert(current, value)
else if (isNamespace<R>(value)) {
current.namespace = value
insert(current, value.tools)
} else insert(current, value)
}
}
insert(root, tools)
@@ -302,29 +307,33 @@ 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> = [],
): 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(),
]
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)),
]
}
const describeTool = <R>(path: string, tool: Tool<R>): ToolDescription => ({
path,
description: tool.description,
signature: `${toolExpression(path)}(input: ${inputTypeScript(tool, true)}): Promise<${outputTypeScript(tool, true)}>`,
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)}>`,
})
// 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))
.map(({ path, tool }) => ({
path,
tool,
description: describeTool(path, tool),
}))
flattenTools(toolTrie(tools)).sort((left, right) => compareText(left.path, right.path))
export type DiscoveryPlan = {
readonly catalog: ReadonlyArray<ToolDescription>
@@ -420,12 +429,13 @@ export const searchSignature = (() => {
return `search(input: ${inputTypeScript(tool, true)}): ${outputTypeScript(tool, true)}`
})()
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
description,
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
searchText: [
path,
tool.description,
...inputProperties(tool).flatMap(({ name, description: property }) =>
visible.path,
visible.tool.description,
...visible.namespaces.flatMap((namespace) => (namespace.description === undefined ? [] : [namespace.description])),
...inputProperties(visible.tool).flatMap(({ name, description: property }) =>
property === undefined ? [name] : [name, property],
),
]
@@ -433,14 +443,13 @@ const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescript
.toLowerCase(),
})
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> =>
visibleTools(tools).map(({ path, tool, description }) => toSearchEntry(path, tool, description))
export const searchIndex = <R>(tools: Tools<R>): ReadonlyArray<SearchEntry> => visibleTools(tools).map(toSearchEntry)
export const prepare = <R>(tools: Tools<R>): DiscoveryPlan => {
const visible = visibleTools(tools)
return {
catalog: visible.map(({ description }) => description),
searchIndex: visible.map(({ path, tool, description }) => toSearchEntry(path, tool, description)),
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
}
}
+2 -2
View File
@@ -127,8 +127,8 @@ const renderSchema = (
])
}
if (schema.allOf) {
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
}
if (Array.isArray(schema.type)) {
@@ -180,7 +180,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
try {
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
return renderSchema(schema, { definitions: {}, pretty })
} catch {
return "unknown"
}
+4 -7
View File
@@ -1,4 +1,6 @@
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
@@ -50,13 +52,8 @@ export type Options<I extends SchemaType, O extends SchemaType | undefined, R =
readonly execute: (input: InputType<I>) => Effect.Effect<ResultType<O>, unknown, R>
}
// 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"
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"
/**
* Declares one schema-described tool available to a CodeMode program through `tools.*`.
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Namespace } from "./namespace.js"
import type { Tool } from "./tool.js"
export type Tools<R = never> = {
readonly [name: string]: Tool<R> | Tools<R>
readonly [name: string]: Tool<R> | Namespace<R> | Tools<R>
}
+35 -4
View File
@@ -25,8 +25,12 @@ 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: unknown, name: string) =>
name.split(".").reduce<unknown>((current, segment) => (isRecord(current) ? current[segment] : undefined), tools)
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 recordingClient = (respond: (request: HttpClientRequest.HttpClientRequest) => Response) => {
const requests: Array<Recorded> = []
@@ -278,6 +282,30 @@ 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({
@@ -315,7 +343,10 @@ describe("OpenAPI.fromSpec", () => {
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
get: {
operationId: "test",
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
parameters: [
{ name: "limit", in: "query", schema: { type: "boolean" } },
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
],
responses: { 200: { description: "Success" } },
},
},
@@ -948,7 +979,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 = isRecord(health) ? health.input : undefined
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
+51 -16
View File
@@ -216,6 +216,35 @@ describe("pretty signature rendering", () => {
})
})
describe("JSON Schema definition scope", () => {
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
expect(jsonSchemaToTypeScript(schema)).toBe("string")
expect(jsonSchemaToTypeScript(schema, true)).toBe("string")
const overridden = { ...schema, $defs: { Value: { type: "number" } } }
expect(jsonSchemaToTypeScript(overridden)).toBe("number")
expect(jsonSchemaToTypeScript(overridden, true)).toBe("number")
})
test.each(["definitions", "$defs"])("nested %s shadow inherited definitions without affecting siblings", (key) => {
const schema = {
type: "object",
definitions: { Inherited: { type: "string" } },
$defs: { Value: { type: "number" } },
properties: {
nested: { $ref: `#/${key}/Value`, [key]: { Value: { type: "boolean" } } },
inherited: { $ref: "#/definitions/Inherited" },
sibling: { $ref: "#/$defs/Value" },
},
}
expect(jsonSchemaToTypeScript(schema)).toBe("{ nested?: boolean; inherited?: string; sibling?: number }")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " nested?: boolean,", " inherited?: string,", " sibling?: number,", "}"].join("\n"),
)
})
})
describe("non-identifier property names render as quoted keys", () => {
// MCP-style schemas routinely carry property names that are not bare TS identifiers
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
@@ -315,28 +344,34 @@ describe("union schemas render every alternative", () => {
expect(outputTypeScript(tool)).toBe("number | boolean")
})
test("allOf renders intersections with parenthesized union members", () => {
test("allOf keeps siblings and parenthesized union members in order", () => {
const schema = {
properties: { common: { type: "boolean" } },
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
} as const
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
expect(jsonSchemaToTypeScript(schema, true)).toBe(
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
)
})
test("allOf does not discard an unresolved constraint", () => {
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
"unknown",
)
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")
}
expect(
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" } },
}),
jsonSchemaToTypeScript(
{
type: "string",
allOf: [{ $ref: "#/$defs/Constraint" }],
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
},
pretty,
),
).toBe("string")
})
})
+43 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { CodeMode, Namespace, Tool } from "../src/index.js"
const echo = (description: string, result: string) =>
Tool.make({
@@ -177,6 +177,48 @@ 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"]) {
+4 -13
View File
@@ -1,20 +1,11 @@
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.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 function build<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
return LayerNode.compile(root, {
replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(replacements)), ...replacements],
})
}
export * as AppNodeBuilder from "./app-node-builder.js"
+10 -16
View File
@@ -55,6 +55,7 @@ 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,
@@ -110,9 +111,9 @@ const nodes = [
Vcs.node,
// Start repository watches only after boot-critical filesystem and Git work.
LocationWatcher.node,
] as const satisfies readonly Node.LocationNode<unknown, unknown>[]
] as const satisfies readonly Node.LocationGraph<never, unknown>[]
export const graph = LayerNode.group<typeof nodes>(nodes)
export const graph = LayerNode.group(nodes)
export type Services = LayerNode.Output<typeof graph>
export type Error = LayerNode.Error<typeof graph>
@@ -141,29 +142,23 @@ export interface Options {
// source still honors explicit plugin operations from wellknown and
// host-injected config.
const vanillaReplacements: LayerNode.Replacements = [
[Config.node, Config.configured({ project: false, global: false })],
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: false, global: false })],
Config.node.replace(Config.configured({ project: false, global: false })),
InstructionDiscovery.node.replace(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 bound pairs (which win over everything).
const allReplacements: LayerNode.Replacements = [
// defaults), then instance bindings (which win over everything).
const replacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
[Location.node, Location.boundNode(ref, { discovery: options.discovery })],
[InstancePlugins.node, InstancePlugins.bound(options.plugins ?? [])],
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
InstancePlugins.node.replace(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(location.node).pipe(
Layer.fresh,
return LayerNode.compile(graph, { replacements, shared: Node.tags.values.global }).pipe(
Layer.tap(() =>
Effect.logInfo("location services booted", {
directory: ref.directory,
@@ -171,6 +166,5 @@ export function layer(ref: Location.Ref, options: Options = {}) {
durationMs: Math.round(performance.now() - startedAt),
}),
),
Layer.provide(LayerNode.compile(location.hoisted)),
)
}
+42
View File
@@ -0,0 +1,42 @@
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 [providerID, ...modelID] = input.split("/")
const index = input.indexOf("/")
return {
providerID: Provider.ID.make(providerID),
modelID: ID.make(modelID.join("/")),
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
}
}
+9 -3
View File
@@ -112,7 +112,7 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/PersistentPty") {}
export const configured = (options: Options = {}) =>
const makeLayer = (options: Options = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
@@ -361,8 +361,14 @@ export const configured = (options: Options = {}) =>
}),
)
export const layer = configured()
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Global.node] })
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()
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
+6 -3
View File
@@ -80,7 +80,8 @@ 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 })))
return {
// Keep the instance graph's inferred types independent of Session handles.
const context: Plugin.Context = {
app,
location: locationInfo(),
options: {},
@@ -206,7 +207,8 @@ 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),
),
),
},
@@ -449,7 +451,8 @@ 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),
},
} satisfies Plugin.Context
}
return 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) =>
export const layerWithCell = (cell: Cell): Layer.Layer<Service> =>
Layer.succeed(
Service,
Service.of({
@@ -11,6 +11,9 @@ 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,
@@ -152,6 +155,8 @@ 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)
@@ -220,6 +225,16 @@ 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
+10 -11
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream } from "effect"
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { Project } from "./project.js"
@@ -10,6 +10,7 @@ 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"
@@ -238,20 +239,16 @@ 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((ref) => locations.get(ref))
const sessions = yield* Session.make()
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(
Effect.provide(locations.get(location)),
instances.provideIfLoaded(session),
)
})
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -403,7 +400,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(Effect.provide(locations.get(session.location)))
const generate = yield* SessionGenerate.Service.pipe(instances.provide(session))
return yield* generate.generate(input)
}),
command: Effect.fn("Session.command")(function* (input) {
@@ -412,7 +409,7 @@ const layer = Layer.effect(
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Command.Service
}).pipe(Effect.provide(locations.get(session.location)))
}).pipe(instances.provide(session))
const delivery = input.delivery ?? "steer"
yield* commands.execute({
name: input.command,
@@ -468,7 +465,8 @@ 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))
if (!source || source.type !== "Directory") {
// Active runners must hand off at a step boundary to retain their continuation.
if ((!source || source.type !== "Directory") && !(yield* execution.isActive(input.sessionID))) {
const cancellations = (yield* SessionInbox.moveIDs(db, input.sessionID)).map(
(item) => [SessionEvent.InboxCancelled, { sessionID: input.sessionID, inboxID: item.id }] as const,
)
@@ -534,6 +532,7 @@ export const node = makeGlobalNode({
Project.node,
SessionExecution.node,
SessionStore.node,
Instance.byLocationNode,
SessionInbox.node,
LocationServiceMap.node,
SessionProjector.node,
+7 -8
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 { LocationServiceMap } from "../location-service-map.js"
import { Instance } from "../instance/service.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 the runner owned by that Session's Location. */
/** Routes execution from a Session ID to its selected instance's runner. */
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, routed through the Session's Location graph. */
/** Process-local execution: drains run in this process using the selected instance. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const instances = yield* Instance.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(
Effect.provide(locations.get(session.location)),
instances.provide(session),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
@@ -159,8 +159,7 @@ 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" || next.type === "compaction" || next.type === "move")
yield* coordinator.wake(sessionID, "steer")
if (next.delivery === "steer" || SessionInbox.isControl(next)) yield* coordinator.wake(sessionID, "steer")
return interrupted
}),
resume: coordinator.run,
@@ -173,7 +172,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node, Job.node],
deps: [SessionStore.node, Instance.byLocationNode, Bus.node, Database.node, Job.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
+6 -3
View File
@@ -47,6 +47,9 @@ 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)
@@ -505,7 +508,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((row) => row.type === "compaction" || row.type === "move")
const control = steers.findIndex(isControl)
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -517,10 +520,10 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
if (!queued || isControl(queued)) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
const control = arrivedSteers.findIndex(isControl)
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 (input.type === "compaction" || input.type === "move") return
if (SessionInbox.isControl(input)) return
yield* insertMessage(
db,
event,
+12 -10
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()
const control = pending.type === "compaction" || pending.type === "move"
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
if (promotable === "steer" && pending.delivery === "queue" && !SessionInbox.isControl(pending))
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: !entering && continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: 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,15 +128,16 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
const ready = yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
db,
bus,
sessionID,
entering && !continuing ? promotable : "steer",
)
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
}
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -145,6 +146,7 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
+10 -19
View File
@@ -1,11 +1,11 @@
export * as Session from "./session.js"
import { DateTime, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { DateTime, Effect, Fiber, 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 { Location } from "../location.js"
import { Instance } from "../instance/service.js"
import { PluginSupervisor } from "../plugin/supervisor-service.js"
import { Shell } from "../shell.js"
import { ShellResult } from "../shell/result.js"
@@ -33,26 +33,19 @@ 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(servicesFor)`.
* Build once in the host Scope: `const sessions = yield* Session.make()`.
* Use `sessions.forSession(id)` for handles that share host services and reload current state.
*/
export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Location.Ref) => Layer.Layer<Services>) {
export const make = Effect.fn("Session.make")(function* () {
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
@@ -174,7 +167,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const preparation = yield* SessionPrompt.Service
const references = yield* Reference.Service
return { item: yield* preparation.prepare({ sessionID, messageID, input }), references }
}).pipe(Effect.provide(servicesFor(session.location))),
}).pipe(instances.provide(session)),
)
// Commit a staged revert only after preparation succeeds, before admitting new work.
if (session.revert) yield* SessionRevert.commit(bus, session)
@@ -205,7 +198,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
const plugins = yield* PluginSupervisor.Service
yield* plugins.flush
return yield* Shell.Service
}).pipe(Effect.provide(servicesFor(session.location)))
}).pipe(instances.provide(session))
const started = yield* shell
.create({
command: input.command,
@@ -256,7 +249,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
input: { id?: SessionMessage.ID; skill: Skill.ID; resume?: boolean },
) {
const session = yield* get(sessionID)
const skills = yield* Skill.Service.pipe(Effect.provide(servicesFor(session.location)))
const skills = yield* Skill.Service.pipe(instances.provide(session))
const skill = yield* skills.get(input.skill)
if (!skill) return yield* new SkillNotFoundError({ skill: input.skill })
yield* bus.publish(
@@ -355,14 +348,12 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
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(Effect.provide(servicesFor(session.location)))
).pipe(instances.provide(session))
})
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(
Effect.provide(servicesFor(session.location)),
)
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(instances.provide(session))
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, globalLayer],
[Location.node, locationLayer],
Global.node.replace(globalLayer),
Location.node.replace(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, Bus.configured({ persist: true })],
Bus.node.replace(Bus.configured({ persist: true })),
]),
)
const a = Location.Ref.make({ directory: AbsolutePath.make("/a") })
+10 -10
View File
@@ -100,12 +100,14 @@ 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, locationLayer],
[Bus.node, Bus.configured({ persist: true })],
Location.node.replace(locationLayer),
Bus.node.replace(Bus.configured({ persist: true })),
]),
)
const itWithoutLocation = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [[Bus.node, Bus.configured({ persist: true })]]),
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
]),
)
const itWithoutPersistence = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node])))
@@ -631,8 +633,7 @@ describe("Bus", () => {
const continueRead = yield* Deferred.make<void>()
let pause = true
const eventLayer = AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node]), [
[
Bus.node,
Bus.node.replace(
Bus.configured({
persist: true,
beforeAggregateRead: () =>
@@ -640,7 +641,7 @@ describe("Bus", () => {
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void,
}),
],
),
])
yield* Effect.gen(function* () {
@@ -1318,7 +1319,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, Bus.configured({ persist: true, logReadPageSize: 2 })],
Bus.node.replace(Bus.configured({ persist: true, logReadPageSize: 2 })),
])
yield* Effect.gen(function* () {
@@ -1351,8 +1352,7 @@ 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,
Bus.node.replace(
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, locationLayer]],
[Location.node.replace(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, locationLayer]]),
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node]), [Location.node.replace(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, locationLayer],
Location.node.replace(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, locationLayer],
Location.node.replace(locationLayer),
]),
)
+1 -1
View File
@@ -35,7 +35,7 @@ describe("CodeMode", () => {
Effect.scoped,
Effect.provide(
AppNodeBuilder.build(Tool.node, [
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
Location.node.replace(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, Location.boundNode({ directory: AbsolutePath.make("/project") })],
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
])
return Effect.gen(function* () {
+10 -11
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, emptyMcpLayer],
[Config.node, emptyConfigLayer],
[Location.node, testLocationLayer],
[ShellSelect.node, shellLayer],
Mcp.node.replace(emptyMcpLayer),
Config.node.replace(emptyConfigLayer),
Location.node.replace(testLocationLayer),
ShellSelect.node.replace(shellLayer),
],
),
)
@@ -340,17 +340,16 @@ describeNative("ConfigCommandPlugin native watcher", () => {
ShellSelect.node,
]),
[
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(path.join(tmp, "project")) })),
),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[ShellSelect.node, shellLayer],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
ShellSelect.node.replace(shellLayer),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
],
),
),
+3 -4
View File
@@ -40,13 +40,12 @@ const it = testEffect(
Layer.merge(
config,
AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, SessionModelRequest.node, Config.node, Bus.node]), [
[
llmClient,
llmClient.replace(
Layer.mock(LLMClient.Service)({
stream: () => Stream.make(LLMEvent.textDelta({ id: "summary", text: "summary" })),
}),
],
[Config.node, config],
),
Config.node.replace(config),
]),
),
)
+11 -12
View File
@@ -55,12 +55,12 @@ function testLayer(
),
)
const built = AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[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],
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),
])
// Merge the watcher layer by reference so Watcher.Test resolves to the same
// memoized instance the built graph uses.
@@ -311,16 +311,15 @@ describe("Config", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(project) })),
),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
),
Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })),
Credential.node.replace(emptyCredentialNode),
WellKnown.node.replace(emptyWellknownNode),
]),
),
)
+4 -7
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, tempGlobalLayer],
Global.node.replace(tempGlobalLayer),
]),
)
const staticIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[ConfigPluginSource.node, ConfigPluginSource.empty],
[Global.node, tempGlobalLayer],
ConfigPluginSource.node.replace(ConfigPluginSource.empty),
Global.node.replace(tempGlobalLayer),
]),
)
const refreshNpm = makeGlobalNode({
@@ -65,10 +65,7 @@ const refreshNpm = makeGlobalNode({
const refreshIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node, Global.node]),
[
[Global.node, tempGlobalLayer],
[Npm.node, refreshNpm],
],
[Global.node.replace(tempGlobalLayer), Npm.node.replace(refreshNpm)],
),
)
+6 -7
View File
@@ -86,14 +86,13 @@ const discover = (directory: string, global: string) =>
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [
[
Location.node,
Location.node.replace(
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
],
[Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })],
[Credential.node, emptyCredentialNode],
[WellKnown.node, emptyWellknownNode],
[Watcher.node, Watcher.testLayer],
),
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),
]),
),
)
+2 -2
View File
@@ -51,8 +51,8 @@ describe("ConfigSnapshotPlugin.Plugin", () => {
}).pipe(
Effect.provide(
AppNodeBuilder.build(Snapshot.node, [
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))],
[Global.node, Global.layerWith({ data: tmp.path, config: path.join(tmp.path, "config") })],
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") })),
]),
),
)
@@ -44,7 +44,9 @@ 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, Global.layerWith({ data: tmp.path })]])),
Effect.provide(
AppNodeBuilder.build(ToolOutput.node, [Global.node.replace(Global.layerWith({ data: tmp.path }))]),
),
),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
@@ -147,6 +147,20 @@ 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* () {
@@ -199,6 +213,30 @@ 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,131 +13,218 @@ class OtherError {
readonly _tag = "OtherError"
}
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] })
// 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])
make({ name: "manual-a", layer: aLayer, deps: [] })
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: [] })
// @ts-expect-error A node must have a service or name
make({ 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 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 = () =>
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 Node replacement cannot introduce a new error
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
void invalidNodeErrorReplacement
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" }
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 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
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 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)
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 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>
// @ts-expect-error Tag configuration can only reference declared tags
LayerNode.tags({ request: ["missing"], global: [] })
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 An unrelated dependency cannot satisfy TagA
request({ service: TagB, layer: tagBLayer, deps: [requestB] })
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 Providing only TagA leaves TagB missing
request({ service: TagC, layer: tagCLayer, deps: [globalA] })
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 TagB leaves TagA missing
request({ service: TagC, layer: tagCLayer, deps: [requestB] })
void [
invalidStored,
rawStored,
forged,
groupNode,
forgedGraph,
narrowedOutput,
widenedOutput,
widenedError,
narrowedError,
widenedTag,
narrowedTag,
widenedGraph,
]
}
// @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", () => {})
test("layer node type contracts compile", () => {
void contracts
})
@@ -1,19 +1,21 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer } from "effect"
import { Context, Deferred, Duration, Effect, Fiber, Layer, LayerMap, Option } 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 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") {}
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",
) {}
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,
@@ -23,240 +25,443 @@ const value = make({ service: Value, layer: valueLayer, deps: [] })
const greeting = make({ service: Greeting, layer: greetingLayer, deps: [value] })
describe("layer node", () => {
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]))),
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",
)
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]])),
),
)
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: [],
})
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,
const dependent = make({
service: Value,
layer: Layer.effect(
Users,
Effect.gen(function* () {
const db = yield* Database
return Users.of({ list: Effect.succeed([db.name]) })
}),
Value,
Effect.map(Greeting, (item) => Value.of({ value: item.value })),
),
deps: [database],
deps: [greeting],
})
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",
expect(() => LayerNode.compile(greeting, { replacements: [value.replace(dependent)] })).toThrow(
"Cycle detected in layer graph",
)
})
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("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([])
}),
)
expect(result.node.dependencies[0]?.dependencies[0]?.dependencies[0]).toMatchObject({
kind: "group",
dependencies: [],
})
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"])
}),
)
})
})
@@ -1,20 +1,23 @@
import { describe, expect, test } from "bun:test"
import { Context, Effect, Layer, LayerMap, Option } from "effect"
import { Context, Effect, Layer, 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 type { LocationError, LocationServices } from "@opencode-ai/core/location-services"
import { buildLocationServiceMap } 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({
@@ -31,7 +34,7 @@ describe("node build", () => {
expect(await Effect.runPromise(program)).toBe("plain")
})
test("detects cycles through a replaced location service map", async () => {
test("detects cycles through a replaced location service map", () => {
const a = Node.makeGlobalNode({
service: CycleA,
layer: Layer.effect(CycleA, Effect.as(LocationServiceMap.Service, CycleA.of({}))),
@@ -45,31 +48,49 @@ describe("node build", () => {
),
deps: [a],
})
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 mapLayer = Layer.unwrap(Effect.as(CycleB, buildLocationServiceMap()))
const map = Node.makeGlobalNode({ service: LocationServiceMap.Service, layer: mapLayer, deps: [b] })
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [[LocationServiceMap.node, map]])).toThrow(
"Cycle detected in layer tree",
expect(() => AppNodeBuilder.build(LayerNode.group([a]), [LocationServiceMap.node.replace(map)])).toThrow(
"Cycle detected in layer graph",
)
})
test("shares top-level project with location services", async () => {
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 () => {
await using tmp = await tmpdir()
let acquisitions = 0
const projectLayer = Layer.effect(
@@ -84,8 +105,8 @@ describe("node build", () => {
}),
)
const ref = Location.Ref.make({ directory: AbsolutePath.make(tmp.path) })
const layer = AppNodeBuilder.build(LayerNode.group([Project.node, LocationServiceMap.node]), [
[Project.node, projectLayer],
const layer = AppNodeBuilder.build(LayerNode.group([LocationServiceMap.node, Project.node]), [
Project.node.replace(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, activeLocation],
[Environment.node, transformEnvironmentFiles(transformFiles)],
Location.node.replace(activeLocation),
Environment.node.replace(transformEnvironmentFiles(transformFiles)),
]),
)
}
+14 -20
View File
@@ -77,16 +77,15 @@ describe("FileSystemSearch", () => {
workspaceID: Workspace.ID.make("wrk_test"),
})
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
location(ref, { vcs: { type: "git", store: AbsolutePath.make(path.join(directory, ".git")) } }),
),
),
],
[Ripgrep.node, ripgrepStub("remote.ts", (input) => (observed = input))],
),
Ripgrep.node.replace(ripgrepStub("remote.ts", (input) => (observed = input))),
])
yield* Effect.gen(function* () {
@@ -103,8 +102,7 @@ describe("FileSystemSearch", () => {
let observed: Ripgrep.FindInput | undefined
const home = AbsolutePath.make(os.homedir())
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
@@ -114,8 +112,8 @@ describe("FileSystemSearch", () => {
),
),
),
],
[Ripgrep.node, ripgrepStub("src/index.ts", (input) => (observed = input))],
),
Ripgrep.node.replace(ripgrepStub("src/index.ts", (input) => (observed = input))),
])
yield* Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
@@ -137,17 +135,15 @@ describe("FileSystemSearch", () => {
const started = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-atomic")) }),
),
),
],
[
Ripgrep.node,
),
Ripgrep.node.replace(
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -169,7 +165,7 @@ describe("FileSystemSearch", () => {
grep: () => Effect.succeed([]),
}),
),
],
),
])
yield* Effect.gen(function* () {
@@ -208,17 +204,15 @@ describe("FileSystemSearch", () => {
(value) => Effect.sync(() => value.mockRestore()),
)
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Location.node.replace(
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
],
[
Ripgrep.node,
),
Ripgrep.node.replace(
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
@@ -234,7 +228,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, type LocationNode } from "@opencode-ai/util/effect/app-node"
import { makeLocationNode } 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: LocationNode<PluginSupervisor.Service> = pluginNode,
plugins: typeof pluginNode = 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, config],
[Location.node, locationLayer],
[PluginSupervisor.node, plugins],
...(watcher ? ([[Watcher.node, watcher]] as const) : []),
Config.node.replace(config),
Location.node.replace(locationLayer),
PluginSupervisor.node.replace(plugins),
...(watcher ? ([Watcher.node.replace(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?: LocationNode<PluginSupervisor.Service>
plugins?: typeof pluginNode
},
) {
return Effect.acquireRelease(
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(),
})
const testLayer = LayerNode.compile(EffectFlock.node, [[Global.node, testGlobal]])
const testLayer = LayerNode.compile(EffectFlock.node, { replacements: [Global.node.replace(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]), [
[Bus.node, Layer.succeed(Bus.Service, bus)],
]),
LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node, Skill.node]), {
replacements: [Bus.node.replace(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, tempGlobalLayer],
Global.node.replace(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, tempGlobalLayer]],
replacements: [Global.node.replace(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, tempGlobalLayer],
[LocationServiceMap.node, instances],
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
)
+5 -6
View File
@@ -23,14 +23,13 @@ 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,
Config.node.replace(
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.
@@ -43,7 +42,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, tempGlobalLayer], ...(name === "vanilla" ? hostConfig : [])],
replacements: [Global.node.replace(tempGlobalLayer), ...(name === "vanilla" ? hostConfig : [])],
})
},
{ idleTimeToLive: Duration.infinity },
@@ -52,8 +51,8 @@ const instances = Layer.effect(
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
[LocationServiceMap.node, instances],
Global.node.replace(tempGlobalLayer),
LocationServiceMap.node.replace(instances),
]),
)
@@ -33,19 +33,18 @@ const instructionLayer = (input: {
AppNodeBuilder.build(
LayerNode.group([InstructionDiscovery.node, Bus.node, FSUtil.node, Global.node, Location.node, Watcher.node]),
[
[InstructionDiscovery.node, InstructionDiscovery.configured({ project: input.project })],
[
Global.node,
InstructionDiscovery.node.replace(InstructionDiscovery.configured({ project: input.project })),
Global.node.replace(
input.config || input.home
? Global.layerWith({
...(input.config ? { config: input.config } : {}),
...(input.home ? { home: input.home } : {}),
})
: tempGlobalLayer,
],
[Location.node, input.locationServiceLayer],
[Watcher.node, watcher],
...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []),
),
Location.node.replace(input.locationServiceLayer),
Watcher.node.replace(watcher),
...(input.filesystemLayer ? [FSUtil.node.replace(input.filesystemLayer)] : []),
],
),
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, Bus.configured({ persist: true })],
Bus.node.replace(Bus.configured({ persist: true })),
]),
)
@@ -30,8 +30,8 @@ const locationLayer = Layer.succeed(
)
const it = testEffect(
AppNodeBuilder.build(InstructionBuiltIns.node, [
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: temporary, tmp: temporary })],
Location.node.replace(locationLayer),
Global.node.replace(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, failingCredentialNode]]),
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
)
function eventually<A, E, R>(
@@ -13,15 +13,16 @@ import { it } from "./lib/effect"
const provide = (directory: string, workspaceID?: Workspace.ID) =>
Effect.provide(
LayerNode.compile(FileSystem.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory), workspaceID })),
LayerNode.compile(FileSystem.node, {
replacements: [
Location.node.replace(
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, tempGlobalLayer],
Global.node.replace(tempGlobalLayer),
]),
)
const itWithSdk = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [
[Global.node, tempGlobalLayer],
Global.node.replace(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, activityLocations],
LocationServiceMap.node.replace(activityLocations),
]),
)
+11 -10
View File
@@ -13,20 +13,21 @@ import { it } from "./lib/effect"
function provide(directory: string, projectDirectory = directory) {
return Effect.provide(
LayerNode.compile(LocationMutation.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
LayerNode.compile(LocationMutation.node, {
replacements: [
Location.node.replace(
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, projectLayer]]))
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [Project.node.replace(projectLayer)]))
describe("Location", () => {
it.effect("resolves the current project and vcs information", () =>
+2 -3
View File
@@ -23,13 +23,12 @@ const tool = (server: string, name = "search") => new Mcp.Tool({ server: Mcp.Ser
const layer = (catalog: () => Mcp.ServerInstructions[], tools: () => Mcp.Tool[]) =>
AppNodeBuilder.build(McpInstructions.node, [
[
Mcp.node,
Mcp.node.replace(
Layer.mock(Mcp.Service, {
instructions: () => Effect.succeed(catalog()),
tools: () => Effect.succeed(tools()),
}),
],
),
])
describe("McpInstructions", () => {
+12 -14
View File
@@ -378,10 +378,10 @@ const permissions = Layer.mock(Permission.Service, {
const events = Layer.mock(Bus.Service, { subscribe: () => Stream.never })
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node]), [
[Mcp.node, mcp],
[Permission.node, permissions],
[Bus.node, events],
[Image.node, imagePassthrough],
Mcp.node.replace(mcp),
Permission.node.replace(permissions),
Bus.node.replace(events),
Image.node.replace(imagePassthrough),
]),
)
@@ -1688,8 +1688,7 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
Effect.provide(
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
Mcp.node,
Mcp.node.replace(
Layer.mock(Mcp.Service, {
tools: () => Ref.get(catalog),
callTool: (input) =>
@@ -1702,9 +1701,9 @@ testEffect(Layer.empty).live("isolates invalid MCP tools and preserves plugin tr
}),
),
}),
],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Image.node, imagePassthrough],
),
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
Image.node.replace(imagePassthrough),
]),
),
),
@@ -1731,8 +1730,7 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
}).pipe(
Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, McpTool.node, Bus.node]), [
[
Mcp.node,
Mcp.node.replace(
Layer.mock(Mcp.Service, {
tools: () =>
Effect.sync(() => [
@@ -1744,9 +1742,9 @@ testEffect(Layer.empty).effect("coalesces queued MCP tool notifications after in
}),
]),
}),
],
[Permission.node, Layer.mock(Permission.Service, { assert: () => Effect.void })],
[Image.node, imagePassthrough],
),
Permission.node.replace(Layer.mock(Permission.Service, { assert: () => Effect.void })),
Image.node.replace(imagePassthrough),
]),
),
)
+17
View File
@@ -5,6 +5,23 @@ import { Provider } from "@opencode-ai/core/provider"
const decode = Schema.decodeUnknownSync(Model.Ref)
describe("Model.parse", () => {
test.each([
["vendor/model", "vendor", "model"],
["vendor/team/model", "vendor", "team/model"],
["vendor", "vendor", ""],
["", "", ""],
["/model", "", "model"],
["vendor/", "vendor", ""],
["vendor//model/", "vendor", "/model/"],
])("parses %j at the first slash", (input, providerID, modelID) => {
expect(Model.parse(input)).toEqual({
providerID: Provider.ID.make(providerID),
modelID: Model.ID.make(modelID),
})
})
})
describe("Model.Ref", () => {
test("accepts a model selection without a variant", () => {
expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({
+6 -6
View File
@@ -182,9 +182,9 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
// every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh(
AppNodeBuilder.build(LayerNode.group([ModelsDev.node, Bus.node]), [
[ModelsDev.node, ModelsDev.configured(options)],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeMockKV(cache)],
ModelsDev.node.replace(ModelsDev.configured(options)),
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
KV.node.replace(makeMockKV(cache)),
]),
)
@@ -312,9 +312,9 @@ describe("ModelsDev Service", () => {
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
ModelsDev.node.replace(ModelsDev.configured({ fetch: true, snapshot: false })),
LayerNodePlatform.httpClient.replace(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
KV.node.replace(makeFailingWriteKV(cache)),
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
+1 -1
View File
@@ -20,7 +20,7 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
)
const npmLayer = (cache: string) =>
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
AppNodeBuilder.build(Npm.node, [Global.node.replace(Global.layerWith({ cache, state: path.join(cache, "state") }))])
async function createGitFixture(directory: string) {
const repository = path.join(directory, "repository")

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