mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 01:51:43 +08:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fab33d59b | |||
| 8b1091c35a | |||
| 9553187ba6 | |||
| d04257eeb4 | |||
| d68f425c17 | |||
| 49dd2cea34 | |||
| fac875dba0 | |||
| 566ca864a0 | |||
| 5df9cecf03 | |||
| a68fe8a97d | |||
| c17c104827 | |||
| 5d4cc4a804 | |||
| 1f04baa684 | |||
| 3e9b009642 | |||
| 36ac35a7c8 | |||
| 197d28e033 | |||
| fcce2d7cc9 | |||
| ec0dcb3da9 | |||
| afd7492018 | |||
| 9517ff1054 | |||
| 1ced747051 | |||
| 43819dc376 | |||
| e15dd8ecd3 | |||
| 6a38cacc1d |
+1
-1
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -482,12 +482,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 +507,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 +526,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 +540,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 +574,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 +647,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 +661,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 +678,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 +734,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 +769,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))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -1408,9 +1409,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>(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"}'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}` })),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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"],
|
||||
])
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,12 @@ export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
|
||||
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||
export type SessionInboxSyntheticPayload = {
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
export type SessionInboxCompactionPayload = {}
|
||||
|
||||
@@ -161,7 +166,12 @@ export type InstructionEntryKey = string
|
||||
|
||||
export type SessionGenerateResponse = { data: { text: string } }
|
||||
|
||||
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
export type SessionInboxSyntheticPayload1 = {
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: { [x: string]: any }
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
export type ShellInfo = {
|
||||
id: string
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.*`.
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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> = []
|
||||
@@ -948,7 +952,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([])
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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] })
|
||||
@@ -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))
|
||||
|
||||
@@ -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"] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -173,7 +173,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. */
|
||||
|
||||
@@ -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(
|
||||
@@ -323,6 +316,7 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
resume: input.resume === false ? false : undefined,
|
||||
}),
|
||||
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
|
||||
} satisfies SessionInbox.Item
|
||||
@@ -355,14 +349,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) {
|
||||
|
||||
@@ -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") })
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -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) },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -248,6 +248,16 @@ describe("Patch", () => {
|
||||
).toBe("line 1\nline 2\nadded 1\nadded 2\n")
|
||||
})
|
||||
|
||||
test.each(["", "original\n"])("preserves equal-offset insertion order and frozen chunks for %j", (original) => {
|
||||
const chunks = Object.freeze([
|
||||
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["first"]) }),
|
||||
Object.freeze({ oldLines: Object.freeze([]), newLines: Object.freeze(["second", "third"]) }),
|
||||
])
|
||||
const expected = { content: original + "first\nsecond\nthird\n", bom: false }
|
||||
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
|
||||
expect(Patch.derive("update.txt", chunks, original)).toEqual(expected)
|
||||
})
|
||||
|
||||
test("applies a pure-addition chunk after an earlier replacement", () => {
|
||||
expect(
|
||||
Patch.derive(
|
||||
|
||||
@@ -37,7 +37,7 @@ const it = testEffect(
|
||||
PluginHooks.node,
|
||||
Permission.node,
|
||||
]),
|
||||
[[Location.node, current]],
|
||||
[Location.node.replace(current)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Effect } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
|
||||
const it = testEffect(layer)
|
||||
const it = testEffect(LayerNode.compile(PluginHooks.node))
|
||||
|
||||
describe("PluginHooks", () => {
|
||||
it.effect("registers scoped session hooks and triggers them sequentially", () =>
|
||||
|
||||
@@ -27,8 +27,8 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Command.node, Mcp.node, Bus.node]), [
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Location.node, locationLayer],
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Location.node.replace(locationLayer),
|
||||
]),
|
||||
)
|
||||
|
||||
|
||||
@@ -88,12 +88,14 @@ export const PluginTestLayer = LayerNode.compile(
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
[Config.node, Config.testLayer()],
|
||||
[Mcp.node, emptyMcpLayer],
|
||||
[Generate.node, generateLayer],
|
||||
[Permission.node, permissionLayer],
|
||||
],
|
||||
{
|
||||
replacements: [
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Npm.node.replace(npmLayer),
|
||||
Config.node.replace(Config.testLayer()),
|
||||
Mcp.node.replace(emptyMcpLayer),
|
||||
Generate.node.replace(generateLayer),
|
||||
Permission.node.replace(permissionLayer),
|
||||
],
|
||||
},
|
||||
) as unknown as Layer.Layer<unknown, never>
|
||||
|
||||
@@ -23,11 +23,11 @@ const it = testEffect(
|
||||
PluginRuntime.providerNodeWithCell(cell),
|
||||
]),
|
||||
[
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(cell)],
|
||||
[PersistentPty.node, PersistentPty.configured()],
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(cell)),
|
||||
PersistentPty.node.replace(PersistentPty.configured()),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -27,12 +27,12 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
[Location.node, locationLayer],
|
||||
Location.node.replace(locationLayer),
|
||||
])
|
||||
const it = testEffect(layer)
|
||||
const real = testEffect(PluginTestLayer)
|
||||
const models = (file: string) =>
|
||||
AppNodeBuilder.build(ModelsDev.node, [[ModelsDev.node, ModelsDev.configured({ file, fetch: false })]])
|
||||
AppNodeBuilder.build(ModelsDev.node, [ModelsDev.node.replace(ModelsDev.configured({ file, fetch: false }))])
|
||||
|
||||
describe("ModelsDevPlugin", () => {
|
||||
real.effect("keeps the retained model seed unchanged across catalog replay", () =>
|
||||
|
||||
@@ -15,7 +15,7 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(Catalog.node, [[Location.node, locationLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Catalog.node, [Location.node.replace(locationLayer)]))
|
||||
|
||||
describe("VariantPlugin", () => {
|
||||
it.effect("adds GLM 5.2 variants after catalog sources", () =>
|
||||
|
||||
@@ -42,7 +42,7 @@ const http = Layer.succeed(
|
||||
export const webSearchIntegrationTest = testEffect(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node, Form.node, WebSearch.node]), [
|
||||
[Config.node, Config.testLayer()],
|
||||
Config.node.replace(Config.testLayer()),
|
||||
]),
|
||||
http,
|
||||
),
|
||||
|
||||
@@ -17,7 +17,9 @@ const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [[Location.node, locationLayer]]))
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
const ptyTest = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () {
|
||||
@@ -200,7 +202,7 @@ describe("pty", () => {
|
||||
|
||||
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
|
||||
const configuredIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [[Location.node, locationLayer]]),
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, Bus.node, ShellSelect.node]), [Location.node.replace(locationLayer)]),
|
||||
)
|
||||
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
||||
const itExpiring = testEffect(
|
||||
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
|
||||
LayerNode.compile(PtyTicket.node, {
|
||||
replacements: [PtyTicket.node.replace(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))],
|
||||
}),
|
||||
)
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { it } from "./lib/effect"
|
||||
import { readInitial, readUpdate } from "./lib/instructions"
|
||||
|
||||
const instructionsLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
||||
AppNodeBuilder.build(ReferenceInstructions.node, [[Reference.node, referenceLayer]])
|
||||
AppNodeBuilder.build(ReferenceInstructions.node, [Reference.node.replace(referenceLayer)])
|
||||
|
||||
describe("ReferenceInstructions", () => {
|
||||
it.effect("lists available references in the instructions", () =>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { it } from "./lib/effect"
|
||||
const cache = Layer.mock(RepositoryCache.Service, {
|
||||
ensure: () => Effect.die("unexpected Git materialization"),
|
||||
})
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [[RepositoryCache.node, cache]])
|
||||
const referenceLayer = AppNodeBuilder.build(Reference.node, [RepositoryCache.node.replace(cache)])
|
||||
|
||||
describe("Reference", () => {
|
||||
it.effect("registers normalized sources for the owning scope", () =>
|
||||
|
||||
@@ -227,8 +227,8 @@ describe("RepositoryCache", () => {
|
||||
|
||||
function cacheLayer(root: string) {
|
||||
return AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node]), [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
[Database.node, Database.configured({ path: path.join(root, "cache.sqlite") })],
|
||||
Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })),
|
||||
Database.node.replace(Database.configured({ path: path.join(root, "cache.sqlite") })),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [[Location.node, tempLocationLayer]]))
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)]))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
it.live("globs files as an array", () =>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { testEffect } from "./lib/effect"
|
||||
const ref = Location.Ref.make({ directory: AbsolutePath.make("/rpc-project") })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Rpc.node, Bus.node, Location.node]), [
|
||||
[Location.node, Layer.succeed(Location.Service, location(ref))],
|
||||
Location.node.replace(Layer.succeed(Location.Service, location(ref))),
|
||||
]),
|
||||
)
|
||||
const Echo = Rpc.define({
|
||||
@@ -200,8 +200,7 @@ describe("Rpc", () => {
|
||||
events: {},
|
||||
})
|
||||
yield* rpc.register(Failing, {
|
||||
standard: (_input, context) =>
|
||||
Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
|
||||
standard: (_input, context) => Effect.fail(context.error("missing", "Missing", { attempts: "2" })),
|
||||
effect: (_input, context) => Effect.fail(context.error("invalid", "Invalid", { count: 3 })),
|
||||
})
|
||||
|
||||
@@ -269,7 +268,6 @@ describe("Rpc", () => {
|
||||
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", "42").pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* rpc.call(Raw.id, "count", 0).pipe(Effect.exit))).toBe(true)
|
||||
expect(Exit.isFailure(yield* registration.events.emit("counted", { count: 0 }).pipe(Effect.exit))).toBe(true)
|
||||
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -330,10 +328,12 @@ describe("Rpc", () => {
|
||||
const bus = yield* Bus.Service
|
||||
const otherRef = Location.Ref.make({ directory: ref.directory, workspaceID: Workspace.ID.make("wrk_other") })
|
||||
const otherContext = yield* Layer.build(
|
||||
LayerNode.compile(Rpc.node, [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
[Location.node, Layer.succeed(Location.Service, location(otherRef))],
|
||||
]).pipe(Layer.fresh),
|
||||
LayerNode.compile(Rpc.node, {
|
||||
replacements: [
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Location.node.replace(Layer.succeed(Location.Service, location(otherRef))),
|
||||
],
|
||||
}).pipe(Layer.fresh),
|
||||
)
|
||||
const other = Context.get(otherContext, Rpc.Service)
|
||||
const first = yield* rpc.register(Echo, { echo: () => Effect.succeed("first") })
|
||||
|
||||
@@ -65,9 +65,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[LocationServiceMap.node, locations],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
LocationServiceMap.node.replace(locations),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -88,10 +88,7 @@ const it = testEffect(
|
||||
SessionCompaction.node,
|
||||
SessionModelRequest.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
],
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), llmClient.replace(client)],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -51,30 +51,27 @@ const it = testEffect(
|
||||
InstructionEntry.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
const liveIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
[Bus.node.replace(Bus.configured({ persist: true })), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
),
|
||||
)
|
||||
const projectIt = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
// Project adoption needs plain-prompt admission, not live plugin/provider startup.
|
||||
[LocationServiceMap.node, promptLocationNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
LocationServiceMap.node.replace(promptLocationNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -968,8 +965,8 @@ describe("Session.create", () => {
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
Database.node.replace(Database.configured({ path: path.join(tmp.path, "target.sqlite") })),
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Instance } from "@opencode-ai/core/instance/service"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { KV } from "@opencode-ai/core/kv"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
@@ -1371,7 +1372,12 @@ function buildExecution(
|
||||
Layer.provide(Layer.succeed(Bus.Service, bus)),
|
||||
Layer.provide(Layer.succeed(SessionStore.Service, store)),
|
||||
Layer.provide(Layer.succeed(Job.Service, jobs)),
|
||||
Layer.provide(locations),
|
||||
// Do not reuse the outer harness's selector with its already-captured Location map.
|
||||
Layer.provide(
|
||||
LayerNode.compile(Instance.byLocationNode, {
|
||||
replacements: [LocationServiceMap.node.replace(locations)],
|
||||
}).pipe(Layer.fresh),
|
||||
),
|
||||
),
|
||||
scope,
|
||||
)
|
||||
|
||||
@@ -142,17 +142,17 @@ const it = testEffect(
|
||||
SessionGenerateNode.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, builtins],
|
||||
[InstructionDiscovery.node, discovery],
|
||||
[SkillInstructions.node, skills],
|
||||
[ReferenceInstructions.node, references],
|
||||
[McpInstructions.node, mcp],
|
||||
[PluginSupervisor.node, plugins],
|
||||
[Tool.node, tools],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
llmClient.replace(client),
|
||||
SessionRunnerModel.node.replace(models),
|
||||
InstructionBuiltIns.node.replace(builtins),
|
||||
InstructionDiscovery.node.replace(discovery),
|
||||
SkillInstructions.node.replace(skills),
|
||||
ReferenceInstructions.node.replace(references),
|
||||
McpInstructions.node.replace(mcp),
|
||||
PluginSupervisor.node.replace(plugins),
|
||||
Tool.node.replace(tools),
|
||||
Location.node.replace(Location.boundNode({ directory: AbsolutePath.make("/project") })),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ const readToolNode = makeLocationNode({
|
||||
|
||||
const permission = permissionLayer({ assert: () => Effect.void })
|
||||
const config = Config.testLayer()
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [Config.node.replace(config)])
|
||||
|
||||
const testLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
@@ -74,12 +74,12 @@ const testLayer = AppNodeBuilder.build(
|
||||
Image.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[Location.node, tempLocationLayer],
|
||||
[Permission.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
Location.node.replace(tempLocationLayer),
|
||||
Permission.node.replace(permission),
|
||||
Config.node.replace(config),
|
||||
Image.node.replace(imageLayer),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user