mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-01 06:26:24 +00:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05aa22a8e2 | ||
|
|
1e3afd7398 | ||
|
|
eef759d9c7 | ||
|
|
a57fcecb95 | ||
|
|
49dd2cea34 | ||
|
|
fac875dba0 | ||
|
|
566ca864a0 | ||
|
|
5df9cecf03 | ||
|
|
a68fe8a97d | ||
|
|
c17c104827 | ||
|
|
5d4cc4a804 | ||
|
|
1f04baa684 | ||
|
|
3e9b009642 | ||
|
|
36ac35a7c8 | ||
|
|
197d28e033 | ||
|
|
fcce2d7cc9 | ||
|
|
ec0dcb3da9 | ||
|
|
afd7492018 | ||
|
|
9517ff1054 | ||
|
|
1ced747051 | ||
|
|
43819dc376 | ||
|
|
e15dd8ecd3 | ||
|
|
6a38cacc1d | ||
|
|
d609752891 | ||
|
|
5894e46688 | ||
|
|
327dc809c5 | ||
|
|
e9f7331516 | ||
|
|
8be3ce8b6c | ||
|
|
30721b8b5d |
+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",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { loadInitialLocale } from "@/runtime/i18n/language"
|
||||
import { PlatformProvider } from "@/runtime/platform/platform"
|
||||
import { createWebPlatform } from "@/runtime/platform/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "@/runtime/platform/pwa"
|
||||
import en from "@/runtime/i18n/en"
|
||||
import zh from "@/runtime/i18n/zh"
|
||||
import { authFromToken } from "@/runtime/server/api"
|
||||
@@ -71,6 +72,8 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
void loadInitialLocale().then((locale) => {
|
||||
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
|
||||
clearAuthToken()
|
||||
const standalone = isStandalone()
|
||||
if (standalone) restorePwaRoute()
|
||||
const server: ServerConnection.Http = {
|
||||
type: "http",
|
||||
authToken: !!auth,
|
||||
@@ -87,7 +90,9 @@ if (root instanceof HTMLElement && root.dataset.opencodeMounted === undefined) {
|
||||
defaultServer={ServerConnection.Key.make(web.defaultServerUrl)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
/>
|
||||
>
|
||||
{standalone && <PwaRoutePersistence />}
|
||||
</AppInterface>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useLocation } from "@solidjs/router"
|
||||
import { createEffect } from "solid-js"
|
||||
|
||||
const LAST_ROUTE_KEY = "opencode.pwa.last-route"
|
||||
|
||||
export function isStandalone() {
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
("standalone" in navigator && navigator.standalone === true)
|
||||
)
|
||||
}
|
||||
|
||||
export function restorePwaRoute() {
|
||||
if (location.pathname !== "/" || location.search || location.hash) return
|
||||
try {
|
||||
const value = localStorage.getItem(LAST_ROUTE_KEY)
|
||||
if (!value) return
|
||||
const url = new URL(value, location.origin)
|
||||
if (url.origin !== location.origin || url.searchParams.has("auth_token")) return
|
||||
if (
|
||||
url.pathname !== "/" &&
|
||||
url.pathname !== "/new-session" &&
|
||||
!/^\/server\/[^/]+\/session\/[^/]+$/.test(url.pathname)
|
||||
)
|
||||
return
|
||||
history.replaceState(history.state, "", url.pathname + url.search + url.hash)
|
||||
} catch {
|
||||
// Storage may be unavailable; keep the launch URL in that case.
|
||||
}
|
||||
}
|
||||
|
||||
export function PwaRoutePersistence() {
|
||||
const location = useLocation()
|
||||
createEffect(() => {
|
||||
const value = location.pathname + location.search + location.hash
|
||||
try {
|
||||
localStorage.setItem(LAST_ROUTE_KEY, value)
|
||||
} catch {
|
||||
// Navigation must still work when storage is unavailable or full.
|
||||
}
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, expect, test } from "bun:test"
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { createComponent, render } from "solid-js/web"
|
||||
import { isStandalone, PwaRoutePersistence, restorePwaRoute } from "../src/runtime/platform/pwa"
|
||||
|
||||
const key = "opencode.pwa.last-route"
|
||||
const originalUrl = window.location.href
|
||||
|
||||
beforeEach(() => {
|
||||
window.location.href = "http://localhost/"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(key)
|
||||
window.location.href = originalUrl
|
||||
})
|
||||
|
||||
test("normal browser windows are not standalone", () => {
|
||||
expect(isStandalone()).toBe(false)
|
||||
})
|
||||
|
||||
test("restores the last PWA route including query and hash without adding history", () => {
|
||||
window.history.replaceState({ retained: true }, "", "http://localhost/")
|
||||
const length = window.history.length
|
||||
localStorage.setItem(key, "/server/local/session/session-1?view=files#file")
|
||||
|
||||
restorePwaRoute()
|
||||
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(
|
||||
"/server/local/session/session-1?view=files#file",
|
||||
)
|
||||
expect(window.history.length).toBe(length)
|
||||
expect(window.history.state).toEqual({ retained: true })
|
||||
})
|
||||
|
||||
test("preserves explicit launch routes, queries, and hashes", () => {
|
||||
localStorage.setItem(key, "/server/local/session/saved")
|
||||
for (const route of ["/server/local/session/linked", "/new-session?draftId=123", "/?launch=1", "/#launch"]) {
|
||||
window.history.replaceState(null, "", `http://localhost${route}`)
|
||||
restorePwaRoute()
|
||||
expect(window.location.pathname + window.location.search + window.location.hash).toBe(route)
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores missing, invalid, external, and auth-bearing saved routes", () => {
|
||||
window.history.replaceState(null, "", "http://localhost/")
|
||||
restorePwaRoute()
|
||||
expect(window.location.pathname).toBe("/")
|
||||
|
||||
for (const value of [
|
||||
"/removed-route",
|
||||
"https://example.com/new-session",
|
||||
"//example.com/new-session",
|
||||
"http://[",
|
||||
"/new-session?auth_token=secret",
|
||||
]) {
|
||||
localStorage.setItem(key, value)
|
||||
restorePwaRoute()
|
||||
expect(window.location.href).toBe("http://localhost/")
|
||||
}
|
||||
})
|
||||
|
||||
test("persists router navigation including returning home", async () => {
|
||||
const host = document.createElement("div")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: "/new-session?draftId=123", replace: true, scroll: false })
|
||||
const dispose = render(() => createComponent(MemoryRouter, { history, root: PwaRoutePersistence }), host)
|
||||
try {
|
||||
expect(localStorage.getItem(key)).toBe("/new-session?draftId=123")
|
||||
history.set({ value: "/server/local/session/next#file", scroll: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(localStorage.getItem(key)).toBe("/server/local/session/next#file")
|
||||
history.set({ value: "/", scroll: false })
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(localStorage.getItem(key)).toBe("/")
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
@@ -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}` })),
|
||||
|
||||
@@ -98,12 +98,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),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -180,7 +180,7 @@ export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false
|
||||
|
||||
export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
|
||||
try {
|
||||
return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
|
||||
return renderSchema(schema, { definitions: {}, pretty })
|
||||
} catch {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -216,6 +216,35 @@ describe("pretty signature rendering", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("JSON Schema definition scope", () => {
|
||||
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
|
||||
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("string")
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe("string")
|
||||
|
||||
const overridden = { ...schema, $defs: { Value: { type: "number" } } }
|
||||
expect(jsonSchemaToTypeScript(overridden)).toBe("number")
|
||||
expect(jsonSchemaToTypeScript(overridden, true)).toBe("number")
|
||||
})
|
||||
|
||||
test.each(["definitions", "$defs"])("nested %s shadow inherited definitions without affecting siblings", (key) => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
definitions: { Inherited: { type: "string" } },
|
||||
$defs: { Value: { type: "number" } },
|
||||
properties: {
|
||||
nested: { $ref: `#/${key}/Value`, [key]: { Value: { type: "boolean" } } },
|
||||
inherited: { $ref: "#/definitions/Inherited" },
|
||||
sibling: { $ref: "#/$defs/Value" },
|
||||
},
|
||||
}
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("{ nested?: boolean; inherited?: string; sibling?: number }")
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
["{", " nested?: boolean,", " inherited?: string,", " sibling?: number,", "}"].join("\n"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-identifier property names render as quoted keys", () => {
|
||||
// MCP-style schemas routinely carry property names that are not bare TS identifiers
|
||||
// (`foo-bar`, `@type`, dotted names); the rendered signature must quote them so the
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -54,9 +54,8 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
const matching = state
|
||||
.get()
|
||||
.formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath)))
|
||||
const extension = path.extname(filepath)
|
||||
const matching = state.get().formatters.filter((formatter) => formatter.extensions.includes(extension))
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
|
||||
@@ -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(
|
||||
@@ -355,14 +348,12 @@ export const make = Effect.fn("Session.make")(function* (servicesFor: (ref: Loca
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
return yield* SessionRevert.Service.use((revert) =>
|
||||
revert.stage({ session, messageID: input.messageID, files: input.files }),
|
||||
).pipe(Effect.provide(servicesFor(session.location)))
|
||||
).pipe(instances.provide(session))
|
||||
})
|
||||
const clear = Effect.fn("Session.revert.clear")(function* (sessionID: SessionSchema.ID) {
|
||||
const session = yield* get(sessionID)
|
||||
if (yield* execution.isActive(sessionID)) return yield* new BusyError({ sessionID })
|
||||
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(
|
||||
Effect.provide(servicesFor(session.location)),
|
||||
)
|
||||
yield* SessionRevert.Service.use((revert) => revert.clear(session)).pipe(instances.provide(session))
|
||||
return yield* execution.wake(sessionID)
|
||||
})
|
||||
const commit = Effect.fn("Session.revert.commit")(function* (sessionID: SessionSchema.ID) {
|
||||
|
||||
@@ -95,7 +95,7 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
const remaining = limit - outputBytes
|
||||
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
|
||||
output.push(next)
|
||||
outputBytes += encoder.encode(next).byteLength
|
||||
outputBytes += bytes.byteLength <= remaining ? bytes.byteLength : encoder.encode(next).byteLength
|
||||
last = next.at(-1) ?? last
|
||||
}
|
||||
const appendRaw = (value: string) => {
|
||||
|
||||
@@ -112,7 +112,6 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({ message: "patch rejected: empty patch" })
|
||||
}
|
||||
const prepared: Prepared[] = []
|
||||
const updates = new Map<string, string>()
|
||||
const resolveTarget = Effect.fnUntraced(function* (value: string) {
|
||||
const target = yield* mutation.resolve({ path: value, kind: "file" })
|
||||
if (!target.externalDirectory) return target
|
||||
@@ -131,6 +130,11 @@ export const Plugin = {
|
||||
for (const hunk of hunks) {
|
||||
yield* Effect.gen(function* () {
|
||||
const target = yield* resolveTarget(hunk.path)
|
||||
if (prepared.some((change) => change.target.absolute === target.absolute)) {
|
||||
return yield* new ToolFailure({
|
||||
message: `patch verification failed: invalid patch: multiple operations target ${target.absolute}`,
|
||||
})
|
||||
}
|
||||
if (hunk.type === "add") {
|
||||
const content =
|
||||
hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
@@ -155,20 +159,15 @@ export const Plugin = {
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.absolute)
|
||||
const original =
|
||||
previous ??
|
||||
(yield* Effect.gen(function* () {
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const content = yield* FileMutation.readText(environment.files, target.absolute).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.absolute}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const original = Bom.join(content.text, content.bom)
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
@@ -183,7 +182,6 @@ export const Plugin = {
|
||||
after: update.content,
|
||||
moveTarget,
|
||||
})
|
||||
if (!moveTarget) updates.set(target.absolute, Patch.joinBom(update.content, update.bom))
|
||||
}).pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof ToolFailure
|
||||
|
||||
@@ -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
|
||||
@@ -58,6 +58,34 @@ function withFormatter<A, E, R>(
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
;[
|
||||
{ file: "test.match", extension: ".match", matches: true },
|
||||
{ file: "test.other", extension: ".match", matches: false },
|
||||
{ file: "test.MATCH", extension: ".match", matches: false },
|
||||
{ file: "test.MATCH", extension: ".MATCH", matches: true },
|
||||
{ file: ".match", extension: ".match", matches: false },
|
||||
{ file: ".match", extension: "", matches: true },
|
||||
{ file: "README", extension: ".match", matches: false },
|
||||
{ file: "README", extension: "", matches: true },
|
||||
{ file: "test.part.match", extension: ".match", matches: true },
|
||||
{ file: "test.part.match", extension: ".part.match", matches: false },
|
||||
].forEach((entry) =>
|
||||
it.live(`matches ${entry.file} against ${JSON.stringify(entry.extension)}: ${entry.matches}`, () =>
|
||||
withFormatter(
|
||||
{
|
||||
matching: {
|
||||
command: [process.execPath, "-e", "process.exit(0)", "$FILE"],
|
||||
extensions: [entry.extension],
|
||||
},
|
||||
},
|
||||
(formatter, directory) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* formatter.file(path.join(directory, entry.file))).toBe(entry.matches)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not run formatters marked as disabled in config", () =>
|
||||
withFormatter(
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -30,10 +30,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,
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(
|
||||
Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
@@ -45,7 +44,7 @@ const it = testEffect(
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -154,8 +153,8 @@ describe("Session.updateMessage", () => {
|
||||
const target = 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 })),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -20,9 +20,13 @@ import { testEffect } from "./lib/effect"
|
||||
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
|
||||
|
||||
const it = testEffect(
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), [
|
||||
[SessionModelTransport.node, SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") })],
|
||||
]),
|
||||
LayerNode.compile(LayerNode.group([SessionModelRequest.node, PluginHooks.node]), {
|
||||
replacements: [
|
||||
SessionModelTransport.node.replace(
|
||||
SessionModelTransport.makeLayer({ open: () => Effect.die("Unexpected connection") }),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const requestInput = (model: LanguageModel) => ({
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionRunner } from "@opencode-ai/core/session/runner/index"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
@@ -24,9 +25,35 @@ import { globalProjectNode } from "./lib/project"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer)],
|
||||
),
|
||||
)
|
||||
const itWithActiveExecution = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
SessionExecution.node,
|
||||
Session.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
Project.node.replace(globalProjectNode),
|
||||
LocationServiceMap.node.replace(
|
||||
Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
(ref: Location.Ref) =>
|
||||
Layer.merge(
|
||||
LayerNode.compile(Location.boundNode(ref), {
|
||||
replacements: [Project.node.replace(globalProjectNode)],
|
||||
}),
|
||||
Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }),
|
||||
) as unknown as Layer.Layer<LocationServices>,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -40,9 +67,9 @@ const itWithUnavailableDestination = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[LocationServiceMap.node, unavailableLocations],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
LocationServiceMap.node.replace(unavailableLocations),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -104,6 +131,41 @@ describe("Session.move", () => {
|
||||
),
|
||||
)
|
||||
|
||||
itWithActiveExecution.live("defers an active move when the source directory no longer exists", () =>
|
||||
tmpdirScoped().pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const execution = yield* SessionExecution.Service
|
||||
const source = AbsolutePath.make(path.join(tmp.path, "source"))
|
||||
const destination = AbsolutePath.make(tmp.path)
|
||||
yield* Effect.promise(() => mkdir(source))
|
||||
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
|
||||
|
||||
// Hold real execution open so the move cannot be consumed before admission is checked.
|
||||
yield* execution.wake(created.id)
|
||||
expect(yield* execution.isActive(created.id)).toBe(true)
|
||||
yield* Effect.promise(() => rm(source, { recursive: true }))
|
||||
|
||||
yield* session.move({ sessionID: created.id, directory: destination })
|
||||
|
||||
expect((yield* session.get(created.id)).location.directory).toBe(source)
|
||||
expect(yield* session.inbox(created.id)).toMatchObject([
|
||||
{
|
||||
type: "move",
|
||||
delivery: "steer",
|
||||
payload: { location: { directory: destination } },
|
||||
},
|
||||
])
|
||||
expect(yield* execution.isActive(created.id)).toBe(true)
|
||||
|
||||
yield* execution.interrupt(created.id)
|
||||
yield* execution.awaitIdle(created.id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("keeps a moved session out of its former directory's new identity", () =>
|
||||
tmpdirScoped().pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Bus } from "../src/bus.js"
|
||||
import { Database } from "../src/database/database.js"
|
||||
import { EventTable } from "../src/event/sql.js"
|
||||
import { Image } from "../src/image.js"
|
||||
import { Instance } from "../src/instance/service.js"
|
||||
import { Location } from "../src/location.js"
|
||||
import { PluginHooks } from "../src/plugin/hooks.js"
|
||||
import { PluginSupervisor } from "../src/plugin/supervisor-service.js"
|
||||
@@ -51,10 +52,9 @@ const it = testEffect(
|
||||
SessionInbox.node,
|
||||
FSUtil.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
],
|
||||
{
|
||||
replacements: [Bus.node.replace(Bus.configured({ persist: true })), Global.node.replace(tempGlobalLayer)],
|
||||
},
|
||||
),
|
||||
)
|
||||
const sessionID = SessionSchema.ID.make("ses_owned")
|
||||
@@ -130,7 +130,7 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
Layer.mock(Image.Service, {}),
|
||||
options?.shell ?? Layer.mock(Shell.Service, {}),
|
||||
)
|
||||
const servicesFor = (ref: Location.Ref): Layer.Layer<Session.Services> => {
|
||||
const servicesFor = (ref: Location.Ref) => {
|
||||
locations.push(ref)
|
||||
return Layer.merge(SessionRevert.layer, SessionPrompt.layer).pipe(
|
||||
Layer.provideMerge(
|
||||
@@ -159,10 +159,20 @@ const setup = Effect.fnUntraced(function* (options?: {
|
||||
Layer.fresh,
|
||||
)
|
||||
}
|
||||
const sessions = yield* Session.make(servicesFor).pipe(
|
||||
const sessions = yield* Session.make().pipe(
|
||||
Effect.satisfiesServicesType<
|
||||
Bus.Service | SessionStore.Service | SessionExecution.Service | SessionInbox.Service | Scope.Scope
|
||||
| Bus.Service
|
||||
| SessionStore.Service
|
||||
| Instance.Service
|
||||
| SessionExecution.Service
|
||||
| SessionInbox.Service
|
||||
| Scope.Scope
|
||||
>(),
|
||||
Effect.provideService(Instance.Service, {
|
||||
// This fixture supplies only the instance services exercised by Session.
|
||||
provide: (session) => Effect.provide(servicesFor(session.location) as Layer.Layer<Instance.Services>),
|
||||
provideIfLoaded: () => () => Effect.die("Unexpected loaded-only instance lookup"),
|
||||
}),
|
||||
Effect.provideService(SessionExecution.Service, options?.execution ?? execution),
|
||||
)
|
||||
return { sessions, hooks, locations, flushes, resumes, wakes, db: database.db, bus, store }
|
||||
|
||||
@@ -35,10 +35,10 @@ import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[[Bus.node, Bus.configured({ persist: true })]],
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
),
|
||||
)
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [[SessionExecution.node, SessionExecution.noopLayer]])
|
||||
const sessionsLayer = AppNodeBuilder.build(Session.node, [SessionExecution.node.replace(SessionExecution.noopLayer)])
|
||||
const sessionID = Session.ID.make("ses_projector_test")
|
||||
const created = DateTime.makeUnsafe(0)
|
||||
const model = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }
|
||||
|
||||
@@ -38,11 +38,11 @@ const it = testEffect(
|
||||
PluginRuntime.providerNodeWithCell(runtime),
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[Global.node, tempGlobalLayer],
|
||||
[Watcher.node, Watcher.configured({ enabled: false })],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(runtime)],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
PluginRuntime.node.replace(PluginRuntime.layerWithCell(runtime)),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -95,9 +95,9 @@ const locations = (references: Layer.Layer<Reference.Service>) =>
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
references,
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), [
|
||||
[Bus.node, Layer.succeed(Bus.Service, bus)],
|
||||
]),
|
||||
LayerNode.compile(LayerNode.group([PluginHooks.node, Skill.node]), {
|
||||
replacements: [Bus.node.replace(Layer.succeed(Bus.Service, bus))],
|
||||
}),
|
||||
Layer.mock(Image.Service, {
|
||||
normalize: (_resource, content) =>
|
||||
ready
|
||||
@@ -131,9 +131,9 @@ const sessionLayer = (references = Layer.mock(Reference.Service, { refresh: () =
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[SessionExecution.node, execution],
|
||||
[LocationServiceMap.node, locations(references)],
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
SessionExecution.node.replace(execution),
|
||||
LocationServiceMap.node.replace(locations(references)),
|
||||
],
|
||||
)
|
||||
const it = testEffect(sessionLayer())
|
||||
@@ -298,13 +298,14 @@ describe("Session.prompt", () => {
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(Reference.node, [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
[
|
||||
RepositoryCache.node,
|
||||
Global.node.replace(
|
||||
Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") }),
|
||||
),
|
||||
RepositoryCache.node.replace(
|
||||
Layer.succeed(RepositoryCache.Service, {
|
||||
ensure: (input) => cache.ensure(input).pipe(Effect.tap(() => Queue.offer(completed, undefined))),
|
||||
}),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -312,7 +313,7 @@ describe("Session.prompt", () => {
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([RepositoryCache.node, KV.node, EffectFlock.node]), [
|
||||
[Global.node, Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })],
|
||||
Global.node.replace(Global.layerWith({ state: path.join(root, "state"), repos: path.join(root, "repos") })),
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Option, RcMap, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Instance } from "@opencode-ai/core/instance/service"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -19,12 +20,18 @@ import { globalProjectNode } from "./lib/project"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
|
||||
const closed: Session.ID[] = []
|
||||
const transport = Layer.succeed(
|
||||
const transportScopes = new Set<Scope.Scope>()
|
||||
const transport = Layer.effect(
|
||||
SessionModelTransport.Service,
|
||||
SessionModelTransport.Service.of({
|
||||
bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
|
||||
close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
|
||||
closeAll: Effect.void,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
transportScopes.add(scope)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => transportScopes.delete(scope)))
|
||||
return SessionModelTransport.Service.of({
|
||||
bind: () => ({ execute: () => Effect.die("Unexpected WebSocket execution") }),
|
||||
close: (sessionID) => Effect.sync(() => closed.push(sessionID)),
|
||||
closeAll: Effect.void,
|
||||
})
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
@@ -36,12 +43,13 @@ const it = testEffect(
|
||||
SessionStore.node,
|
||||
SessionEnvironment.node,
|
||||
Session.node,
|
||||
Instance.byLocationNode,
|
||||
LocationServiceMap.node,
|
||||
]),
|
||||
[
|
||||
[Project.node, globalProjectNode],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
[SessionModelTransport.node, transport],
|
||||
Project.node.replace(globalProjectNode),
|
||||
SessionExecution.node.replace(SessionExecution.noopLayer),
|
||||
SessionModelTransport.node.replace(transport),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -72,6 +80,27 @@ describe("Session.remove", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("removes unloaded sessions and children without initializing an instance", () =>
|
||||
Effect.gen(function* () {
|
||||
const temporary = yield* tmpdirScoped()
|
||||
const sessions = yield* Session.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const parent = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }),
|
||||
})
|
||||
yield* sessions.create({ parentID: parent.id })
|
||||
closed.length = 0
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
|
||||
yield* sessions.remove(parent.id)
|
||||
|
||||
expect(closed).toEqual([])
|
||||
expect(transportScopes.size).toBe(0)
|
||||
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
|
||||
expect((yield* sessions.list()).data).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when the session does not exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
@@ -84,3 +113,40 @@ describe("Session.remove", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Instance.provideIfLoaded", () => {
|
||||
it.live("skips absent instances and scopes loaded borrows without replacing the caller's Scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const temporary = yield* tmpdirScoped()
|
||||
const sessions = yield* Session.Service
|
||||
const instances = yield* Instance.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const session = yield* sessions.create({
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make(temporary.path) }),
|
||||
})
|
||||
const absent = Effect.die("An unloaded instance must not run the effect").pipe(instances.provideIfLoaded(session))
|
||||
|
||||
expect(yield* absent).toEqual(Option.none())
|
||||
expect(transportScopes.size).toBe(0)
|
||||
yield* Location.Service.pipe(instances.provide(session))
|
||||
expect(transportScopes.size).toBe(1)
|
||||
expect(yield* Effect.void.pipe(instances.provideIfLoaded(session))).toEqual(Option.some(undefined))
|
||||
const failure = new Error("Borrowed operation failed")
|
||||
expect(yield* Effect.fail(failure).pipe(instances.provideIfLoaded(session), Effect.flip)).toBe(failure)
|
||||
|
||||
const borrowed = yield* Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const callerScope = yield* Scope.Scope
|
||||
expect(callerScope).toBe(scope)
|
||||
yield* locations.invalidate(session.location)
|
||||
expect(transportScopes.size).toBe(1)
|
||||
return location.directory
|
||||
}).pipe(instances.provideIfLoaded(session), Effect.satisfiesServicesType<Scope.Scope>())
|
||||
|
||||
expect(borrowed).toEqual(Option.some(session.location.directory))
|
||||
expect(transportScopes.size).toBe(0)
|
||||
expect(yield* absent).toEqual(Option.none())
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user