From beaaa174ea9c77984eec91d73f20dc161028bd8f Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:28:01 +0200 Subject: [PATCH 001/303] fix(app): disable empty server chevron (#34292) --- packages/app/src/pages/home.tsx | 63 ++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 90c074210ca6..f7574880826f 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -569,13 +569,14 @@ function HomeProjectColumn(props: { const key = ServerConnection.key(item) const healthy = () => !!global.servers.health[key]?.healthy const serverCtx = global.ensureServerCtx(item) + const projects = () => serverCtx.projects.list() + const hasProjects = () => projects().length > 0 const collapsed = () => !!state().collapsed[key] return (
setState("collapsed", key, !state().collapsed[key])} language={props.language} /> - +
- +
) @@ -635,7 +636,6 @@ function HomeUtilityNav(props: { function HomeServerRow(props: { server: ServerConnection.Any selected: boolean - healthy: boolean collapsed: boolean health: ServerHealth | undefined controller: ReturnType @@ -645,39 +645,46 @@ function HomeServerRow(props: { toggleCollapsed: () => void language: ReturnType }) { + const global = useGlobal() const [state, setState] = createStore({ menuOpen: false }) + const healthy = () => !!props.health?.healthy + const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0 return (
) + const circleV2 = () => ( +
+ +
+ ) const tooltipValue = () => (
@@ -113,6 +122,16 @@ export function SessionContextUsage(props: SessionContextUsageProps) { {circle()} + + +
{(id) => ( -
- +
+ - { - setTitle("menuOpen", open) - if (open) return - }} + { + setTitle("menuOpen", open) + if (open) return + }} + > + { + more = el + }} + /> + + { + if (title.pendingRename) { + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + return + } + if (title.pendingShare) { + event.preventDefault() + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + } + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + + { + setTitle({ pendingShare: true, menuOpen: false }) + }} + > + + {language.t("session.share.action.share")} + + + + void archiveSession(id)}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + + } > - { - more = el + { + setTitle("menuOpen", open) + if (open) return }} - /> - - { - if (title.pendingRename) { - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - return - } - if (title.pendingShare) { - event.preventDefault() - requestAnimationFrame(() => { - setShare({ open: true, dismiss: null }) - setTitle("pendingShare", false) - }) - } + > + } + variant="ghost-muted" + size="large" + state={share.open || title.pendingShare ? "pressed" : undefined} + aria-label={language.t("common.moreOptions")} + aria-expanded={title.menuOpen || share.open || title.pendingShare} + ref={(el: HTMLButtonElement) => { + more = el }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) + /> + + { + if (title.pendingRename) { + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + return + } + if (title.pendingShare) { + event.preventDefault() + requestAnimationFrame(() => { + setShare({ open: true, dismiss: null }) + setTitle("pendingShare", false) + }) + } }} > - {language.t("common.rename")} - - - { - setTitle({ pendingShare: true, menuOpen: false }) + setTitle("pendingRename", true) + setTitle("menuOpen", false) }} > - - {language.t("session.share.action.share")} - - - - void archiveSession(id)}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - + {language.t("common.rename")} + + + { + setTitle({ pendingShare: true, menuOpen: false }) + }} + > + {language.t("session.share.action.share")}... + + + void archiveSession(id)}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")}... + + + + + more} placement="bottom-end" - gutter={4} + gutter={settings.general.newLayoutDesigns() ? 6 : 4} modal={false} onOpenChange={(open) => { if (open) setShare("dismiss", null) @@ -1461,6 +1610,10 @@ export function MessageTimeline(props: { { setShare({ dismiss: "escape", open: false }) @@ -1478,24 +1631,90 @@ export function MessageTimeline(props: { setShare("dismiss", null) }} > -
-
-
+ +
+
+ {language.t("session.share.popover.title")} +
+
+ {shareUrl() + ? language.t("session.share.popover.description.shared") + : language.t("session.share.popover.description.unshared")} +
+
+
+ + {shareMutation.isPending + ? language.t("session.share.action.publishing") + : language.t("session.share.action.publish")} + + } + > +
+ +
+ + +
+
+
+
+
+ } + > +
+
{language.t("session.share.popover.title")}
-
+
{shareUrl() ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared")}
-
+
+ } >
- -
-
+
+ {unshareMutation.isPending ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")} - - +
-
+ diff --git a/packages/ui/src/v2/components/button-v2.css b/packages/ui/src/v2/components/button-v2.css index 822904d0e3ce..4e5edad9edc4 100644 --- a/packages/ui/src/v2/components/button-v2.css +++ b/packages/ui/src/v2/components/button-v2.css @@ -113,6 +113,26 @@ cursor: not-allowed; } +/* Outline */ +[data-component="button-v2"][data-variant="outline"] { + background-color: transparent; + color: var(--v2-text-text-base); + box-shadow: inset 0 0 0 1px var(--v2-border-border-muted); +} + +[data-component="button-v2"][data-variant="outline"]:is(:hover, [data-state="hover"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-hover); +} + +[data-component="button-v2"][data-variant="outline"]:is(:active, [data-state="pressed"]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + +[data-component="button-v2"][data-variant="outline"]:is(:disabled, [data-state="disabled"]) { + opacity: 0.5; + cursor: not-allowed; +} + /* Contrast */ [data-component="button-v2"][data-variant="contrast"] { background-image: diff --git a/packages/ui/src/v2/components/button-v2.tsx b/packages/ui/src/v2/components/button-v2.tsx index eef46b9c4a2f..0900d46d09a6 100644 --- a/packages/ui/src/v2/components/button-v2.tsx +++ b/packages/ui/src/v2/components/button-v2.tsx @@ -7,7 +7,7 @@ export interface ButtonV2Props extends ComponentProps, Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" - variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted" + variant?: "neutral" | "danger" | "contrast" | "ghost" | "ghost-muted" | "outline" icon?: IconProps["name"] } diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css index 15b52d9482d3..b74d2723972b 100644 --- a/packages/ui/src/v2/components/dialog-v2.css +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -59,6 +59,7 @@ margin: 0; flex: 1; min-width: 0; + user-select: none; font-weight: 530; font-size: 13px; line-height: 16px; @@ -80,6 +81,7 @@ margin: 0; flex: none; flex-grow: 0; + user-select: none; font-weight: 530; font-size: 15px; line-height: 20px; @@ -91,6 +93,7 @@ [data-slot="dialog-description"] { flex: none; flex-grow: 0; + user-select: none; font-weight: 440; font-size: 13px; line-height: 20px; diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index c62dd43c7487..06821f0ffe0f 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -110,3 +110,5 @@ export function Dialog(props: DialogProps) {
) } + +export const DialogV2 = Dialog diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index f986624eace9..63486caac10b 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -85,6 +85,14 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "outline-copy": { + viewBox: "0 0 16 16", + body: ``, + }, + "outline-square-arrow": { + viewBox: "0 0 16 16", + body: ``, + }, archive: { viewBox: "0 0 16 16", body: ``, diff --git a/packages/ui/src/v2/components/progress-circle-v2.css b/packages/ui/src/v2/components/progress-circle-v2.css new file mode 100644 index 000000000000..2f84ed4c2958 --- /dev/null +++ b/packages/ui/src/v2/components/progress-circle-v2.css @@ -0,0 +1,13 @@ +[data-component="progress-circle-v2"] { + display: block; + transform: rotate(-90deg); +} + +[data-component="progress-circle-v2"] [data-slot="progress-circle-v2-background"] { + stroke: var(--v2-background-bg-layer-04); +} + +[data-component="progress-circle-v2"] [data-slot="progress-circle-v2-progress"] { + stroke: var(--v2-icon-icon-muted); + transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1); +} diff --git a/packages/ui/src/v2/components/progress-circle-v2.tsx b/packages/ui/src/v2/components/progress-circle-v2.tsx new file mode 100644 index 000000000000..473824d23746 --- /dev/null +++ b/packages/ui/src/v2/components/progress-circle-v2.tsx @@ -0,0 +1,46 @@ +import { type ComponentProps, createMemo, splitProps } from "solid-js" +import "./progress-circle-v2.css" + +export interface ProgressCircleV2Props extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ProgressCircleV2(props: ProgressCircleV2Props) { + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) + + const size = () => split.size ?? 14 + const strokeWidth = () => split.strokeWidth ?? 1.5 + const viewBoxSize = 14 + const center = viewBoxSize / 2 + const radius = () => center - strokeWidth() / 2 + const circumference = createMemo(() => 2 * Math.PI * radius()) + const offset = createMemo(() => circumference() * (1 - Math.max(0, Math.min(100, split.percentage || 0)) / 100)) + + return ( + + + + + ) +} From 48dc05eee754426ce001b285d4c0b740ca0b803f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 10:13:23 +0000 Subject: [PATCH 008/303] chore: generate --- packages/ui/src/v2/components/progress-circle-v2.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/v2/components/progress-circle-v2.tsx b/packages/ui/src/v2/components/progress-circle-v2.tsx index 473824d23746..32393446ab06 100644 --- a/packages/ui/src/v2/components/progress-circle-v2.tsx +++ b/packages/ui/src/v2/components/progress-circle-v2.tsx @@ -31,7 +31,13 @@ export function ProgressCircleV2(props: ProgressCircleV2Props) { [split.class ?? ""]: !!split.class, }} > - + Date: Mon, 29 Jun 2026 15:50:26 +0530 Subject: [PATCH 009/303] feat(app): improve projects sidebar reactivity (#34391) Co-authored-by: Brendan Allan --- packages/app/src/index.css | 62 ++++++++++++++++++++++++ packages/app/src/pages/home.tsx | 84 ++++++++++++++++++--------------- 2 files changed, 108 insertions(+), 38 deletions(-) diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 7b8435c04e3d..6f8ec89d4caf 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -171,4 +171,66 @@ transform: rotate(360deg); } } + + @keyframes home-projects-fade-top { + from { + visibility: hidden; + } + to { + visibility: visible; + } + } + + @keyframes home-projects-fade-bottom { + from { + visibility: visible; + } + to { + visibility: hidden; + } + } + + [data-slot="home-projects-scroll"] { + timeline-scope: --home-projects-scroll; + } + + [data-slot="home-projects-scroll"]::before, + [data-slot="home-projects-scroll"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + z-index: 10; + height: 16px; + pointer-events: none; + visibility: hidden; + } + + [data-slot="home-projects-scroll"]::before { + top: 0; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); + } + + [data-slot="home-projects-scroll"]::after { + bottom: 0; + background: linear-gradient(to top, var(--v2-background-bg-base), transparent); + } + + @supports (animation-timeline: --home-projects-scroll) and (timeline-scope: --home-projects-scroll) { + [data-slot="home-projects-scroll"] .scroll-view__viewport { + scroll-timeline: --home-projects-scroll y; + } + + [data-slot="home-projects-scroll"]::before { + animation: home-projects-fade-top linear both; + animation-timeline: --home-projects-scroll; + animation-range: 0 0.1px; + } + + [data-slot="home-projects-scroll"]::after { + animation: home-projects-fade-bottom linear both; + animation-timeline: --home-projects-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } + } } diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index b20427bf88eb..21c7d7137be3 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -668,10 +668,10 @@ function HomeProjectColumn(props: { return (
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - + event.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> {meta()} From 7fac84319d6605643e0a204256969a560d341d17 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:14:18 +0530 Subject: [PATCH 012/303] feat(app): align slash popover to v2 tokens (#34286) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/components/prompt-input.tsx | 10 +- .../components/prompt-input/slash-popover.tsx | 212 ++++++++++++++---- .../composer/session-composer-region.tsx | 2 +- 3 files changed, 175 insertions(+), 49 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e6f933f0f769..1a25d10f7884 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -791,8 +791,7 @@ export const PromptInput: Component = (props) => { } } - // Auto-scroll active command into view when navigating with keyboard - createEffect(() => { + const scrollSlashActiveIntoView = () => { const activeId = slashActive() if (!activeId || !slashPopoverRef) return @@ -800,7 +799,7 @@ export const PromptInput: Component = (props) => { const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) }) - }) + } const selectPopoverActive = () => { if (store.popover === "at") { const items = atFlat() @@ -1287,6 +1286,9 @@ export const PromptInput: Component = (props) => { } if (store.popover === "slash") { slashOnKeyDown(event) + if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) { + scrollSlashActiveIntoView() + } } event.preventDefault() return @@ -1406,6 +1408,8 @@ export const PromptInput: Component = (props) => { setSlashActive={setSlashActive} onSlashSelect={handleSlashSelect} commandKeybind={command.keybind} + commandKeybindParts={command.keybindParts} + newLayoutDesigns={props.controls.newLayoutDesigns} t={(key) => language.t(key as Parameters[0])} /> diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index d8c4bd035c75..393e94ec6d61 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,6 +1,8 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = @@ -30,6 +32,8 @@ type PromptPopoverProps = { setSlashActive: (id: string) => void onSlashSelect: (item: SlashCommand) => void commandKeybind: (id: string) => string | undefined + commandKeybindParts: (id: string) => string[] + newLayoutDesigns: boolean t: (key: string) => string } @@ -41,15 +45,30 @@ export const PromptPopover: Component = (props) => { if (props.popover === "slash") props.setSlashPopoverRef(el) }} class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] - bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" + overflow-auto no-scrollbar flex flex-col p-2" + classList={{ + "z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": + props.newLayoutDesigns, + "rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]": + !props.newLayoutDesigns, + }} onMouseDown={(e) => e.preventDefault()} > 0} - fallback={
{props.t("prompt.popover.emptyResults")}
} + fallback={ +
+ {props.t("prompt.popover.emptyResults")} +
+ } > {(item) => { @@ -58,13 +77,29 @@ export const PromptPopover: Component = (props) => { if (item.type === "agent") { return ( ) } @@ -75,16 +110,44 @@ export const PromptPopover: Component = (props) => { return ( @@ -96,41 +159,100 @@ export const PromptPopover: Component = (props) => { 0} - fallback={
{props.t("prompt.popover.emptyCommands")}
} + fallback={ +
+ {props.t("prompt.popover.emptyCommands")} +
+ } > - {(cmd) => ( - - )} + + + {cmd.description} + + +
+
+ + + {cmd.source === "skill" + ? props.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? props.t("prompt.slash.badge.mcp") + : props.t("prompt.slash.badge.custom")} + + } + > + + {cmd.source === "skill" + ? props.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? props.t("prompt.slash.badge.mcp") + : props.t("prompt.slash.badge.custom")} + + + + 0 : keybind()} + > + {keybind()}} + > + + + +
+ + ) + }}
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 84cd4dc69109..600ff41e3def 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
Date: Mon, 29 Jun 2026 10:45:44 +0000 Subject: [PATCH 013/303] chore: generate --- packages/app/src/components/prompt-input/slash-popover.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 393e94ec6d61..aa56b07961d9 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -47,8 +47,7 @@ export const PromptPopover: Component = (props) => { class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 overflow-auto no-scrollbar flex flex-col p-2" classList={{ - "z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": - props.newLayoutDesigns, + "z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns, "rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]": !props.newLayoutDesigns, }} @@ -239,9 +238,7 @@ export const PromptPopover: Component = (props) => { - 0 : keybind()} - > + 0 : keybind()}> {keybind()}} From 48fc9e3cc3a1626a7cb77e2541c62c612ddbcf7b Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 16:26:33 +0530 Subject: [PATCH 014/303] feat(llm): add response reducer (#34417) --- packages/llm/src/route/client.ts | 17 +- packages/llm/src/schema/events.ts | 245 +++++++++++++++++- packages/llm/test/adapter.test.ts | 1 + ...nttokencount-on-identical-second-call.json | 4 +- .../llm/test/provider/openai-chat.test.ts | 19 +- packages/llm/test/response.test.ts | 60 +++++ 6 files changed, 324 insertions(+), 22 deletions(-) create mode 100644 packages/llm/test/response.test.ts diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 5b5bc5ab2d5a..b261cd7f2315 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -374,17 +374,12 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) = const generateWith = (stream: Interface["stream"]) => Effect.fn("LLM.generate")(function* (request: LLMRequest) { - return new LLMResponse( - yield* stream(request).pipe( - Stream.runFold( - () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }), - (acc, event) => { - acc.events.push(event) - if ("usage" in event && event.usage !== undefined) acc.usage = event.usage - return acc - }, - ), - ), + const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) + const response = LLMResponse.complete(state) + if (response) return response + return yield* ProviderShared.eventError( + `${request.model.provider}/${request.model.route.id}`, + "Provider stream ended without a terminal finish event", ) }) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 3e46013521e7..e0e09aa703f2 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -1,7 +1,7 @@ import { Schema } from "effect" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelSchema } from "./options" -import { ToolOutput, ToolResultValue } from "./messages" +import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ProviderFailureClassification } from "./errors" /** @@ -335,9 +335,231 @@ const responseUsage = (events: ReadonlyArray) => undefined, ) +interface ContentAssembly { + readonly contentIndex: number + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ToolInputAssembly { + readonly name: string + readonly text: string + readonly providerMetadata?: ProviderMetadata +} + +interface ResponseState { + readonly events: ReadonlyArray + readonly message: Message + readonly usage?: Usage + readonly finishReason?: FinishReason + readonly textParts: Readonly> + readonly reasoningParts: Readonly> + readonly toolInputs: Readonly> +} + +const emptyResponseState = (): ResponseState => ({ + events: [], + message: Message.assistant([]), + textParts: {}, + reasoningParts: {}, + toolInputs: {}, +}) + +const appendEvent = (state: ResponseState, event: LLMEvent): ResponseState => { + const events = [...state.events, event] + if (LLMEvent.is.finish(event)) { + return { + ...state, + events, + usage: event.usage ?? state.usage, + finishReason: event.reason, + } + } + if (LLMEvent.is.providerError(event)) { + return { + ...state, + events, + finishReason: state.finishReason ?? "error", + } + } + return { + ...state, + events, + usage: "usage" in event && event.usage !== undefined ? event.usage : state.usage, + } +} + +const textContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "text", text } : { type: "text", text, providerMetadata } + +const reasoningContent = (text: string, providerMetadata: ProviderMetadata | undefined): ContentPart => + providerMetadata === undefined ? { type: "reasoning", text } : { type: "reasoning", text, providerMetadata } + +const contentWith = (state: ResponseState, content: ReadonlyArray): ResponseState => ({ + ...state, + message: Message.assistant(content), +}) + +const appendContent = (state: ResponseState, part: ContentPart) => + contentWith(state, [...state.message.content, part]) + +const replaceContent = (state: ResponseState, index: number, part: ContentPart) => + contentWith( + state, + state.message.content.map((item, itemIndex) => (itemIndex === index ? part : item)), + ) + +const ensureText = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.textParts[id]) return state + return { + ...appendContent(state, textContent("", providerMetadata)), + textParts: { + ...state.textParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceTextDelta = (state: ResponseState, event: TextDelta): ResponseState => { + const started = ensureText(state, event.id, event.providerMetadata) + const current = started.textParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, textContent(text, providerMetadata)), + textParts: { ...started.textParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceTextEnd = (state: ResponseState, event: TextEnd): ResponseState => { + const current = state.textParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)), + textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const ensureReasoning = (state: ResponseState, id: string, providerMetadata?: ProviderMetadata): ResponseState => { + if (state.reasoningParts[id]) return state + return { + ...appendContent(state, reasoningContent("", providerMetadata)), + reasoningParts: { + ...state.reasoningParts, + [id]: { contentIndex: state.message.content.length, text: "", providerMetadata }, + }, + } +} + +const reduceReasoningDelta = (state: ResponseState, event: ReasoningDelta): ResponseState => { + const started = ensureReasoning(state, event.id, event.providerMetadata) + const current = started.reasoningParts[event.id] + if (!current) return started + const text = current.text + event.text + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(started, current.contentIndex, reasoningContent(text, providerMetadata)), + reasoningParts: { ...started.reasoningParts, [event.id]: { ...current, text, providerMetadata } }, + } +} + +const reduceReasoningEnd = (state: ResponseState, event: ReasoningEnd): ResponseState => { + const current = state.reasoningParts[event.id] + if (!current) return state + const providerMetadata = event.providerMetadata ?? current.providerMetadata + return { + ...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)), + reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } }, + } +} + +const reduceToolInputStart = (state: ResponseState, event: ToolInputStart): ResponseState => ({ + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { name: event.name, text: "", providerMetadata: event.providerMetadata }, + }, +}) + +const reduceToolInputDelta = (state: ResponseState, event: ToolInputDelta): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { ...state.toolInputs, [event.id]: { ...current, text: current.text + event.text } }, + } +} + +const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): ResponseState => { + const current = state.toolInputs[event.id] ?? { name: event.name, text: "" } + return { + ...state, + toolInputs: { + ...state.toolInputs, + [event.id]: { ...current, name: event.name, providerMetadata: event.providerMetadata ?? current.providerMetadata }, + }, + } +} + +const toolCallContent = (event: ToolCall): ContentPart => + ToolCallPart.make({ + id: event.id, + name: event.name, + input: event.input, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const toolResultContent = (event: ToolResult): ContentPart => + ToolResultPart.make({ + id: event.id, + name: event.name, + result: event.result, + ...(event.providerExecuted === undefined ? {} : { providerExecuted: event.providerExecuted }), + ...(event.providerMetadata === undefined ? {} : { providerMetadata: event.providerMetadata }), + }) + +const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState => { + const { [event.id]: _finished, ...toolInputs } = state.toolInputs + return { ...appendContent(state, toolCallContent(event)), toolInputs } +} + +const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => { + const next = appendEvent(state, event) + switch (event.type) { + case "text-start": + return ensureText(next, event.id, event.providerMetadata) + case "text-delta": + return reduceTextDelta(next, event) + case "text-end": + return reduceTextEnd(next, event) + case "reasoning-start": + return ensureReasoning(next, event.id, event.providerMetadata) + case "reasoning-delta": + return reduceReasoningDelta(next, event) + case "reasoning-end": + return reduceReasoningEnd(next, event) + case "tool-input-start": + return reduceToolInputStart(next, event) + case "tool-input-delta": + return reduceToolInputDelta(next, event) + case "tool-input-end": + return reduceToolInputEnd(next, event) + case "tool-call": + return reduceToolCall(next, event) + case "tool-result": + return appendContent(next, toolResultContent(event)) + default: + return next + } +} + export class LLMResponse extends Schema.Class("LLM.Response")({ + message: Message, events: Schema.Array(LLMEvent), usage: Schema.optional(Usage), + finishReason: FinishReason, }) { /** Concatenated assistant text assembled from streamed `text-delta` events. */ get text() { @@ -356,8 +578,29 @@ export class LLMResponse extends Schema.Class("LLM.Response")({ } export namespace LLMResponse { + export type State = ResponseState export type Output = LLMResponse | { readonly events: ReadonlyArray; readonly usage?: Usage } + /** Initial reducer state for assembling one provider attempt. */ + export const empty = emptyResponseState + + /** Purely fold one provider-neutral event into the attempt assembly state. */ + export const reduce = reduceResponseState + + /** Return a completed response only after a terminal finish or provider error. */ + export const complete = (state: State): LLMResponse | undefined => + state.finishReason === undefined + ? undefined + : new LLMResponse({ + message: state.message, + events: [...state.events], + usage: state.usage, + finishReason: state.finishReason, + }) + + /** Convenience reducer for callers that already have a collected event list. */ + export const fromEvents = (events: ReadonlyArray) => complete(events.reduce(reduce, empty())) + /** Concatenate assistant text from a response or collected event list. */ export const text = (response: Output) => responseText(response.events) diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index 8e182948fa2c..f71c6ef9be56 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -115,6 +115,7 @@ describe("llm route", () => { expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }]) }), ) diff --git a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json index 0145756887e0..209aadfc1a43 100644 --- a/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json +++ b/packages/llm/test/fixtures/recordings/gemini-cache/reports-cachedcontenttokencount-on-identical-second-call.json @@ -21,7 +21,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } }, { @@ -39,7 +39,7 @@ "headers": { "content-type": "text/event-stream" }, - "body": "" + "body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" } } ] diff --git a/packages/llm/test/provider/openai-chat.test.ts b/packages/llm/test/provider/openai-chat.test.ts index 02995c00ce62..b736dc9dd33c 100644 --- a/packages/llm/test/provider/openai-chat.test.ts +++ b/packages/llm/test/provider/openai-chat.test.ts @@ -1,7 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" import { HttpClientRequest } from "effect/unstable/http" -import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src" +import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIChat from "../../src/protocols/openai-chat" @@ -597,19 +597,22 @@ describe("OpenAI Chat route", () => { }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), ) - const response = yield* LLMClient.generate( - LLM.updateRequest(request, { - tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], - }), - ).pipe(Effect.provide(fixedResponse(body))) + const input = LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }) + const events = Array.from( + yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))), + ) + const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip) - expect(response.events).toEqual([ + expect(events).toEqual([ { type: "step-start", index: 0 }, { type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, ]) - expect(response.toolCalls).toEqual([]) + expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) + expect(error.message).toContain("Provider stream ended without a terminal finish event") }), ) diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts new file mode 100644 index 000000000000..8c8de191511e --- /dev/null +++ b/packages/llm/test/response.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { LLMEvent, LLMResponse } from "../src" + +const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) + +describe("LLMResponse reducer", () => { + test("assembles interleaved reasoning and text with end metadata", () => { + const response = LLMResponse.fromEvents([ + LLMEvent.reasoningStart({ id: "r1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), + LLMEvent.textStart({ id: "t1" }), + LLMEvent.reasoningDelta({ id: "r1", text: "compare..." }), + LLMEvent.reasoningEnd({ id: "r1", providerMetadata: { anthropic: { signature: "sig" } } }), + LLMEvent.textDelta({ id: "t1", text: "Answer" }), + LLMEvent.textEnd({ id: "t1" }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), + ]) + + expect(response?.finishReason).toBe("stop") + expect(response?.usage).toMatchObject({ outputTokens: 5 }) + expect(response?.message.content).toEqual([ + { + type: "reasoning", + text: "I should compare...", + providerMetadata: { anthropic: { signature: "sig" } }, + }, + { type: "text", text: "Answer" }, + ]) + expect(response?.events).toHaveLength(8) + }) + + test("preserves partial content without completing a failed stream", () => { + const state = reduce([LLMEvent.textStart({ id: "t1" }), LLMEvent.textDelta({ id: "t1", text: "partial" })]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("assembles tool-call content only after the completed tool call event", () => { + const pending = reduce([ + LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query"' }), + ]) + + expect(pending.message.content).toEqual([]) + expect(pending.toolInputs.call_1?.text).toBe('{"query"') + + const response = LLMResponse.fromEvents([ + ...pending.events, + LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: ':"weather"}' }), + LLMEvent.toolInputEnd({ id: "call_1", name: "lookup" }), + LLMEvent.toolCall({ id: "call_1", name: "lookup", input: { query: "weather" } }), + LLMEvent.finish({ reason: "tool-calls" }), + ]) + + expect(response?.message.content).toEqual([ + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }) +}) From c3637753bc7effa486cc10333762aab70b67479b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 10:58:22 +0000 Subject: [PATCH 015/303] chore: generate --- packages/llm/src/schema/events.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index e0e09aa703f2..98fcc9a24d41 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -400,8 +400,7 @@ const contentWith = (state: ResponseState, content: ReadonlyArray): message: Message.assistant(content), }) -const appendContent = (state: ResponseState, part: ContentPart) => - contentWith(state, [...state.message.content, part]) +const appendContent = (state: ResponseState, part: ContentPart) => contentWith(state, [...state.message.content, part]) const replaceContent = (state: ResponseState, index: number, part: ContentPart) => contentWith( @@ -497,7 +496,11 @@ const reduceToolInputEnd = (state: ResponseState, event: ToolInputEnd): Response ...state, toolInputs: { ...state.toolInputs, - [event.id]: { ...current, name: event.name, providerMetadata: event.providerMetadata ?? current.providerMetadata }, + [event.id]: { + ...current, + name: event.name, + providerMetadata: event.providerMetadata ?? current.providerMetadata, + }, }, } } From b0151e1d024a9ca02e0f2421eb890890e6e6e9d2 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 17:01:45 +0530 Subject: [PATCH 016/303] test(llm): verify generate reducer law (#34418) --- packages/llm/test/adapter.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/llm/test/adapter.test.ts b/packages/llm/test/adapter.test.ts index f71c6ef9be56..bbbb29f37af7 100644 --- a/packages/llm/test/adapter.test.ts +++ b/packages/llm/test/adapter.test.ts @@ -1,6 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Schema, Stream } from "effect" -import { LLM } from "../src" +import { LLM, LLMResponse } from "../src" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Model } from "../src/schema" import { testEffect } from "./lib/effect" @@ -112,9 +112,15 @@ describe("llm route", () => { const llm = yield* LLMClient.Service const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const response = yield* llm.generate(request) + const reduced = LLMResponse.fromEvents(events) expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) - expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"]) + expect(reduced).toBeDefined() + if (!reduced) throw new Error("stream reducer did not produce a completed response") + expect(response.events).toEqual(events) + expect(response.message).toEqual(reduced.message) + expect(response.usage).toEqual(reduced.usage) + expect(response.finishReason).toEqual(reduced.finishReason) expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }]) }), ) From e5101d96514ea8d8d2880c59b177725ce9159a75 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 17:22:46 +0530 Subject: [PATCH 017/303] test(llm): lock event reducer laws (#34423) --- .../test/provider/anthropic-messages.test.ts | 4 ++ .../test/provider/openai-responses.test.ts | 5 +++ packages/llm/test/response.test.ts | 44 +++++++++++++++++-- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index dabf512f6b69..898931295849 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -395,6 +395,10 @@ describe("Anthropic Messages route", () => { expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ providerMetadata: { anthropic: { signature: "sig_1" } }, }) + expect(response.message.content).toEqual([ + { type: "text", text: "Hello!" }, + { type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } }, + ]) expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "stop", diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index d72611285cdb..cd8bad51af47 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -778,6 +778,11 @@ describe("OpenAI Responses route", () => { { type: "step-finish", index: 0, reason: "stop" }, { type: "finish", reason: "stop" }, ]) + expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1) + expect(response.message.content).toEqual([ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hello" }, + ]) }), ) diff --git a/packages/llm/test/response.test.ts b/packages/llm/test/response.test.ts index 8c8de191511e..5e48e5ef4575 100644 --- a/packages/llm/test/response.test.ts +++ b/packages/llm/test/response.test.ts @@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test" import { LLMEvent, LLMResponse } from "../src" const reduce = (events: ReadonlyArray) => events.reduce(LLMResponse.reduce, LLMResponse.empty()) +const finishEvents = (events: ReadonlyArray) => events.filter(LLMEvent.is.finish) describe("LLMResponse reducer", () => { test("assembles interleaved reasoning and text with end metadata", () => { - const response = LLMResponse.fromEvents([ + const events = [ LLMEvent.reasoningStart({ id: "r1" }), LLMEvent.reasoningDelta({ id: "r1", text: "I should " }), LLMEvent.textStart({ id: "t1" }), @@ -14,10 +15,23 @@ describe("LLMResponse reducer", () => { LLMEvent.textDelta({ id: "t1", text: "Answer" }), LLMEvent.textEnd({ id: "t1" }), LLMEvent.finish({ reason: "stop", usage: { outputTokens: 5 } }), - ]) + ] + const response = LLMResponse.fromEvents(events) expect(response?.finishReason).toBe("stop") expect(response?.usage).toMatchObject({ outputTokens: 5 }) + expect(response?.events).toEqual(events) + expect(response?.events.map((event) => event.type)).toEqual([ + "reasoning-start", + "reasoning-delta", + "text-start", + "reasoning-delta", + "reasoning-end", + "text-delta", + "text-end", + "finish", + ]) + expect(finishEvents(response?.events ?? [])).toHaveLength(1) expect(response?.message.content).toEqual([ { type: "reasoning", @@ -26,7 +40,6 @@ describe("LLMResponse reducer", () => { }, { type: "text", text: "Answer" }, ]) - expect(response?.events).toHaveLength(8) }) test("preserves partial content without completing a failed stream", () => { @@ -36,6 +49,31 @@ describe("LLMResponse reducer", () => { expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) }) + test("does not complete ended content without a terminal finish", () => { + const state = reduce([ + LLMEvent.textStart({ id: "t1" }), + LLMEvent.textDelta({ id: "t1", text: "partial" }), + LLMEvent.textEnd({ id: "t1" }), + ]) + + expect(LLMResponse.complete(state)).toBeUndefined() + expect(state.message.content).toEqual([{ type: "text", text: "partial" }]) + }) + + test("uses terminal usage when present and keeps prior usage when finish omits it", () => { + const withFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop", usage: { outputTokens: 2 } }), + ]) + const withoutFinishUsage = LLMResponse.fromEvents([ + LLMEvent.stepFinish({ index: 0, reason: "stop", usage: { inputTokens: 3 } }), + LLMEvent.finish({ reason: "stop" }), + ]) + + expect(withFinishUsage?.usage).toMatchObject({ outputTokens: 2 }) + expect(withoutFinishUsage?.usage).toMatchObject({ inputTokens: 3 }) + }) + test("assembles tool-call content only after the completed tool call event", () => { const pending = reduce([ LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }), From 6d9539f46967d9c978b9b7910c08b590a9904cc0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:59:30 +0000 Subject: [PATCH 018/303] fix: exempt org issues from compliance close (#34431) --- .github/workflows/compliance-close.yml | 14 ++++++++++++++ .github/workflows/duplicate-issues.yml | 8 +++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index 14e68701e57d..a6e4b9215fe4 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -34,11 +34,25 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; + const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + if (orgMemberAssociations.has(item.author_association)) { + core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`); + try { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + name: 'needs:compliance', + }); + } catch (e) {} + continue; + } + const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 4648a2d0c3d3..b4500e0cc1e5 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -38,6 +38,7 @@ jobs: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: Issue number: ${{ github.event.issue.number }} + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. @@ -49,6 +50,8 @@ jobs: Check whether the issue follows our contributing guidelines and issue templates. + If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues. + This project has three issue templates that every issue MUST use one of: 1. Bug Report - requires a Description field with real content @@ -83,7 +86,7 @@ jobs: Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: - If the issue is NOT compliant, start the comment with: + If the issue is NOT compliant and the author association is not OWNER or MEMBER, start the comment with: Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance @@ -148,9 +151,12 @@ jobs: } run: | opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. + Issue author association: ${{ github.event.issue.author_association }} Lookup this issue with gh issue view ${{ github.event.issue.number }}. + If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment. + Re-check whether the issue now follows our contributing guidelines and issue templates. This project has three issue templates that every issue MUST use one of: From 1fd8bf526d8135dd0fca9a3cd6de8e2c448ecf7f Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 19:10:00 +0530 Subject: [PATCH 019/303] feat(llm): add model defaults and compatibility data (#34436) --- packages/llm/src/provider.ts | 3 +- packages/llm/src/schema/options.ts | 55 +++++++++++++++++++++++++++++- packages/llm/test/llm.test.ts | 36 +++++++++++++++++-- 3 files changed, 89 insertions(+), 5 deletions(-) diff --git a/packages/llm/src/provider.ts b/packages/llm/src/provider.ts index 7f69583418bf..c0406f3da02d 100644 --- a/packages/llm/src/provider.ts +++ b/packages/llm/src/provider.ts @@ -1,7 +1,6 @@ -import type { RouteDefaultsInput } from "./route/client" import type { Model, ModelID, ProviderID } from "./schema" -export type ModelOptions = RouteDefaultsInput +export type ModelOptions = Pick /** * Advanced structural provider definition helper. Built-in providers should diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index c02af6d1ed4d..9a10304cbde9 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -134,15 +134,60 @@ export namespace ModelLimits { input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) } +export class ModelDefaults extends Schema.Class("LLM.ModelDefaults")({ + limits: Schema.optional(ModelLimits), + generation: Schema.optional(GenerationOptions), + providerOptions: Schema.optional(ProviderOptions), + http: Schema.optional(HttpOptions), +}) {} + +export namespace ModelDefaults { + export type Input = ModelDefaults | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } + + /** Normalize selected-model request defaults without applying precedence. */ + export const make = (input: Input) => { + if (input instanceof ModelDefaults) return input + return new ModelDefaults({ + limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), + generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), + providerOptions: input.providerOptions, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }) + } +} + +export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) +export type ModelToolSchemaCompatibility = Schema.Schema.Type + +export class ModelCompatibility extends Schema.Class("LLM.ModelCompatibility")({ + toolSchema: Schema.optional(ModelToolSchemaCompatibility), +}) {} + +export namespace ModelCompatibility { + export type Input = ModelCompatibility | ConstructorParameters[0] + + /** Normalize model/upstream compatibility metadata without projecting requests. */ + export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) +} + export class Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility constructor(input: Model.ConstructorInput) { this.id = input.id this.provider = input.provider this.route = input.route + this.defaults = input.defaults + this.compatibility = input.compatibility } static make(input: Model.Input) { @@ -150,6 +195,8 @@ export class Model { id: ModelID.make(input.id), provider: ProviderID.make(input.provider), route: input.route, + defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), + compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), }) } @@ -158,6 +205,8 @@ export class Model { id: model.id, provider: model.provider, route: model.route, + defaults: model.defaults, + compatibility: model.compatibility, } } @@ -175,11 +224,15 @@ export namespace Model { readonly id: ModelID readonly provider: ProviderID readonly route: AnyRoute + readonly defaults?: ModelDefaults + readonly compatibility?: ModelCompatibility } - export type Input = Omit & { + export type Input = Omit & { readonly id: string | ModelID readonly provider: string | ProviderID + readonly defaults?: ModelDefaults.Input + readonly compatibility?: ModelCompatibility.Input } } diff --git a/packages/llm/test/llm.test.ts b/packages/llm/test/llm.test.ts index 633a4662da18..64346a8bb030 100644 --- a/packages/llm/test/llm.test.ts +++ b/packages/llm/test/llm.test.ts @@ -90,15 +90,47 @@ describe("llm constructors", () => { provider: "fake", route: chatRoute, }) - const updated = Model.update(base, { route: responsesRoute }) + const updated = Model.update(base, { + route: responsesRoute, + defaults: { generation: { maxTokens: 20 } }, + compatibility: { toolSchema: "gemini" }, + }) + const updatedInput = Model.input(updated) expect(updated).toBeInstanceOf(Model) expect(String(updated.id)).toBe("fake-model") expect(updated.route).toBe(responsesRoute) - expect(String(Model.input(updated).provider)).toBe("fake") + expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) + expect(updated.compatibility).toEqual({ toolSchema: "gemini" }) + expect(updatedInput.defaults).toBe(updated.defaults) + expect(updatedInput.compatibility).toBe(updated.compatibility) + expect(String(updatedInput.provider)).toBe("fake") expect(Model.update(updated, {})).toBe(updated) }) + test("carries model defaults and compatibility through route model selection", () => { + const model = chatRoute.model({ + id: "kimi-k2", + defaults: { + limits: { context: 128_000, output: 8_192 }, + generation: { maxTokens: 1_024, stop: ["END"] }, + providerOptions: { openai: { parallelToolCalls: false } }, + http: { body: { extra_body: true } }, + }, + compatibility: { toolSchema: "moonshot" }, + }) + const request = LLM.request({ model, prompt: "Say hello." }) + + expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 }) + expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] }) + expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } }) + expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } }) + expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" }) + expect(request.generation).toBeUndefined() + expect(request.providerOptions).toBeUndefined() + expect(request.http).toBeUndefined() + }) + test("builds tool choices from names and tools", () => { const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) From 7077c70d6044de8eb49d0036412990effe31ef45 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 13:41:59 +0000 Subject: [PATCH 020/303] chore: generate --- packages/llm/src/schema/options.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/llm/src/schema/options.ts b/packages/llm/src/schema/options.ts index 9a10304cbde9..6d11333b536d 100644 --- a/packages/llm/src/schema/options.ts +++ b/packages/llm/src/schema/options.ts @@ -142,12 +142,14 @@ export class ModelDefaults extends Schema.Class("LLM.ModelDefault }) {} export namespace ModelDefaults { - export type Input = ModelDefaults | { - readonly limits?: ModelLimits.Input - readonly generation?: GenerationOptions.Input - readonly providerOptions?: ProviderOptions - readonly http?: HttpOptions.Input - } + export type Input = + | ModelDefaults + | { + readonly limits?: ModelLimits.Input + readonly generation?: GenerationOptions.Input + readonly providerOptions?: ProviderOptions + readonly http?: HttpOptions.Input + } /** Normalize selected-model request defaults without applying precedence. */ export const make = (input: Input) => { From 08c5a2a5e8c565da9796261f5beaf8887b31c7a6 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 19:24:37 +0530 Subject: [PATCH 021/303] feat(llm): enforce request precedence (#34440) --- .../llm/src/protocols/anthropic-messages.ts | 3 +- packages/llm/src/route/client.ts | 15 +- packages/llm/test/prepare.test.ts | 162 ++++++++++++++++++ 3 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 packages/llm/test/prepare.test.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index a37cd2c9a758..d51da9851d6f 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -504,6 +504,7 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers // over-mark we keep their tool hints and shed the message-tail ones first. @@ -533,7 +534,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques tools, tool_choice: toolChoice, stream: true as const, - max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096, + max_tokens: generation?.maxTokens ?? outputLimit, temperature: generation?.temperature, top_p: generation?.topP, top_k: generation?.topK, diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index b261cd7f2315..183f171dcd36 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -164,13 +164,16 @@ export interface GenerateMethod { export class Service extends Context.Service()("@opencode/LLMClient") {} -const resolveRequestOptions = (request: LLMRequest) => - LLMRequest.update(request, { - generation: - mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}), - providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions), - http: mergeHttpOptions(request.model.route.defaults.http, request.http), +const resolveRequestOptions = (request: LLMRequest) => { + const routeDefaults = request.model.route.defaults + const modelDefaults = request.model.defaults + const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) + return LLMRequest.update(request, { + generation: generation ?? new GenerationOptions({}), + providerOptions: mergeProviderOptions(routeDefaults.providerOptions, modelDefaults?.providerOptions, request.providerOptions), + http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), }) +} export interface MakeInput { /** Route id used in diagnostics and prepared request metadata. */ diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts new file mode 100644 index 000000000000..d6006095f4ed --- /dev/null +++ b/packages/llm/test/prepare.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Schema } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { LLM, mergeProviderOptions } from "../src" +import { AnthropicMessages, OpenAIChat } from "../src/protocols" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" +import { dynamicResponse } from "./lib/http" +import { deltaChunk } from "./lib/openai-chunks" +import { sseEvents } from "./lib/sse" + +const TargetJson = Schema.fromJsonString(Schema.Unknown) +const decodeJson = Schema.decodeUnknownSync(TargetJson) + +describe("request option precedence", () => { + test("deep-merges provider option records and replaces arrays, primitives, and null", () => { + const merged = mergeProviderOptions( + { + openai: { + include: ["route"], + metadata: { route: true, shared: "route" }, + nullable: "route", + primitive: "route", + }, + }, + { + openai: { + include: ["model"], + metadata: { model: true, shared: "model" }, + nullable: null, + primitive: "model", + }, + }, + { openai: { metadata: { request: true }, primitive: false } }, + ) + + expect(merged).toEqual({ + openai: { + include: ["model"], + metadata: { route: true, model: true, request: true, shared: "model" }, + nullable: null, + primitive: false, + }, + }) + }) + + it.effect("prepares bodies with route defaults, model defaults, and call options in order", () => + Effect.gen(function* () { + const route = OpenAIChat.route.with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("test"), + generation: { maxTokens: 10, temperature: 1, stop: ["route"] }, + providerOptions: { openai: { store: false, reasoningEffort: "low" } }, + }) + const model = route.model({ + id: "gpt-4o-mini", + defaults: { + generation: { maxTokens: 20, temperature: 0.5, frequencyPenalty: 0.25, stop: ["model"] }, + providerOptions: { openai: { reasoningEffort: "medium" } }, + }, + }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + generation: { maxTokens: 30, topP: 0.9, stop: ["request"] }, + providerOptions: { openai: { store: true } }, + }), + ) + + expect(prepared.body).toMatchObject({ + model: "gpt-4o-mini", + stream: true, + max_tokens: 30, + temperature: 0.5, + top_p: 0.9, + frequency_penalty: 0.25, + store: true, + reasoning_effort: "medium", + }) + expect(prepared.body.stop).toEqual(["request"]) + }), + ) + + it.effect("applies model HTTP defaults before request HTTP overlays", () => + LLMClient.generate( + LLM.request({ + model: OpenAIChat.route + .with({ + endpoint: { baseURL: "https://api.openai.test/v1/" }, + auth: Auth.bearer("fresh-key"), + http: { + body: { metadata: { route: true, shared: "route" }, value: "route" }, + headers: { "x-route": "route", "x-shared": "route" }, + query: { route: "1", shared: "route" }, + }, + }) + .model({ + id: "gpt-4o-mini", + defaults: { + http: { + body: { metadata: { model: true, shared: "model" }, value: "model" }, + headers: { "x-model": "model", "x-shared": "model" }, + query: { model: "1", shared: "model" }, + }, + }, + }), + prompt: "Say hello.", + http: { + body: { metadata: { request: true }, value: null }, + headers: { "x-request": "request" }, + query: { request: "1" }, + }, + }), + ).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + const url = new URL(web.url) + expect(url.searchParams.get("route")).toBe("1") + expect(url.searchParams.get("model")).toBe("1") + expect(url.searchParams.get("request")).toBe("1") + expect(url.searchParams.get("shared")).toBe("model") + expect(web.headers.get("authorization")).toBe("Bearer fresh-key") + expect(web.headers.get("x-route")).toBe("route") + expect(web.headers.get("x-model")).toBe("model") + expect(web.headers.get("x-request")).toBe("request") + expect(web.headers.get("x-shared")).toBe("model") + expect(decodeJson(input.text)).toMatchObject({ + metadata: { route: true, model: true, request: true, shared: "model" }, + value: null, + }) + return input.respond(sseEvents(deltaChunk({}, "stop")), { + headers: { "content-type": "text/event-stream" }, + }) + }), + ), + ), + ), + ) + + it.effect("uses model output limits after route limits and before call maxTokens", () => + Effect.gen(function* () { + const route = AnthropicMessages.route.with({ + endpoint: { baseURL: "https://api.anthropic.test/v1/" }, + auth: Auth.header("x-api-key", "test"), + limits: { output: 128 }, + }) + const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } }) + const withoutMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none" }), + ) + const withMaxTokens = yield* LLMClient.prepare( + LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }), + ) + + expect(withoutMaxTokens.body.max_tokens).toBe(64) + expect(withMaxTokens.body.max_tokens).toBe(32) + }), + ) +}) From c0e43c0c65fbb8af16a846d380e88022aa8e993b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 13:57:06 +0000 Subject: [PATCH 022/303] chore: generate --- packages/llm/src/route/client.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/llm/src/route/client.ts b/packages/llm/src/route/client.ts index 183f171dcd36..d3b41f5817f1 100644 --- a/packages/llm/src/route/client.ts +++ b/packages/llm/src/route/client.ts @@ -170,7 +170,11 @@ const resolveRequestOptions = (request: LLMRequest) => { const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) return LLMRequest.update(request, { generation: generation ?? new GenerationOptions({}), - providerOptions: mergeProviderOptions(routeDefaults.providerOptions, modelDefaults?.providerOptions, request.providerOptions), + providerOptions: mergeProviderOptions( + routeDefaults.providerOptions, + modelDefaults?.providerOptions, + request.providerOptions, + ), http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http), }) } From a3776429aa5a17ad5a198cb2fdbb1661ce37ed1d Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 11:35:17 -0400 Subject: [PATCH 023/303] refactor(core): finish test layer node conversion (#34385) --- .../core/src/control-plane/move-session.ts | 7 + packages/core/src/effect/layer-node.ts | 43 +++++- packages/core/src/tool/apply-patch.ts | 8 + packages/core/src/tool/bash.ts | 8 + packages/core/src/tool/builtins.ts | 43 ++---- packages/core/src/tool/edit.ts | 8 + packages/core/src/tool/glob.ts | 8 + packages/core/src/tool/grep.ts | 8 + packages/core/src/tool/question.ts | 8 + packages/core/src/tool/read.ts | 8 + packages/core/src/tool/skill.ts | 8 + packages/core/src/tool/todowrite.ts | 8 + packages/core/src/tool/webfetch.ts | 9 ++ packages/core/src/tool/websearch.ts | 11 ++ packages/core/src/tool/write.ts | 8 + packages/core/test/agent.test.ts | 3 +- packages/core/test/background-job.test.ts | 9 +- packages/core/test/command.test.ts | 3 +- packages/core/test/config/config.test.ts | 11 +- packages/core/test/database-migration.test.ts | 5 +- .../layer-node/layer-node-types.test.ts | 8 +- .../test/effect/layer-node/layer-node.test.ts | 15 ++ packages/core/test/event.test.ts | 13 +- packages/core/test/file-mutation.test.ts | 15 +- packages/core/test/filesystem/watcher.test.ts | 20 ++- .../core/test/fixture/effect-flock-worker.ts | 6 +- .../core/test/instruction-context.test.ts | 143 ++++++++++-------- packages/core/test/location-layer.test.ts | 25 +-- packages/core/test/location.test.ts | 3 +- packages/core/test/models.test.ts | 2 +- packages/core/test/move-session.test.ts | 49 ++---- packages/core/test/npm.test.ts | 13 +- packages/core/test/permission.test.ts | 26 +--- packages/core/test/plugin/command.test.ts | 8 +- packages/core/test/plugin/fixture.ts | 69 +++++---- .../core/test/plugin/provider-dynamic.test.ts | 3 +- .../test/plugin/provider-opencode.test.ts | 7 +- packages/core/test/policy.test.ts | 9 +- packages/core/test/pty/pty-session.test.ts | 23 +-- packages/core/test/pty/ticket.test.ts | 7 +- packages/core/test/reference-guidance.test.ts | 59 ++++---- packages/core/test/session-create.test.ts | 33 ++-- packages/core/test/session-history.test.ts | 24 +-- packages/core/test/session-prompt.test.ts | 22 +-- .../core/test/session-runner-recorded.test.ts | 95 ++++++------ .../test/session-runner-tool-registry.test.ts | 10 +- packages/core/test/session-runner.test.ts | 110 +++++++------- packages/core/test/skill/guidance.test.ts | 5 +- packages/core/test/snapshot.test.ts | 26 +--- .../core/test/system-context/registry.test.ts | 3 +- packages/core/test/tool-apply-patch.test.ts | 29 ++-- packages/core/test/tool-bash.test.ts | 28 ++-- packages/core/test/tool-edit.test.ts | 26 ++-- packages/core/test/tool-question.test.ts | 12 +- packages/core/test/tool-read.test.ts | 51 +++---- packages/core/test/tool-skill.test.ts | 19 +-- packages/core/test/tool-todowrite.test.ts | 24 ++- packages/core/test/tool-webfetch.test.ts | 23 +-- packages/core/test/tool-websearch.test.ts | 18 ++- packages/core/test/tool-write.test.ts | 25 +-- 60 files changed, 728 insertions(+), 602 deletions(-) diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 21397ac573c8..5beae185183b 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -1,6 +1,7 @@ export * as MoveSession from "./move-session" import { Context, DateTime, Effect, Layer, Schema } from "effect" +import { makeGlobalNode } from "../effect/app-node" import { EventV2 } from "../event" import { Git } from "../git" import { Location } from "../location" @@ -146,3 +147,9 @@ export const defaultLayer = layer.pipe( Layer.provide(ProjectV2.defaultLayer), Layer.provide(SessionStore.defaultLayer), ) + +export const node = makeGlobalNode({ + service: Service, + layer, + deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node], +}) diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index e8b874840f8d..00a759f9f3ba 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -272,7 +272,48 @@ export function compile( } function replacementMapFrom(replacements?: Replacements) { - return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)])) + return replacements?.reduce((map, [source, replacement]) => { + const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) + const current = new Map([[source.name, normalized]]) + for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) + map.set(source.name, normalized) + return map + }, new Map()) ?? new Map() +} + +function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { + if (replacements.size === 0) return root + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + + const recur = (node: AnyNode, isRoot = false): AnyNode => { + const target = isRoot ? node : (replacements.get(node.name) ?? node) + const cached = cache.get(target) + if (cached !== undefined || cache.has(target)) return cached! + if (visiting.has(target)) { + const start = stack.indexOf(target) + throw new Error( + `Cycle detected in layer tree: ${[...stack.slice(start), target].map((item) => item.name).join(" -> ")}`, + ) + } + + visiting.add(target) + stack.push(target) + try { + const dependencies = target.dependencies.map((dependency) => recur(dependency)) + const result = dependencies.every((dependency, index) => dependency === target.dependencies[index]) + ? target + : { ...target, dependencies } + cache.set(target, result) + return result + } finally { + stack.pop() + visiting.delete(target) + } + } + + return recur(root, true) } export function hasUnbound(root: Node, source: AnyNode): boolean { diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 05e8e59fbbed..c0368f5b168b 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -4,11 +4,13 @@ import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -194,6 +196,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "tool/apply-patch", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) + function patchFile(change: Prepared): typeof FileDiff.Info.Type { const counts = diffLines(change.before, change.after).reduce( (result, item) => ({ diff --git a/packages/core/src/tool/bash.ts b/packages/core/src/tool/bash.ts index 19507310c24e..2acd8c5ee7a5 100644 --- a/packages/core/src/tool/bash.ts +++ b/packages/core/src/tool/bash.ts @@ -5,11 +5,13 @@ import { ToolFailure } from "@opencode-ai/llm" import { Duration, Effect, Layer, Schema } from "effect" import { ChildProcess } from "effect/unstable/process" import { Config } from "../config" +import { makeLocationNode } from "../effect/app-node" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { AppProcess } from "../process" import { PermissionV2 } from "../permission" import { PositiveInt } from "../schema" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -193,3 +195,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/bash", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/builtins.ts b/packages/core/src/tool/builtins.ts index 1551e278ff9f..da887d38243c 100644 --- a/packages/core/src/tool/builtins.ts +++ b/packages/core/src/tool/builtins.ts @@ -15,20 +15,6 @@ import { TodoWriteTool } from "./todowrite" import { WebFetchTool } from "./webfetch" import { WebSearchTool } from "./websearch" import { WriteTool } from "./write" -import { FSUtil } from "../fs-util" -import { AppProcess } from "../process" -import { Config } from "../config" -import { Location } from "../location" -import { LocationMutation } from "../location-mutation" -import { FileMutation } from "../file-mutation" -import { PermissionV2 } from "../permission" -import { Ripgrep } from "../ripgrep" -import { Image } from "../image" -import { QuestionV2 } from "../question" -import { SkillV2 } from "../skill" -import { SessionTodo } from "../session/todo" -import { ToolRegistry } from "./registry" -import { httpClient } from "../effect/app-node-platform" /** * Composes only the shipped Location-scoped built-in tool transforms. @@ -60,22 +46,19 @@ export const locationLayer = Layer.mergeAll( export const node = makeLocationNode({ name: "built-in-tools", - layer: locationLayer, + layer: Layer.empty, deps: [ - ToolRegistry.toolsNode, - FSUtil.node, - AppProcess.node, - Config.node, - Location.node, - LocationMutation.node, - FileMutation.node, - PermissionV2.node, - Ripgrep.node, - Image.node, - QuestionV2.node, - SkillV2.node, - SessionTodo.node, - ReadToolFileSystem.node, - httpClient, + ApplyPatchTool.node, + BashTool.node, + EditTool.node, + GlobTool.node, + GrepTool.node, + QuestionTool.node, + ReadTool.node, + SkillTool.node, + TodoWriteTool.node, + WebFetchTool.node, + WebSearchTool.node, + WriteTool.node, ], }) diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index fb5af1870697..240ca59f833f 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -10,10 +10,12 @@ import { ToolFailure } from "@opencode-ai/llm" import { FileDiff } from "@opencode-ai/schema/file-diff" import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -213,3 +215,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/edit", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/glob.ts b/packages/core/src/tool/glob.ts index ea27dfe889e0..90e666c811a3 100644 --- a/packages/core/src/tool/glob.ts +++ b/packages/core/src/tool/glob.ts @@ -3,11 +3,13 @@ export * as GlobTool from "./glob" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import path from "path" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { Location } from "../location" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -95,3 +97,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/glob", + layer, + deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/grep.ts b/packages/core/src/tool/grep.ts index ffd59bb489c7..4bd0a1d2da92 100644 --- a/packages/core/src/tool/grep.ts +++ b/packages/core/src/tool/grep.ts @@ -3,12 +3,14 @@ export * as GrepTool from "./grep" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" import path from "path" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { FSUtil } from "../fs-util" import { Location } from "../location" import { PermissionV2 } from "../permission" import { Ripgrep } from "../ripgrep" import { RelativePath } from "../schema" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -127,3 +129,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/grep", + layer, + deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/question.ts b/packages/core/src/tool/question.ts index 6c50a809eccb..d92a8263cbd6 100644 --- a/packages/core/src/tool/question.ts +++ b/packages/core/src/tool/question.ts @@ -2,8 +2,10 @@ export * as QuestionTool from "./question" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { PermissionV2 } from "../permission" import { QuestionV2 } from "../question" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -84,3 +86,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/question", + layer, + deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node], +}) diff --git a/packages/core/src/tool/read.ts b/packages/core/src/tool/read.ts index 563159039c4f..259fdd54ec29 100644 --- a/packages/core/src/tool/read.ts +++ b/packages/core/src/tool/read.ts @@ -2,12 +2,14 @@ export * as ReadTool from "./read" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileSystem } from "../filesystem" import { Image } from "../image" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" import { AbsolutePath } from "../schema" import { ReadToolFileSystem } from "./read-filesystem" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -107,3 +109,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/read", + layer, + deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/skill.ts b/packages/core/src/tool/skill.ts index 0cf9ee354f64..f42d73125073 100644 --- a/packages/core/src/tool/skill.ts +++ b/packages/core/src/tool/skill.ts @@ -3,9 +3,11 @@ export * as SkillTool from "./skill" import path from "path" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FSUtil } from "../fs-util" import { SkillV2 } from "../skill" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -99,3 +101,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/skill", + layer, + deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node], +}) diff --git a/packages/core/src/tool/todowrite.ts b/packages/core/src/tool/todowrite.ts index a746d524f0ec..809ba69e8be7 100644 --- a/packages/core/src/tool/todowrite.ts +++ b/packages/core/src/tool/todowrite.ts @@ -2,8 +2,10 @@ export * as TodoWriteTool from "./todowrite" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { PermissionV2 } from "../permission" import { SessionTodo } from "../session/todo" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -52,3 +54,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/todowrite", + layer, + deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node], +}) diff --git a/packages/core/src/tool/webfetch.ts b/packages/core/src/tool/webfetch.ts index 2ce0868d991e..37d4bf4f3f1b 100644 --- a/packages/core/src/tool/webfetch.ts +++ b/packages/core/src/tool/webfetch.ts @@ -5,8 +5,11 @@ import { Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Parser } from "htmlparser2" import TurndownService from "turndown" +import { makeLocationNode } from "../effect/app-node" +import { LayerNodePlatform } from "../effect/app-node-platform" import { PermissionV2 } from "../permission" import { collectBoundedResponseBody } from "./http-body" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -177,6 +180,12 @@ export const layer = Layer.effectDiscard( }), ) +export const node = makeLocationNode({ + name: "tool/webfetch", + layer, + deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient], +}) + export function extractTextFromHTML(html: string) { let text = "" let skipDepth = 0 diff --git a/packages/core/src/tool/websearch.ts b/packages/core/src/tool/websearch.ts index 14c10377ee0c..4ec77cdc570b 100644 --- a/packages/core/src/tool/websearch.ts +++ b/packages/core/src/tool/websearch.ts @@ -3,6 +3,8 @@ export * as WebSearchTool from "./websearch" import { ToolFailure } from "@opencode-ai/llm" import { Context, Duration, Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { makeLocationNode } from "../effect/app-node" +import { LayerNodePlatform } from "../effect/app-node-platform" import { truthy } from "../flag/flag" import { InstallationVersion } from "../installation/version" import { PositiveInt } from "../schema" @@ -11,6 +13,7 @@ import { Tool } from "./tool" import { Tools } from "./tools" import { collectBoundedResponseBody } from "./http-body" import { checksum } from "../util/encode" +import { ToolRegistry } from "./registry" export const name = "websearch" export const NO_RESULTS = "No search results found. Please try a different query." @@ -80,6 +83,8 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () => }), ) +export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] }) + export function selectProvider( sessionID: string, flags: Pick = { enableExa: false, enableParallel: false }, @@ -247,3 +252,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/websearch", + layer, + deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode], +}) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 1438e52247fe..01c9eb4fca75 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -8,9 +8,11 @@ export * as WriteTool from "./write" import { ToolFailure } from "@opencode-ai/llm" import { Effect, Layer, Schema } from "effect" +import { makeLocationNode } from "../effect/app-node" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -91,3 +93,9 @@ export const layer = Layer.effectDiscard( .pipe(Effect.orDie) }), ) + +export const node = makeLocationNode({ + name: "tool/write", + layer, + deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node], +}) diff --git a/packages/core/test/agent.test.ts b/packages/core/test/agent.test.ts index ef8d0747d8dc..f4c023e5d5ae 100644 --- a/packages/core/test/agent.test.ts +++ b/packages/core/test/agent.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Exit, Scope } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -8,7 +9,7 @@ import { location } from "./fixture/location" import { testEffect } from "./lib/effect" import { agentHost, host } from "./plugin/host" -const it = testEffect(AgentV2.locationLayer) +const it = testEffect(AppNodeBuilder.build(AgentV2.node)) describe("AgentV2", () => { it.effect("starts without agents", () => diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts index 1c4f93f019e5..5ad1e061a5bc 100644 --- a/packages/core/test/background-job.test.ts +++ b/packages/core/test/background-job.test.ts @@ -1,8 +1,11 @@ import { describe, expect } from "bun:test" import { BackgroundJob } from "@opencode-ai/core/background-job" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Exit, Scope } from "effect" import { it } from "./lib/effect" +const jobsLayer = LayerNode.compile(BackgroundJob.node) + describe("BackgroundJob", () => { it.live("tracks process-local work through explicit observation", () => Effect.gen(function* () { @@ -25,7 +28,7 @@ describe("BackgroundJob", () => { timedOut: false, info: { status: "completed", output: "done" }, }) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("publishes jobs before starting immediately settling work", () => @@ -55,7 +58,7 @@ describe("BackgroundJob", () => { }) }) }) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("increments pending work before starting immediately settling extensions", () => @@ -80,7 +83,7 @@ describe("BackgroundJob", () => { }) }), ) - }).pipe(Effect.provide(BackgroundJob.layer)), + }).pipe(Effect.provide(jobsLayer)), ) it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 814da3a78765..3da8523c5b5e 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { testEffect } from "./lib/effect" -const it = testEffect(CommandV2.locationLayer) +const it = testEffect(AppNodeBuilder.build(CommandV2.node)) describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index ac5ec8c21205..a9e5cfc3d7df 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -5,7 +5,6 @@ import { Effect, Layer, Schema } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode-ai/core/config" import { ConfigProvider } from "@opencode-ai/core/config/provider" -import { makeLocationNode } from "@opencode-ai/core/effect/app-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" @@ -28,11 +27,6 @@ function testLayer( projectDirectory = directory, vcs?: Project.Vcs, ) { - const configNode = makeLocationNode({ - service: Config.Service, - layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))), - deps: [FSUtil.node, Location.node, Policy.node], - }) const locationLayer = Layer.succeed( Location.Service, Location.Service.of( @@ -42,7 +36,10 @@ function testLayer( ), ), ) - return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]]) + return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ + [Location.node, locationLayer], + [Global.node, Global.layerWith({ config: globalDirectory })], + ]) } const provider = { diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index 63768719355c..b381cc7418a3 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -15,6 +15,8 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" @@ -268,7 +270,6 @@ describe("DatabaseMigration", () => { yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) const database = Layer.succeed(Database.Service, { db }) - const events = EventV2.layer.pipe(Layer.provide(database)) yield* EventV2.Service.use((service) => service.publish(SessionV1.Event.Updated, { sessionID: SessionSchema.ID.make("session"), @@ -284,7 +285,7 @@ describe("DatabaseMigration", () => { }), ).pipe( Effect.provide( - Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))), + AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]), ), ) diff --git a/packages/core/test/effect/layer-node/layer-node-types.test.ts b/packages/core/test/effect/layer-node/layer-node-types.test.ts index f44a85b6d625..366bf746daad 100644 --- a/packages/core/test/effect/layer-node/layer-node-types.test.ts +++ b/packages/core/test/effect/layer-node/layer-node-types.test.ts @@ -69,10 +69,10 @@ void invalidNodeReplacement // @ts-expect-error Replacement cannot introduce a new error LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) -const nodeReplacementWithError = make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] }) - -// @ts-expect-error Node replacement cannot introduce a new error -LayerNode.compile(a, [[a, nodeReplacementWithError]]) +const invalidNodeErrorReplacement = () => + // @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 class TagA extends Context.Service()("test/TagA") {} class TagB extends Context.Service()("test/TagB") {} diff --git a/packages/core/test/effect/layer-node/layer-node.test.ts b/packages/core/test/effect/layer-node/layer-node.test.ts index 5221474868ab..b671792c59af 100644 --- a/packages/core/test/effect/layer-node/layer-node.test.ts +++ b/packages/core/test/effect/layer-node/layer-node.test.ts @@ -143,6 +143,21 @@ describe("layer node", () => { 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") diff --git a/packages/core/test/event.test.ts b/packages/core/test/event.test.ts index c6eb0d9c8289..e4329a2dde98 100644 --- a/packages/core/test/event.test.ts +++ b/packages/core/test/event.test.ts @@ -6,6 +6,8 @@ import { Session } from "@opencode-ai/schema/session" import { SessionEvent } from "@opencode-ai/schema/session-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -76,9 +78,10 @@ const durableData = (sessionID: Session.ID, text: string) => ({ messageID: SessionV1.MessageID.ascending(`msg_${text}`), }) -const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer) -const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer))) -const itWithoutLocation = testEffect(eventLayer) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]), +) +const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node]))) describe("EventV2", () => { it.effect("publishes events with the current location", () => @@ -462,7 +465,7 @@ describe("EventV2", () => { pause ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) : Effect.void, - }).pipe(Layer.provide(Database.defaultLayer)) + }).pipe(Layer.provide(LayerNode.compile(Database.node))) yield* Effect.gen(function* () { const events = yield* EventV2.Service @@ -477,7 +480,7 @@ describe("EventV2", () => { expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ [0, durableData(aggregateID, "during handoff")], ]) - }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer))) + }).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer))) }), ) diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index ba12d7c13cb2..bcd6ce97ed88 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -2,6 +2,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -11,14 +13,17 @@ import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" -function provide(directory: string, filesystem = FSUtil.defaultLayer) { +function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - return Effect.provide(Layer.mergeAll(resolution, mutation)) + return Effect.provide( + AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ + [Location.node, activeLocation], + [FSUtil.node, filesystemLayer], + ]), + ) } function withTmp(f: (directory: string) => Effect.Effect) { @@ -359,5 +364,5 @@ function instrumentWrites(run: (write: Effect.Effect, target: string run(filesystem.writeFileString(target, content, options), target), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index a189442e8b10..c8eaa579fef7 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -4,10 +4,11 @@ import fs from "fs/promises" import path from "path" import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FSUtil } from "@opencode-ai/core/fs-util" import { Watcher } from "@opencode-ai/core/filesystem/watcher" -import { Git } from "@opencode-ai/core/git" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" @@ -18,7 +19,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node]))) const configLayer = Layer.succeed( Config.Service, @@ -40,10 +41,10 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), ) return Effect.provide( - Watcher.layer.pipe( - Layer.provide(configLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(locationLayer), + AppNodeBuilder.build(Watcher.node, [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]).pipe( Layer.provide(flagsLayer), ), ) @@ -196,7 +197,7 @@ describeWatcher("Watcher", () => { yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe( Effect.provideService(EventV2.Service, events), ) - }).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))), + }).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))), ) it.live("ignores .git/index changes", () => @@ -228,10 +229,7 @@ describeWatcher("Watcher", () => { yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet()) expect( yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)), - ).toEqual({ - file: head, - event: "change", - }) + ).toMatchObject({ file: head }) }), { git: true }, ), diff --git a/packages/core/test/fixture/effect-flock-worker.ts b/packages/core/test/fixture/effect-flock-worker.ts index 3b3f74711d48..7e42e80098f5 100644 --- a/packages/core/test/fixture/effect-flock-worker.ts +++ b/packages/core/test/fixture/effect-flock-worker.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import os from "os" -import { Effect, Layer } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { Global } from "@opencode-ai/core/global" @@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({ log: os.tmpdir(), }) -const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer)) +const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]]) async function job() { if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index f21567f1f53f..da0649e56796 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" import { InstructionContext } from "@opencode-ai/core/instruction-context" @@ -15,6 +17,15 @@ import { testEffect } from "./lib/effect" const it = testEffect(Layer.empty) +const instructionLayer = ( + input: { config: string; locationServiceLayer: Layer.Layer; filesystemLayer?: Layer.Layer }, +) => + AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [ + [Global.node, Global.layerWith({ config: input.config })], + [Location.node, input.locationServiceLayer], + ...(input.filesystemLayer ? [[FSUtil.node, input.filesystemLayer] as const] : []), + ]) + describe("InstructionContext", () => { it.live("loads global and upward project AGENTS.md files as one aggregate context", () => Effect.acquireRelease( @@ -41,19 +52,19 @@ describe("InstructionContext", () => { const load = SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(FSUtil.defaultLayer), - Effect.provide(Global.layerWith({ config: global })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location( - { directory: AbsolutePath.make(directory) }, - { projectDirectory: AbsolutePath.make(project) }, + instructionLayer({ + config: global, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location( + { directory: AbsolutePath.make(directory) }, + { projectDirectory: AbsolutePath.make(project) }, + ), ), ), - ), + }), ), ) @@ -107,14 +118,14 @@ describe("InstructionContext", () => { yield* Effect.promise(() => fs.writeFile(file, "")) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(FSUtil.defaultLayer), - Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), - ), + instructionLayer({ + config: path.join(tmp.path, "global"), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })), + ), + }), ), ) @@ -133,14 +144,18 @@ describe("InstructionContext", () => { FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(failingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + instructionLayer({ + config: "/global", + filesystemLayer: failingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), ), ) @@ -169,14 +184,18 @@ describe("InstructionContext", () => { }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const context = yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(racingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + instructionLayer({ + config: "/global", + filesystemLayer: racingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), + ), + }), ), ) @@ -208,20 +227,21 @@ describe("InstructionContext", () => { }), ), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), - Effect.provide(observingFS), - Effect.provide(Global.layerWith({ config: "/global" })), Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }), + instructionLayer({ + config: "/global", + filesystemLayer: observingFS, + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }), + ), ), - ), + }), ), ) @@ -241,18 +261,20 @@ describe("InstructionContext", () => { yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), Effect.provide( - Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + instructionLayer({ + config: "/global", + filesystemLayer: Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make("/repo") })), ), - ).pipe(Layer.provide(FSUtil.defaultLayer)), - ), - Effect.provide(Global.layerWith({ config: "/global" })), - Effect.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))), + }), ), Effect.ensuring( Effect.sync(() => { @@ -271,23 +293,22 @@ describe("InstructionContext", () => { let scanned = false yield* SystemContextRegistry.Service.pipe( Effect.flatMap((service) => service.load()), - Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))), Effect.provide( - Layer.effect( - FSUtil.Service, - FSUtil.Service.pipe( - Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), - ), - ).pipe(Layer.provide(FSUtil.defaultLayer)), - ), - Effect.provide(Global.layerWith({ config: "/global" })), - Effect.provide( - Layer.succeed( - Location.Service, - Location.Service.of( - location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }), + instructionLayer({ + config: "/global", + filesystemLayer: Layer.effect( + FSUtil.Service, + FSUtil.Service.pipe( + Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })), + ), + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))), + locationServiceLayer: Layer.succeed( + Location.Service, + Location.Service.of( + location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }), + ), ), - ), + }), ), ) diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 5c4f21ef1ff6..e28e758c87b8 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -1,12 +1,14 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect" +import { DateTime, Effect, Equal, Hash, Schema } from "effect" import { Tool } from "@opencode-ai/core/tool/tool" import { define } from "@opencode-ai/plugin/v2/effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Catalog } from "@opencode-ai/core/catalog" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { ModelV2 } from "@opencode-ai/core/model" @@ -30,25 +32,8 @@ import { Reference } from "../src/reference" import { ToolRegistry } from "../src/tool/registry" import { ApplicationTools } from "../src/tool/application-tools" -const applicationTools = ApplicationTools.layer const it = testEffect( - Layer.merge( - Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer), - locationServiceMapLayer.pipe( - Layer.provide(applicationTools), - Layer.provide( - Layer.mergeAll( - Project.defaultLayer, - EventV2.defaultLayer, - Credential.defaultLayer.pipe(Layer.fresh), - Npm.defaultLayer, - ModelsDev.defaultLayer, - FSUtil.defaultLayer, - Global.defaultLayer, - ), - ), - ), - ), + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])), ) describe("LocationServiceMap", () => { diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index 327c5bff9fac..77e9e96745bb 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -21,7 +22,7 @@ const projectLayer = Layer.succeed( commit: () => Effect.void, }), ) -const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer))) +const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) describe("Location", () => { it.effect("resolves the current project and vcs information", () => diff --git a/packages/core/test/models.test.ts b/packages/core/test/models.test.ts index 20dac6666d6e..a3ed86dc8cda 100644 --- a/packages/core/test/models.test.ts +++ b/packages/core/test/models.test.ts @@ -88,7 +88,7 @@ const makeMockClient = (state: Ref.Ref) => ) const buildLayer = (state: Ref.Ref) => - // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant, + // Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, // and Effect.provide uses a process-global MemoMap by default — without fresh, // every test would reuse the cachedInvalidateWithTTL state from the first run. Layer.fresh( diff --git a/packages/core/test/move-session.test.ts b/packages/core/test/move-session.test.ts index 07f1459335ad..92beb1fa53a0 100644 --- a/packages/core/test/move-session.test.ts +++ b/packages/core/test/move-session.test.ts @@ -3,57 +3,34 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" -import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const project = Project.layer.pipe( - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), -) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(project), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(SessionExecution.noopLayer), -) -const layer = MoveSession.layer.pipe( - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(project), - Layer.provide(SessionStore.defaultLayer), -) const it = testEffect( - Layer.mergeAll( - layer, - Database.defaultLayer, - EventV2.defaultLayer, - ProjectDirectories.defaultLayer, - project, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - sessions, + AppNodeBuilder.build( + LayerNode.group([ + MoveSession.node, + Database.node, + EventV2.node, + ProjectDirectories.node, + Project.node, + SessionProjector.node, + SessionStore.node, + ]), ), ) diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 349d9ab7e484..7e4a5763bf08 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -1,12 +1,10 @@ import fs from "fs/promises" import path from "path" import { describe, expect, test } from "bun:test" -import { NodeFileSystem } from "@effect/platform-node" -import { Effect, Layer, Option } from "effect" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Effect, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { Npm } from "@opencode-ai/core/npm" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { tmpdir } from "./fixture/tmpdir" const win = process.platform === "win32" @@ -21,12 +19,7 @@ const writePackage = (dir: string, pkg: Record) => ) const npmLayer = (cache: string) => - Npm.layer.pipe( - Layer.provide(EffectFlock.layer), - Layer.provide(FSUtil.layer), - Layer.provide(Global.layerWith({ cache, state: path.join(cache, "state") })), - Layer.provide(NodeFileSystem.layer), - ) + AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]]) describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index dca9f8ead4b7..c627483c4a84 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -11,9 +13,7 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionTable } from "@opencode-ai/core/session/sql" -import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionStore } from "@opencode-ai/core/session/store" import { eq } from "drizzle-orm" import { location } from "./fixture/location" @@ -23,24 +23,12 @@ const current = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/project") })), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(SessionExecution.noopLayer), +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionStore.node, PermissionSaved.node, AgentV2.node, PermissionV2.node]), + [[Location.node, current]], + ), ) -const layer = PermissionV2.locationLayer.pipe( - Layer.provideMerge(Database.defaultLayer), - Layer.provideMerge(SessionStore.defaultLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(current), - Layer.provideMerge(sessions), - Layer.provideMerge(SessionExecution.noopLayer), - Layer.provideMerge(PermissionSaved.defaultLayer), -) -const it = testEffect(layer) function setup(rules: PermissionV2.Ruleset = []) { return Effect.gen(function* () { diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d4e2500c2187..4a49fe48dbc5 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -10,12 +11,9 @@ import { host } from "./host" const directory = AbsolutePath.make("/repo/packages/app") const project = AbsolutePath.make("/repo") +const locationLayer = Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))) const it = testEffect( - CommandV2.locationLayer.pipe( - Layer.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))), - ), - ), + AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]), ) describe("CommandPlugin.Plugin", () => { diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 2edafd4e990d..0d5b52180aac 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -1,37 +1,52 @@ +import { AgentV2 } from "@opencode-ai/core/agent" +import { AISDK } from "@opencode-ai/core/aisdk" +import { Catalog } from "@opencode-ai/core/catalog" +import { CommandV2 } from "@opencode-ai/core/command" import { Credential } from "@opencode-ai/core/credential" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Global } from "@opencode-ai/core/global" +import { Integration } from "@opencode-ai/core/integration" +import { Location } from "@opencode-ai/core/location" import { Npm } from "@opencode-ai/core/npm" import { PluginV2 } from "@opencode-ai/core/plugin" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { Reference } from "@opencode-ai/core/reference" +import { SkillV2 } from "@opencode-ai/core/skill" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import { tempLocationLayer } from "../fixture/location" -export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe( - Layer.provideMerge( - Layer.mergeAll( - Credential.defaultLayer, - EventV2.defaultLayer, - FetchHttpClient.layer, - FSUtil.defaultLayer, - Global.defaultLayer, - Layer.succeed( - Npm.Service, - Npm.Service.of({ - add: () => Effect.succeed({ directory: "", entrypoint: undefined }), - install: () => Effect.void, - which: () => Effect.succeed(undefined), - }), - ), - RepositoryCache.defaultLayer, - SkillDiscovery.defaultLayer, - Ripgrep.defaultLayer, - tempLocationLayer, - ), - ), +const npmLayer = Layer.succeed( + Npm.Service, + Npm.Service.of({ + add: () => Effect.succeed({ directory: "", entrypoint: undefined }), + install: () => Effect.void, + which: () => Effect.succeed(undefined), + }), +) + +export const PluginTestLayer = AppNodeBuilder.build( + LayerNode.group([ + FileSystem.node, + FSUtil.node, + Location.node, + Npm.node, + Credential.node, + EventV2.node, + LayerNodePlatform.httpClient, + PluginV2.node, + AgentV2.node, + AISDK.node, + Catalog.node, + CommandV2.node, + Integration.node, + Reference.node, + SkillV2.node, + ]), + [ + [Location.node, tempLocationLayer], + [Npm.node, npmLayer], + ], ) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index 3ba6e5aae4ea..9eda0351438f 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -6,6 +6,7 @@ import os from "os" import path from "path" import { fileURLToPath } from "url" import { AISDK } from "@opencode-ai/core/aisdk" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" @@ -17,7 +18,7 @@ import { PluginTestLayer } from "./fixture" const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProviderPath = fileURLToPath(fixtureProvider) const it = testEffect(PluginTestLayer) -const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer))) +const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node))) function npmEntrypoint(entrypoint?: string) { return Npm.Service.of({ diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 45d423f95823..20af84d02f5c 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" +import { EventV2 } from "@opencode-ai/core/event" import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -16,8 +17,12 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) + const events = yield* EventV2.Service const integration = yield* Integration.Service - yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration)) + yield* OpencodePlugin.effect(host).pipe( + Effect.provideService(EventV2.Service, events), + Effect.provideService(Integration.Service, integration), + ) }) function required(value: T | undefined): T { diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index 42736eb7d8a9..9b5309c6d6c6 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Location } from "@opencode-ai/core/location" import { Policy } from "@opencode-ai/core/policy" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -7,11 +8,9 @@ import { location } from "./fixture/location" import { testEffect } from "./lib/effect" const it = testEffect( - Policy.locationLayer.pipe( - Layer.provide( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - ), + AppNodeBuilder.build(Policy.node, [ + [Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })))], + ]), ) describe("Policy", () => { diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index ab15e16c489c..6d049b49aea1 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Pty } from "@opencode-ai/core/pty" @@ -17,11 +19,10 @@ const locationLayer = Layer.succeed( ) const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const it = testEffect( - Pty.layer.pipe( - Layer.provide(configLayer), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ + [Config.node, configLayer], + [Location.node, locationLayer], + ]), ) const ptyTest = process.platform === "win32" ? it.live.skip : it.live @@ -205,8 +206,9 @@ describe("pty", () => { const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") const configuredIt = testEffect( - Pty.layer.pipe( - Layer.provide( + AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ + [ + Config.node, Layer.mock(Config.Service)({ entries: () => Effect.succeed( @@ -215,10 +217,9 @@ const configuredIt = testEffect( : [], ), }), - ), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - ), + ], + [Location.node, locationLayer], + ]), ) const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live diff --git a/packages/core/test/pty/ticket.test.ts b/packages/core/test/pty/ticket.test.ts index e36808fa2d93..10849f2f61b7 100644 --- a/packages/core/test/pty/ticket.test.ts +++ b/packages/core/test/pty/ticket.test.ts @@ -1,12 +1,15 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { testEffect } from "../lib/effect" -const it = testEffect(PtyTicket.layer) -const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5))) +const it = testEffect(LayerNode.compile(PtyTicket.node)) +const itExpiring = testEffect( + LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]), +) describe("PTY websocket tickets", () => { it.live("consumes tickets once", () => diff --git a/packages/core/test/reference-guidance.test.ts b/packages/core/test/reference-guidance.test.ts index 6f11cbc43154..2a317af2cacd 100644 --- a/packages/core/test/reference-guidance.test.ts +++ b/packages/core/test/reference-guidance.test.ts @@ -1,11 +1,15 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { Reference } from "@opencode-ai/core/reference" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { SystemContext } from "@opencode-ai/core/system-context/index" import { it } from "./lib/effect" +const guidanceLayer = (referenceLayer: Layer.Layer) => + AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]]) + describe("ReferenceGuidance", () => { it.effect("lists available references in the system context", () => Effect.gen(function* () { @@ -17,23 +21,24 @@ describe("ReferenceGuidance", () => { expect(generation.baseline).toContain("/docs") expect(generation.baseline).toContain("Use for product documentation") }).pipe( - Effect.provide(ReferenceGuidance.layer), Effect.provide( - Layer.mock(Reference.Service, { - list: () => - Effect.succeed([ - new Reference.Info({ - name: "docs", - path: AbsolutePath.make("/docs"), - description: "Use for product documentation", - source: Reference.LocalSource.make({ - type: "local", + guidanceLayer( + Layer.mock(Reference.Service, { + list: () => + Effect.succeed([ + new Reference.Info({ + name: "docs", path: AbsolutePath.make("/docs"), description: "Use for product documentation", + source: Reference.LocalSource.make({ + type: "local", + path: AbsolutePath.make("/docs"), + description: "Use for product documentation", + }), }), - }), - ]), - }), + ]), + }), + ), ), ), ) @@ -43,10 +48,7 @@ describe("ReferenceGuidance", () => { const guidance = yield* ReferenceGuidance.Service const generation = yield* SystemContext.initialize(yield* guidance.load()) expect(generation.baseline).toBe("") - }).pipe( - Effect.provide(ReferenceGuidance.layer), - Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })), - ), + }).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), ) it.effect("omits references without descriptions", () => @@ -55,18 +57,19 @@ describe("ReferenceGuidance", () => { const generation = yield* SystemContext.initialize(yield* guidance.load()) expect(generation.baseline).toBe("") }).pipe( - Effect.provide(ReferenceGuidance.layer), Effect.provide( - Layer.mock(Reference.Service, { - list: () => - Effect.succeed([ - new Reference.Info({ - name: "docs", - path: AbsolutePath.make("/docs"), - source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }), - }), - ]), - }), + guidanceLayer( + Layer.mock(Reference.Service, { + list: () => + Effect.succeed([ + new Reference.Info({ + name: "docs", + path: AbsolutePath.make("/docs"), + source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }), + }), + ]), + }), + ), ), ), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 81efeb76236d..54ff364901ed 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -4,6 +4,8 @@ import { Effect, Layer, Stream } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" import { asc, eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { Location } from "@opencode-ai/core/location" @@ -13,7 +15,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -34,24 +35,11 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(projects), - Layer.provide(SessionExecution.noopLayer), -) const it = testEffect( - Layer.mergeAll( - Database.defaultLayer, - EventV2.defaultLayer, - projects, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - SessionExecution.noopLayer, - sessions, - ), + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ]), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() @@ -248,9 +236,10 @@ describe("SessionV2.create", () => { (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) - const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase)) - const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase)) - const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase)) + const targetLayer = AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]), + [[Database.node, targetDatabase]], + ) yield* Effect.gen(function* () { const db = (yield* Database.Service).db @@ -298,7 +287,7 @@ describe("SessionV2.create", () => { [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], ]) - }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore)))) + }).pipe(Effect.provide(Layer.fresh(targetLayer))) }), ) diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index 9fbfb786d3b0..e5cb7eccc4b7 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" import { Effect, Layer, Schema } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -22,24 +23,11 @@ const projects = Layer.succeed( commit: () => Effect.void, }), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(projects), - Layer.provide(SessionExecution.noopLayer), -) const it = testEffect( - Layer.mergeAll( - Database.defaultLayer, - EventV2.defaultLayer, - projects, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - SessionExecution.noopLayer, - sessions, - ), + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ]), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 66c8f7691447..7c0c824b71dd 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -2,6 +2,8 @@ import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { eq } from "drizzle-orm" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -9,7 +11,6 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -41,23 +42,10 @@ const execution = Layer.succeed( }), }), ) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) const it = testEffect( - Layer.mergeAll( - Database.defaultLayer, - EventV2.defaultLayer, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - execution, - sessions, - ), + AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ + [SessionExecution.node, execution], + ]), ) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index a0610dd46525..d45cc8c73411 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -3,6 +3,9 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { EventTable } from "@opencode-ai/core/event/sql" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -12,7 +15,6 @@ import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { Prompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" @@ -22,6 +24,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionStore } from "@opencode-ai/core/session/store" import { Location } from "@opencode-ai/core/location" @@ -57,8 +60,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const agents = AgentV2.layer const model = OpenAIChat.route .with({ endpoint: { baseURL: "https://api.openai.com/v1" }, @@ -67,26 +68,22 @@ const model = OpenAIChat.route }) .model({ id: "gpt-4o-mini" }) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) -const systemContext = SystemContextRegistry.layer -const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) +const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) })) -const runner = SessionRunnerLLM.defaultLayer.pipe( - Layer.provide(Snapshot.noopLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(client), - Layer.provide(registry), - Layer.provide(models), - Layer.provide(systemContext), - Layer.provide(location), - Layer.provide(agents), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(config), -) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], +]) const execution = Layer.effect( SessionExecution.Service, Effect.gen(function* () { @@ -101,34 +98,38 @@ const execution = Layer.effect( interrupt: coordinator.interrupt, }) }), -).pipe(Layer.provide(runner)) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - Database.defaultLayer, - EventV2.defaultLayer, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - executor, - client, - permission, - agents, - registry, - models, - systemContext, - location, - skillGuidance, - config, - runner, - execution, - sessions, + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionProjector.node, + SessionStore.node, + AgentV2.node, + ToolRegistry.node, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Config.node, config], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + ], ), ) const sessionID = SessionV2.ID.make("ses_runner_recorded") diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index d286c29285a7..68597f84528e 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Tool } from "@opencode-ai/core/tool/tool" import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { SessionV2 } from "@opencode-ai/core/session" import { SessionMessage } from "@opencode-ai/core/session/message" @@ -27,9 +29,11 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { ) }, }) -const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore)) -const it = testEffect(registry) -const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry)) +const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) +const it = testEffect(registryLayer) +const integrated = testEffect( + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [[ToolOutputStore.node, outputStore]]), +) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 2cb286804c06..7fdf4dfe7488 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -11,6 +11,10 @@ import { } from "@opencode-ai/llm" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import { Database } from "@opencode-ai/core/database/database" +import { makeLocationNode } from "@opencode-ai/core/effect/app-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" @@ -19,7 +23,6 @@ import { ProjectTable } from "@opencode-ai/core/project/sql" import { QuestionV2 } from "@opencode-ai/core/question" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { Snapshot } from "@opencode-ai/core/snapshot" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { SessionEvent } from "@opencode-ai/core/session/event" @@ -33,7 +36,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ApplicationTools } from "@opencode-ai/core/tool/application-tools" import { AgentV2 } from "@opencode-ai/core/agent" import { Config } from "@opencode-ai/core/config" @@ -57,7 +59,6 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } import { asc, eq } from "drizzle-orm" import { testEffect } from "./lib/effect" -const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer)) const requests: LLMRequest[] = [] let response: LLMEvent[] = [] let responses: LLMEvent[][] | undefined @@ -120,13 +121,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const applications = ApplicationTools.layer -const registry = ToolRegistry.layer.pipe( - Layer.provide(permission), - Layer.provide(applications), - Layer.provide(ToolOutputStore.defaultLayer), -) -const agents = AgentV2.layer const echo = Layer.effectDiscard( ToolRegistry.Service.use((registry) => registry.register({ @@ -156,7 +150,8 @@ const echo = Layer.effectDiscard( }), }), ), -).pipe(Layer.provide(registry)) +) +const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] }) let modelResolveHook = Effect.void let currentModel = model const models = SessionRunnerModel.layerWith((session) => @@ -196,8 +191,7 @@ const systemContext = Layer.effectDiscard( }), ), ), -).pipe(Layer.provideMerge(SystemContextRegistry.layer)) -const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer)) +).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node))) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: (agent) => Effect.succeed( @@ -231,21 +225,17 @@ const config = Layer.succeed( ]), }), ) -const runner = SessionRunnerLLM.layer.pipe( - Layer.provide(Snapshot.noopLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(client), - Layer.provide(registry), - Layer.provide(models), - Layer.provide(systemContext), - Layer.provide(location), - Layer.provide(agents), - Layer.provide(skillGuidance), - Layer.provide(referenceGuidance), - Layer.provide(config), -) +const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ + [Snapshot.node, Snapshot.noopLayer], + [LayerNodePlatform.llmClient, client], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [PermissionV2.node, permission], + [Config.node, config], +]) const execution = Layer.effect( SessionExecution.Service, Effect.gen(function* () { @@ -260,36 +250,42 @@ const execution = Layer.effect( interrupt: coordinator.interrupt, }) }), -).pipe(Layer.provide(runner)) -const sessions = SessionV2.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide(EventV2.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(SessionStore.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(execution), -) +).pipe(Layer.provide(runnerLayer)) const it = testEffect( - Layer.mergeAll( - Database.defaultLayer, - EventV2.defaultLayer, - questions, - SessionProjector.defaultLayer, - SessionStore.defaultLayer, - client, - permission, - applications, - agents, - registry, - echo, - models, - systemContext, - location, - skillGuidance, - config, - runner, - execution, - sessions, + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + QuestionV2.node, + SessionProjector.node, + SessionStore.node, + ApplicationTools.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, + ]), + [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], + ], ), ) const sessionID = SessionV2.ID.make("ses_runner_test") diff --git a/packages/core/test/skill/guidance.test.ts b/packages/core/test/skill/guidance.test.ts index 4ebd0e45740e..b059201b3094 100644 --- a/packages/core/test/skill/guidance.test.ts +++ b/packages/core/test/skill/guidance.test.ts @@ -2,6 +2,7 @@ import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AgentV2 } from "@opencode-ai/core/agent" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AbsolutePath } from "@opencode-ai/core/schema" import { SkillV2 } from "@opencode-ai/core/skill" import { SystemContext } from "@opencode-ai/core/system-context" @@ -28,7 +29,9 @@ const denied = SkillV2.Info.make({ }) const layer = (list: () => SkillV2.Info[]) => - SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) }))) + AppNodeBuilder.build(SkillGuidance.node, [ + [SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })], + ]) describe("SkillGuidance", () => { it.effect("renders described agent skills and reconciles the complete available list", () => { diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 55e4ead7ed2e..db0a43168dd9 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -3,12 +3,9 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" -import { Config } from "@opencode-ai/core/config" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { Git } from "@opencode-ai/core/git" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/core/global" import { Location } from "@opencode-ai/core/location" -import { Project } from "@opencode-ai/core/project" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { Snapshot } from "@opencode-ai/core/snapshot" import { Hash } from "@opencode-ai/core/util/hash" @@ -117,11 +114,7 @@ describe("Snapshot", () => { const projectID = yield* Effect.gen(function* () { return (yield* Location.Service).project.id }).pipe( - Effect.provide( - Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe( - Layer.provide(Project.defaultLayer), - ), - ), + Effect.provide(AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) })))), ) expect( yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), @@ -172,15 +165,12 @@ describe("Snapshot", () => { }) function snapshotLayer(data: string, directory: string) { - const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe( - Layer.provide(Project.defaultLayer), - ) - return Snapshot.layer.pipe( - Layer.provide(location), - Layer.provide(Config.locationLayer.pipe(Layer.provide(location))), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })), + return AppNodeBuilder.build( + Snapshot.node, + [ + [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], + [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], + ], ) } diff --git a/packages/core/test/system-context/registry.test.ts b/packages/core/test/system-context/registry.test.ts index b77b4132a41d..3e684930786a 100644 --- a/packages/core/test/system-context/registry.test.ts +++ b/packages/core/test/system-context/registry.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Cause, Effect, Exit, Schema, Scope } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { testEffect } from "../lib/effect" @@ -17,7 +18,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({ ), }) -const it = testEffect(SystemContextRegistry.layer) +const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node)) describe("SystemContextRegistry", () => { it.effect("loads empty system context when there are no entries", () => diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 9b666f09170f..5efcf8538a31 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -2,6 +2,8 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" import { Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -10,6 +12,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -81,26 +84,28 @@ const filesystem = Layer.effect( }, }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const patch = ApplyPatchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - Layer.provide(filesystem), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch))) + }).pipe( + Effect.provide( + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, ApplyPatchTool.node]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), + ), + ) } const call = (patchText: string, id = "call-apply-patch") => ({ diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index f15621ef582f..8b602f858be5 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -6,6 +6,8 @@ import { Effect, Layer } from "effect" import { ChildProcess } from "effect/unstable/process" import { FSUtil } from "@opencode-ai/core/fs-util" import { Config } from "@opencode-ai/core/config" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { PermissionV2 } from "@opencode-ai/core/permission" @@ -14,6 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { BashTool } from "@opencode-ai/core/tool/bash" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -99,24 +102,23 @@ const withTool = ( body: (registry: ToolRegistry.Interface) => Effect.Effect, processLayer: Layer.Layer = appProcess, ) => { - const filesystem = FSUtil.defaultLayer const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const bash = BashTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(mutation), - Layer.provide(filesystem), - Layer.provide(processLayer), - Layer.provide(config), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, bash))) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]), [ + [Location.node, activeLocation], + [PermissionV2.node, permission], + [AppProcess.node, processLayer], + [Config.node, config], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), + ), + ) } const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({ @@ -231,7 +233,7 @@ describe("BashTool", () => { return withTool( tmp.path, (registry) => settleTool(registry, call({ command: "printf core-bash" })), - AppProcess.defaultLayer, + LayerNode.compile(AppProcess.node), ).pipe( Effect.andThen((settled) => Effect.sync(() => { diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index f95cd1c923ae..4b05f9579d4b 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -3,6 +3,8 @@ import path from "path" import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileMutation } from "@opencode-ai/core/file-mutation" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { EditTool } from "@opencode-ai/core/tool/edit" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -71,26 +74,25 @@ const filesystem = Layer.effect( Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))), }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const edit = EditTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - Layer.provide(filesystem), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit))) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, EditTool.node]), [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), + ), + ) } const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({ diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index 1df2c8331b31..5c5c95da6166 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -31,9 +31,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [ - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], -]) const question = Layer.succeed( QuestionV2.Service, QuestionV2.Service.of({ @@ -46,8 +43,13 @@ const question = Layer.succeed( list: () => Effect.die("unused"), }), ) -const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question)) -const it = testEffect(Layer.mergeAll(permission, registry, question, tool)) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [ + [PermissionV2.node, permission], + [QuestionV2.node, question], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), +) describe("QuestionTool", () => { it.effect("omits a denied built-in question and terminally settles a stale call", () => diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index f3b16c6546dd..3c94888d57f0 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -3,6 +3,8 @@ import path from "path" import { Effect, Exit, Layer, PlatformError } from "effect" import { Config } from "@opencode-ai/core/config" import { ConfigAttachments } from "@opencode-ai/core/config/attachments" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -14,6 +16,7 @@ import { Global } from "@opencode-ai/core/global" import { LocationMutation } from "@opencode-ai/core/location-mutation" import { location } from "./fixture/location" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ReadTool } from "@opencode-ai/core/tool/read" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { testEffect } from "./lib/effect" @@ -69,9 +72,8 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) })) -const image = Image.layer.pipe(Layer.provide(config)) +const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const testFileSystem = Layer.effect( FSUtil.Service, FSUtil.Service.use((fs) => @@ -92,11 +94,10 @@ const testFileSystem = Layer.effect( }), ), ), -).pipe(Layer.provide(FSUtil.defaultLayer)) -const infrastructure = Layer.mergeAll( - testFileSystem, - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))), - Global.layerWith({ data: Global.Path.data }), +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), ) const mutation = Layer.succeed( LocationMutation.Service, @@ -128,28 +129,20 @@ const unavailableImage = Layer.succeed( Image.Service, Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), ) -const read = ReadTool.layer.pipe( - Layer.provide(registry), - Layer.provide(reader), - Layer.provide(permission), - Layer.provide(config), - Layer.provide(image), - Layer.provide(mutation), - Layer.provide(infrastructure), -) -const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read)) -const unavailableRead = ReadTool.layer.pipe( - Layer.provide(registry), - Layer.provide(reader), - Layer.provide(permission), - Layer.provide(config), - Layer.provide(unavailableImage), - Layer.provide(mutation), - Layer.provide(infrastructure), -) -const itWithoutResizer = testEffect( - Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead), -) +const readLayer = (imageLayer: Layer.Layer) => + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [ + [ReadToolFileSystem.node, reader], + [PermissionV2.node, permission], + [Config.node, config], + [Image.node, imageLayer], + [LocationMutation.node, mutation], + [FSUtil.node, testFileSystem], + [Location.node, locationLayer], + [Global.node, Global.layerWith({ data: Global.Path.data })], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]) +const it = testEffect(readLayer(imageLayer)) +const itWithoutResizer = testEffect(readLayer(unavailableImage)) const sessionID = SessionV2.ID.make("ses_read_tool_test") describe("ReadTool", () => { diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index e235265b1195..4831acb79932 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -4,7 +4,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { FSUtil } from "@opencode-ai/core/fs-util" import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" @@ -66,16 +65,14 @@ describe("SkillTool", () => { list: () => Effect.succeed(current), }), ) - const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [ - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ]) - const tool = SkillTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(LayerNode.compile(FSUtil.node)), - Layer.provide(skills), + const skillToolLayer = AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]), + [ + [PermissionV2.node, permission], + [SkillV2.node, skills], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], ) - const layer = Layer.mergeAll(permission, skills, registry, tool) return yield* Effect.gen(function* () { const registry = yield* ToolRegistry.Service @@ -144,7 +141,7 @@ describe("SkillTool", () => { call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, }), ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) - }).pipe(Effect.provide(layer)) + }).pipe(Effect.provide(skillToolLayer)) }), ), ), diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 174d6139c809..170d1230a3f5 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" import { Database } from "@opencode-ai/core/database/database" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { PermissionV2 } from "@opencode-ai/core/permission" import { Project } from "@opencode-ai/core/project" @@ -11,6 +13,7 @@ import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTodo } from "@opencode-ai/core/session/todo" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -32,14 +35,21 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const tool = TodoWriteTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(SessionTodo.defaultLayer), -) const it = testEffect( - Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool), + AppNodeBuilder.build( + LayerNode.group([ + Database.node, + EventV2.node, + SessionTodo.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + TodoWriteTool.node, + ]), + [ + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ) const setup = Effect.gen(function* () { diff --git a/packages/core/test/tool-webfetch.test.ts b/packages/core/test/tool-webfetch.test.ts index 5a856ffaf428..2fe46f0ae54e 100644 --- a/packages/core/test/tool-webfetch.test.ts +++ b/packages/core/test/tool-webfetch.test.ts @@ -1,11 +1,15 @@ import { describe, expect, test } from "bun:test" import { Duration, Effect, Fiber, Layer, Schema } from "effect" import * as TestClock from "effect/testing/TestClock" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -35,15 +39,14 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) -const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http)) -const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch)) -const fetchWebfetch = WebFetchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(FetchHttpClient.layer), -) -const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch)) +const toolLayer = (replacements: LayerNode.Replacements = []) => + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [ + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ...replacements, + ]) +const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) +const live = testEffect(toolLayer()) const reset = () => { requests.length = 0 diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 9715dd5c7f23..4f3c8f156cb2 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -1,10 +1,14 @@ import { beforeEach, describe, expect, test } from "bun:test" import { Effect, Layer, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { PermissionV2 } from "@opencode-ai/core/permission" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { WebSearchTool } from "@opencode-ai/core/tool/websearch" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { testEffect } from "./lib/effect" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" @@ -99,7 +103,6 @@ const permission = Layer.succeed( list: () => Effect.die("unused"), }), ) -const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) const websearchConfig = Layer.succeed( WebSearchTool.ConfigService, WebSearchTool.ConfigService.of({ @@ -120,13 +123,14 @@ const websearchConfig = Layer.succeed( }, }), ) -const websearch = WebSearchTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(http), - Layer.provide(websearchConfig), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), [ + [PermissionV2.node, permission], + [LayerNodePlatform.httpClient, http], + [WebSearchTool.configNode, websearchConfig], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), ) -const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch)) describe("WebSearchTool registration", () => { it.effect("registers websearch, asserts query permission, and calls Exa", () => diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index a81b32d37d30..fcbe31b684b5 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from "url" import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { FileMutation } from "@opencode-ai/core/file-mutation" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { LocationMutation } from "@opencode-ai/core/location-mutation" @@ -11,6 +13,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionV2 } from "@opencode-ai/core/session" import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { WriteTool } from "@opencode-ai/core/tool/write" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" @@ -55,25 +58,25 @@ const filesystem = Layer.effect( Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), }) }), -).pipe(Layer.provide(FSUtil.defaultLayer)) +).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) const withTool = (directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect) => { const activeLocation = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) })), ) - const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation)) - const mutation = FileMutation.layer.pipe(Layer.provide(filesystem)) - const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission)) - const write = WriteTool.layer.pipe( - Layer.provide(registry), - Layer.provide(permission), - Layer.provide(resolution), - Layer.provide(mutation), - ) return Effect.gen(function* () { return yield* body(yield* ToolRegistry.Service) - }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write))) + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, WriteTool.node]), [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ]), + ), + ) } const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({ From 9205dfe72471204b2d7638123a3553f0202b5c2b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 15:37:20 +0000 Subject: [PATCH 024/303] chore: generate --- packages/core/src/effect/layer-node.ts | 16 +++++++------- packages/core/test/filesystem/watcher.test.ts | 4 +--- .../core/test/instruction-context.test.ts | 13 ++++++++---- packages/core/test/permission.test.ts | 9 +++++++- packages/core/test/plugin/command.test.ts | 7 ++++--- packages/core/test/policy.test.ts | 5 ++++- packages/core/test/session-create.test.ts | 11 ++++++---- packages/core/test/session-history.test.ts | 11 ++++++---- packages/core/test/session-prompt.test.ts | 7 ++++--- .../test/session-runner-tool-registry.test.ts | 4 +++- packages/core/test/snapshot.test.ts | 15 +++++++------ packages/core/test/tool-apply-patch.test.ts | 8 ++++++- packages/core/test/tool-bash.test.ts | 17 ++++++++------- packages/core/test/tool-edit.test.ts | 21 +++++++++++++------ packages/core/test/tool-websearch.test.ts | 15 +++++++------ packages/core/test/tool-write.test.ts | 21 +++++++++++++------ 16 files changed, 119 insertions(+), 65 deletions(-) diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts index 00a759f9f3ba..b58692a15532 100644 --- a/packages/core/src/effect/layer-node.ts +++ b/packages/core/src/effect/layer-node.ts @@ -272,13 +272,15 @@ export function compile( } function replacementMapFrom(replacements?: Replacements) { - return replacements?.reduce((map, [source, replacement]) => { - const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) - const current = new Map([[source.name, normalized]]) - for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) - map.set(source.name, normalized) - return map - }, new Map()) ?? new Map() + return ( + replacements?.reduce((map, [source, replacement]) => { + const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map) + const current = new Map([[source.name, normalized]]) + for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current)) + map.set(source.name, normalized) + return map + }, new Map()) ?? new Map() + ) } function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap) { diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index c8eaa579fef7..bce2bf69c24c 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -44,9 +44,7 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) { AppNodeBuilder.build(Watcher.node, [ [Config.node, configLayer], [Location.node, locationLayer], - ]).pipe( - Layer.provide(flagsLayer), - ), + ]).pipe(Layer.provide(flagsLayer)), ) } diff --git a/packages/core/test/instruction-context.test.ts b/packages/core/test/instruction-context.test.ts index da0649e56796..0a61ede3f058 100644 --- a/packages/core/test/instruction-context.test.ts +++ b/packages/core/test/instruction-context.test.ts @@ -17,9 +17,11 @@ import { testEffect } from "./lib/effect" const it = testEffect(Layer.empty) -const instructionLayer = ( - input: { config: string; locationServiceLayer: Layer.Layer; filesystemLayer?: Layer.Layer }, -) => +const instructionLayer = (input: { + config: string + locationServiceLayer: Layer.Layer + filesystemLayer?: Layer.Layer +}) => AppNodeBuilder.build(LayerNode.group([SystemContextRegistry.node, InstructionContext.node]), [ [Global.node, Global.layerWith({ config: input.config })], [Location.node, input.locationServiceLayer], @@ -305,7 +307,10 @@ describe("InstructionContext", () => { locationServiceLayer: Layer.succeed( Location.Service, Location.Service.of( - location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }), + location( + { directory: AbsolutePath.make("/outside") }, + { projectDirectory: AbsolutePath.make("/repo") }, + ), ), ), }), diff --git a/packages/core/test/permission.test.ts b/packages/core/test/permission.test.ts index c627483c4a84..a556f1fce217 100644 --- a/packages/core/test/permission.test.ts +++ b/packages/core/test/permission.test.ts @@ -25,7 +25,14 @@ const current = Layer.succeed( ) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Database.node, EventV2.node, SessionStore.node, PermissionSaved.node, AgentV2.node, PermissionV2.node]), + LayerNode.group([ + Database.node, + EventV2.node, + SessionStore.node, + PermissionSaved.node, + AgentV2.node, + PermissionV2.node, + ]), [[Location.node, current]], ), ) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index 4a49fe48dbc5..d163cd903a7e 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -11,10 +11,11 @@ import { host } from "./host" const directory = AbsolutePath.make("/repo/packages/app") const project = AbsolutePath.make("/repo") -const locationLayer = Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))) -const it = testEffect( - AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]), +const locationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory }, { projectDirectory: project })), ) +const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]])) describe("CommandPlugin.Plugin", () => { it.effect("registers built-in init and review commands", () => diff --git a/packages/core/test/policy.test.ts b/packages/core/test/policy.test.ts index 9b5309c6d6c6..1428c1b830fa 100644 --- a/packages/core/test/policy.test.ts +++ b/packages/core/test/policy.test.ts @@ -9,7 +9,10 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(Policy.node, [ - [Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") })))], + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), + ], ]), ) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 54ff364901ed..4688ede82d18 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -36,10 +36,13 @@ const projects = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ - [ProjectV2.node, projects], - [SessionExecution.node, SessionExecution.noopLayer], - ]), + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const id = SessionV2.ID.create() diff --git a/packages/core/test/session-history.test.ts b/packages/core/test/session-history.test.ts index e5cb7eccc4b7..c67f776d2930 100644 --- a/packages/core/test/session-history.test.ts +++ b/packages/core/test/session-history.test.ts @@ -24,10 +24,13 @@ const projects = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ - [ProjectV2.node, projects], - [SessionExecution.node, SessionExecution.noopLayer], - ]), + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [ + [ProjectV2.node, projects], + [SessionExecution.node, SessionExecution.noopLayer], + ], + ), ) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index 7c0c824b71dd..c6bc9430b3ac 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -43,9 +43,10 @@ const execution = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), [ - [SessionExecution.node, execution], - ]), + AppNodeBuilder.build( + LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), + [[SessionExecution.node, execution]], + ), ) const sessionID = SessionV2.ID.make("ses_prompt_test") const messageID = SessionMessage.ID.create() diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 68597f84528e..82cd015aaf68 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -32,7 +32,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const it = testEffect(registryLayer) const integrated = testEffect( - AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [[ToolOutputStore.node, outputStore]]), + AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [ + [ToolOutputStore.node, outputStore], + ]), ) const identity = { agent: AgentV2.ID.make("build"), diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index db0a43168dd9..5e01fefc17dc 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -114,7 +114,9 @@ describe("Snapshot", () => { const projectID = yield* Effect.gen(function* () { return (yield* Location.Service).project.id }).pipe( - Effect.provide(AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) })))), + Effect.provide( + AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), + ), ) expect( yield* Effect.promise(() => fs.stat(path.join(tmp.path, "snapshot", projectID, Hash.fast(project)))), @@ -165,13 +167,10 @@ describe("Snapshot", () => { }) function snapshotLayer(data: string, directory: string) { - return AppNodeBuilder.build( - Snapshot.node, - [ - [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], - [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], - ], - ) + return AppNodeBuilder.build(Snapshot.node, [ + [Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], + [Global.node, Global.layerWith({ data, config: path.join(data, "config") })], + ]) } function read(file: string) { diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index 5efcf8538a31..b986d4972a33 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -96,7 +96,13 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte }).pipe( Effect.provide( AppNodeBuilder.build( - LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, ApplyPatchTool.node]), + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + ApplyPatchTool.node, + ]), [ [FSUtil.node, filesystem], [Location.node, activeLocation], diff --git a/packages/core/test/tool-bash.test.ts b/packages/core/test/tool-bash.test.ts index 8b602f858be5..3a969e9570e1 100644 --- a/packages/core/test/tool-bash.test.ts +++ b/packages/core/test/tool-bash.test.ts @@ -110,13 +110,16 @@ const withTool = ( return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]), [ - [Location.node, activeLocation], - [PermissionV2.node, permission], - [AppProcess.node, processLayer], - [Config.node, config], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ]), + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, BashTool.node]), + [ + [Location.node, activeLocation], + [PermissionV2.node, permission], + [AppProcess.node, processLayer], + [Config.node, config], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ), ) } diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 4b05f9579d4b..2c684523b799 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -85,12 +85,21 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, EditTool.node]), [ - [FSUtil.node, filesystem], - [Location.node, activeLocation], - [PermissionV2.node, permission], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ]), + AppNodeBuilder.build( + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + EditTool.node, + ]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ), ) } diff --git a/packages/core/test/tool-websearch.test.ts b/packages/core/test/tool-websearch.test.ts index 4f3c8f156cb2..0b99a80ecb33 100644 --- a/packages/core/test/tool-websearch.test.ts +++ b/packages/core/test/tool-websearch.test.ts @@ -124,12 +124,15 @@ const websearchConfig = Layer.succeed( }), ) const it = testEffect( - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), [ - [PermissionV2.node, permission], - [LayerNodePlatform.httpClient, http], - [WebSearchTool.configNode, websearchConfig], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ]), + AppNodeBuilder.build( + LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), + [ + [PermissionV2.node, permission], + [LayerNodePlatform.httpClient, http], + [WebSearchTool.configNode, websearchConfig], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ) describe("WebSearchTool registration", () => { diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index fcbe31b684b5..a45bf73ee440 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -69,12 +69,21 @@ const withTool = (directory: string, body: (registry: ToolRegistry.Inte return yield* body(yield* ToolRegistry.Service) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, LocationMutation.node, FileMutation.node, WriteTool.node]), [ - [FSUtil.node, filesystem], - [Location.node, activeLocation], - [PermissionV2.node, permission], - [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], - ]), + AppNodeBuilder.build( + LayerNode.group([ + ToolRegistry.node, + ToolRegistry.toolsNode, + LocationMutation.node, + FileMutation.node, + WriteTool.node, + ]), + [ + [FSUtil.node, filesystem], + [Location.node, activeLocation], + [PermissionV2.node, permission], + [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], + ], + ), ), ) } From f7eeb089427a609d701353414701c0db80af3ead Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 21:18:06 +0530 Subject: [PATCH 025/303] fix(llm): narrow raw overlays (#34448) --- packages/llm/src/route/transport/http.ts | 47 ++++++++++++++++++++++++ packages/llm/test/prepare.test.ts | 28 +++++++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/packages/llm/src/route/transport/http.ts b/packages/llm/src/route/transport/http.ts index 00508957a76b..acc52c6ea173 100644 --- a/packages/llm/src/route/transport/http.ts +++ b/packages/llm/src/route/transport/http.ts @@ -28,9 +28,56 @@ const applyQuery = (url: string, query: Record | undefined) => { return next.toString() } +const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([ + "content", + "contents", + "frequencyPenalty", + "frequency_penalty", + "generationConfig", + "inferenceConfig", + "input", + "maxTokens", + "max_tokens", + "messages", + "model", + "presencePenalty", + "presence_penalty", + "responseFormat", + "response_format", + "seed", + "stop", + "stopSequences", + "stop_sequences", + "stream", + "streamOptions", + "stream_options", + "system", + "systemInstruction", + "system_instruction", + "temperature", + "thinking", + "toolChoice", + "toolConfig", + "tool_choice", + "tool_config", + "tools", + "topK", + "topP", + "top_k", + "top_p", +]) + +const forbiddenBodyOverlayKeys = (body: Record) => + Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key)) + const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => Effect.gen(function* () { if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } + const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body) + if (forbiddenKeys.length > 0) + return yield* ProviderShared.invalidRequest( + `http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`, + ) if (ProviderShared.isRecord(body)) { const overlaid = mergeJsonRecords(body, request.http.body) ?? {} return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts index d6006095f4ed..312802a9a05f 100644 --- a/packages/llm/test/prepare.test.ts +++ b/packages/llm/test/prepare.test.ts @@ -117,11 +117,9 @@ describe("request option precedence", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - const url = new URL(web.url) - expect(url.searchParams.get("route")).toBe("1") - expect(url.searchParams.get("model")).toBe("1") - expect(url.searchParams.get("request")).toBe("1") - expect(url.searchParams.get("shared")).toBe("model") + expect(web.url).toBe( + "https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1", + ) expect(web.headers.get("authorization")).toBe("Bearer fresh-key") expect(web.headers.get("x-route")).toBe("route") expect(web.headers.get("x-model")).toBe("model") @@ -140,6 +138,26 @@ describe("request option precedence", () => { ), ) + it.effect("rejects raw body overlays for protocol-owned roots", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "gpt-4o-mini" }) + const error = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Say hello.", + http: { body: { model: "gpt-5", messages: [], tools: [] } }, + }), + ).pipe(Effect.flip) + + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + message: "http.body cannot overlay protocol-owned field(s): model, messages, tools", + }) + }), + ) + it.effect("uses model output limits after route limits and before call maxTokens", () => Effect.gen(function* () { const route = AnthropicMessages.route.with({ From 71ec022b47d581f78fe6721f1516bdda7e41e47d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 15:50:24 +0000 Subject: [PATCH 026/303] chore: generate --- packages/llm/test/prepare.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/llm/test/prepare.test.ts b/packages/llm/test/prepare.test.ts index 312802a9a05f..6923c5a678ed 100644 --- a/packages/llm/test/prepare.test.ts +++ b/packages/llm/test/prepare.test.ts @@ -117,9 +117,7 @@ describe("request option precedence", () => { dynamicResponse((input) => Effect.gen(function* () { const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) - expect(web.url).toBe( - "https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1", - ) + expect(web.url).toBe("https://api.openai.test/v1/chat/completions?route=1&shared=model&model=1&request=1") expect(web.headers.get("authorization")).toBe("Bearer fresh-key") expect(web.headers.get("x-route")).toBe("route") expect(web.headers.get("x-model")).toBe("model") From 18466b8020aa6775d2d72c23c22241c2fe553cab Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Mon, 29 Jun 2026 21:45:42 +0530 Subject: [PATCH 027/303] feat(llm): add tool schema projections (#34454) --- .../llm/src/protocols/anthropic-messages.ts | 11 +- .../llm/src/protocols/bedrock-converse.ts | 17 ++- packages/llm/src/protocols/gemini.ts | 17 ++- packages/llm/src/protocols/openai-chat.ts | 14 ++- .../llm/src/protocols/openai-responses.ts | 14 ++- packages/llm/src/protocols/shared.ts | 35 +----- .../src/protocols/utils/gemini-tool-schema.ts | 4 +- .../llm/src/protocols/utils/tool-schema.ts | 86 +++++++++++++ .../llm/test/tool-schema-projection.test.ts | 117 ++++++++++++++++++ 9 files changed, 261 insertions(+), 54 deletions(-) create mode 100644 packages/llm/src/protocols/utils/tool-schema.ts create mode 100644 packages/llm/test/tool-schema-projection.test.ts diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index d51da9851d6f..26990b0063b5 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -9,6 +9,7 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "anthropic-messages" @@ -256,10 +258,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string | return typeof anthropic.signature === "string" ? anthropic.signature : undefined } -const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({ +const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ name: tool.name, description: tool.description, - input_schema: tool.inputSchema, + input_schema: inputSchema, cache_control: cacheControl(breakpoints, tool.cache), }) @@ -504,6 +506,7 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 // Allocate the 4-breakpoint budget in invalidation order: tools → system → // messages. Tools live highest in the cache hierarchy, so when callers @@ -512,7 +515,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques const tools = request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined - : request.tools.map((tool) => lowerTool(breakpoints, tool)) + : request.tools.map((tool) => + lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ) const system = request.system.length === 0 ? undefined diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 80412ca9ddb2..c447a1a39d34 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -7,7 +7,9 @@ import { Usage, type CacheHint, type FinishReason, + type JsonSchema, type LLMRequest, + type ModelToolSchemaCompatibility, type ProviderMetadata, type ReasoningPart, type ToolCallPart, @@ -21,6 +23,7 @@ import { BedrockAuth } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" import { BedrockMedia } from "./utils/bedrock-media" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "bedrock-converse" @@ -205,18 +208,22 @@ type BedrockEvent = Schema.Schema.Type // ============================================================================= // Request Lowering // ============================================================================= -const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({ +const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ toolSpec: { name: tool.name, description: tool.description, - inputSchema: { json: tool.inputSchema }, + inputSchema: { json: inputSchema }, }, }) -const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray): BedrockTool[] => { +const lowerTools = ( + compatibility: ModelToolSchemaCompatibility | undefined, + breakpoints: BedrockCache.Breakpoints, + tools: ReadonlyArray, +): BedrockTool[] => { const result: BedrockTool[] = [] for (const tool of tools) { - result.push(lowerToolSpec(tool)) + result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) const cachePoint = BedrockCache.block(breakpoints, tool.cache) if (cachePoint) result.push(cachePoint) } @@ -386,7 +393,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: const breakpoints = BedrockCache.breakpoints() const toolConfig = request.tools.length > 0 && request.toolChoice?.type !== "none" - ? { tools: lowerTools(breakpoints, request.tools), toolChoice } + ? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } : undefined const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const messages = yield* lowerMessages(request, breakpoints) diff --git a/packages/llm/src/protocols/gemini.ts b/packages/llm/src/protocols/gemini.ts index c7324608200c..c4bb9476a49d 100644 --- a/packages/llm/src/protocols/gemini.ts +++ b/packages/llm/src/protocols/gemini.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ProviderMetadata, @@ -19,6 +20,7 @@ import { import { JsonObject, optionalArray, ProviderShared } from "./shared" import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" const ADAPTER = "gemini" const MEDIA_MIMES = new Set(ProviderShared.MEDIA_MIMES) @@ -166,10 +168,10 @@ interface ParserState { // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition) => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ name: tool.name, description: tool.description, - parameters: GeminiToolSchema.convert(tool.inputSchema), + parameters: GeminiToolSchema.convert(inputSchema), }) const lowerToolConfig = (toolChoice: NonNullable) => @@ -300,6 +302,7 @@ const thinkingConfig = (request: LLMRequest) => { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { maxOutputTokens: generation?.maxTokens, temperature: generation?.temperature, @@ -313,7 +316,15 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined, + tools: toolsEnabled + ? [ + { + functionDeclarations: request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), + }, + ] + : undefined, toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig diff --git a/packages/llm/src/protocols/openai-chat.ts b/packages/llm/src/protocols/openai-chat.ts index 4e41a34ce4a6..9ac85b07b139 100644 --- a/packages/llm/src/protocols/openai-chat.ts +++ b/packages/llm/src/protocols/openai-chat.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type MediaPart, type ReasoningPart, @@ -19,6 +20,7 @@ import { import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-chat" @@ -174,12 +176,12 @@ const invalid = ProviderShared.invalidRequest // Lowering is the only place that knows how common LLM messages map onto the // OpenAI Chat wire format. Keep provider quirks here instead of leaking native // fields into `LLMRequest`. -const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ type: "function", function: { name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), }, }) @@ -343,10 +345,16 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR // `fromRequest` returns the provider body only. Endpoint, auth, framing, // validation, and HTTP execution are composed by `Route.make`. const generation = request.generation + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, messages: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, stream_options: { include_usage: true }, diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 00100c1a0a6c..4936d31c921b 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -8,6 +8,7 @@ import { LLMEvent, Usage, type FinishReason, + type JsonSchema, type LLMRequest, type ProviderMetadata, type ReasoningPart, @@ -21,6 +22,7 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share import { isContextOverflow } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" +import { ToolSchemaProjection } from "./utils/tool-schema" import { ToolStream } from "./utils/tool-stream" const ADAPTER = "openai-responses" @@ -254,11 +256,11 @@ const invalid = ProviderShared.invalidRequest // ============================================================================= // Request Lowering // ============================================================================= -const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ +const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({ type: "function", name: tool.name, description: tool.description, - parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema), + parameters: ToolSchemaProjection.openAI(inputSchema), // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. strict: false, }) @@ -476,10 +478,16 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const generation = request.generation const options = yield* lowerOptions(request) + const toolSchemaCompatibility = request.model.compatibility?.toolSchema return { model: request.model.id, input: yield* lowerMessages(request), - tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool), + tools: + request.tools.length === 0 + ? undefined + : request.tools.map((tool) => + lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + ), tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined, stream: true as const, max_output_tokens: generation?.maxTokens, diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index c5b6003fd280..173dc511bb03 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -1,5 +1,5 @@ import { Buffer } from "node:buffer" -import { Effect, JsonSchema, Schema, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { Headers, HttpClientRequest } from "effect/unstable/http" import { @@ -24,39 +24,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown) export const optionalArray = (schema: S) => Schema.optional(Schema.Array(schema)) export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) -/** OpenAI function schemas require one flat object at the top level. */ -export const openAiToolInputSchema = (schema: JsonSchema.JsonSchema): JsonSchema.JsonSchema => { - const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] - const flattened = - variants.length === 0 - ? { ...schema, type: "object" } - : { - ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), - type: "object", - properties: variants.reduce( - (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), - {}, - ), - additionalProperties: false, - } - const normalized = removeNullSchemas(flattened) - return isRecord(normalized) ? normalized : { type: "object" } -} - -const removeNullSchemas = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(removeNullSchemas) - if (!isRecord(value)) return value - const fields = Object.fromEntries( - Object.entries(value) - .filter(([key]) => key !== "anyOf") - .map(([key, field]) => [key, removeNullSchemas(field)]), - ) - if (!Array.isArray(value.anyOf)) return fields - const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) - if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } - return { ...fields, anyOf: variants } -} - /** * Streaming tool-call accumulator. Adapters that build a tool call across * multiple `tool-input-delta` chunks store the partial JSON input string here diff --git a/packages/llm/src/protocols/utils/gemini-tool-schema.ts b/packages/llm/src/protocols/utils/gemini-tool-schema.ts index 7690b2e60018..efdbe3f6ec65 100644 --- a/packages/llm/src/protocols/utils/gemini-tool-schema.ts +++ b/packages/llm/src/protocols/utils/gemini-tool-schema.ts @@ -1,4 +1,4 @@ -import { ProviderShared } from "../shared" +import { isRecord } from "../../utils/record" // Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a // handful of common JSON Schema shapes. Keep this projection isolated so the @@ -20,8 +20,6 @@ const SCHEMA_INTENT_KEYS = [ "else", ] -const isRecord = ProviderShared.isRecord - const hasCombiner = (schema: unknown) => isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) diff --git a/packages/llm/src/protocols/utils/tool-schema.ts b/packages/llm/src/protocols/utils/tool-schema.ts new file mode 100644 index 000000000000..3a311eb34ce9 --- /dev/null +++ b/packages/llm/src/protocols/utils/tool-schema.ts @@ -0,0 +1,86 @@ +import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" +import { isRecord } from "../../utils/record" +import { GeminiToolSchema } from "./gemini-tool-schema" + +const removeNullSchemas = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(removeNullSchemas) + if (!isRecord(value)) return value + const fields = Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "anyOf") + .map(([key, field]) => [key, removeNullSchemas(field)]), + ) + if (!Array.isArray(value.anyOf)) return fields + const variants = value.anyOf.filter((variant) => !isRecord(variant) || variant.type !== "null").map(removeNullSchemas) + if (variants.length === 1 && isRecord(variants[0])) return { ...fields, ...variants[0] } + return { ...fields, anyOf: variants } +} + +const tupleItemsSchema = (items: ReadonlyArray) => { + const projected = items.map(moonshotNode) + if (projected.length === 0) return {} + if (projected.length === 1) return projected[0] + return { anyOf: projected } +} + +const moonshotNode = (schema: unknown): unknown => { + if (Array.isArray(schema)) return schema.map(moonshotNode) + if (!isRecord(schema)) return schema + if (typeof schema.$ref === "string") return { $ref: schema.$ref } + return Object.fromEntries( + Object.entries(schema).flatMap(([key, value]) => { + if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]] + if (key === "prefixItems") { + if ("items" in schema) return [] + return [["items", tupleItemsSchema(Array.isArray(value) ? value : [])]] + } + if (key === "unevaluatedItems") return [] + return [[key, moonshotNode(value)]] + }), + ) +} + +const moonshot = (schema: JsonSchema): JsonSchema => { + const projected = moonshotNode(schema) + return isRecord(projected) ? projected : {} +} + +const openAI = (schema: JsonSchema): JsonSchema => { + const variants = Array.isArray(schema.anyOf) ? schema.anyOf.filter(isRecord) : [] + const flattened = + variants.length === 0 + ? { ...schema, type: "object" } + : { + ...Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "anyOf")), + type: "object", + properties: variants.reduce( + (properties, variant) => ({ ...(isRecord(variant.properties) ? variant.properties : {}), ...properties }), + {}, + ), + additionalProperties: false, + } + const normalized = removeNullSchemas(flattened) + return isRecord(normalized) ? normalized : { type: "object" } +} + +const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {} + +const modelCompatibility = ( + schema: JsonSchema, + compatibility: ModelToolSchemaCompatibility | undefined, +): JsonSchema => { + if (compatibility === undefined) return schema + switch (compatibility) { + case "gemini": + return gemini(schema) + case "moonshot": + return moonshot(schema) + } +} + +export const ToolSchemaProjection = { + gemini, + modelCompatibility, + moonshot, + openAI, +} as const diff --git a/packages/llm/test/tool-schema-projection.test.ts b/packages/llm/test/tool-schema-projection.test.ts new file mode 100644 index 000000000000..a9df815dafab --- /dev/null +++ b/packages/llm/test/tool-schema-projection.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLM } from "../src" +import { OpenAIChat } from "../src/protocols" +import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema" +import { Auth, LLMClient } from "../src/route" +import { it } from "./lib/effect" + +describe("tool schema projections", () => { + test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => { + expect( + ToolSchemaProjection.moonshot({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + linked: { $ref: "#/$defs/Linked" }, + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } }, + }, + }) + }) + + test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => { + expect( + ToolSchemaProjection.gemini({ + type: "object", + required: ["status", "missing"], + properties: { + status: { type: "integer", enum: [1, 2] }, + tags: { type: "array" }, + name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] }, + }, + }), + ).toEqual({ + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["1", "2"] }, + tags: { type: "array", items: { type: "string" } }, + name: { type: "string" }, + }, + }) + }) + + test("openai keeps one flat object top-level schema", () => { + expect( + ToolSchemaProjection.openAI({ + anyOf: [ + { + type: "object", + properties: { + path: { type: "string" }, + maybe: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + }, + { type: "object", properties: { resource: { type: "string" } } }, + ], + }), + ).toEqual({ + type: "object", + properties: { + path: { type: "string" }, + maybe: { type: "string" }, + resource: { type: "string" }, + }, + additionalProperties: false, + }) + }) + + it.effect("applies model compatibility before protocol projection", () => + Effect.gen(function* () { + const model = OpenAIChat.route + .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") }) + .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } }) + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + prompt: "Use the tool.", + tools: [ + { + name: "lookup", + description: "Lookup data.", + inputSchema: { + type: "object", + anyOf: [ + { + type: "object", + properties: { + tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] }, + linked: { $ref: "#/$defs/Linked", description: "drop me" }, + }, + }, + ], + }, + }, + ], + }), + ) + + expect(prepared.body.tools?.[0]?.function.parameters).toEqual({ + type: "object", + properties: { + tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + linked: { $ref: "#/$defs/Linked" }, + }, + additionalProperties: false, + }) + }), + ) +}) From b10d617c809c5adb6ffced6517fc0576d23af4d5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 16:17:54 +0000 Subject: [PATCH 028/303] chore: generate --- packages/llm/src/protocols/anthropic-messages.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 26990b0063b5..1c0dcd32a433 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -516,7 +516,11 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques request.tools.length === 0 || request.toolChoice?.type === "none" ? undefined : request.tools.map((tool) => - lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)), + lowerTool( + breakpoints, + tool, + ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), + ), ) const system = request.system.length === 0 From bc5ce5eab1a2a9069b35af619dd3d95949a00af6 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 12:56:44 -0400 Subject: [PATCH 029/303] refactor(core): convert opencode tests to nodes (#34453) --- packages/core/src/observability.ts | 3 +++ packages/opencode/src/acp/session.ts | 3 +++ packages/opencode/test/account/repo.test.ts | 6 ++++-- packages/opencode/test/acp/event.test.ts | 3 ++- packages/opencode/test/acp/permission.test.ts | 3 ++- packages/opencode/test/acp/session.test.ts | 3 ++- .../test/agent/plan-mode-subagent-bypass.test.ts | 3 ++- packages/opencode/test/auth/auth.test.ts | 8 +++----- packages/opencode/test/background/job.test.ts | 3 ++- .../test/cli/effect-cmd-instance-als.test.ts | 3 ++- .../opencode/test/config/agent-color.test.ts | 6 +++--- .../test/effect/app-runtime-logger.test.ts | 8 +++++--- .../opencode/test/filesystem/filesystem.test.ts | 7 +++---- packages/opencode/test/image/image.test.ts | 16 +++++++--------- packages/opencode/test/lsp/lifecycle.test.ts | 6 +++--- packages/opencode/test/mcp/headers.test.ts | 3 ++- packages/opencode/test/mcp/lifecycle.test.ts | 3 ++- packages/opencode/test/patch/patch.test.ts | 3 ++- packages/opencode/test/permission-task.test.ts | 3 ++- .../test/project/instance-bootstrap.test.ts | 4 ++-- packages/opencode/test/project/instance.test.ts | 7 +++++-- .../opencode/test/project/migrate-global.test.ts | 5 +++-- .../test/project/project-directory.test.ts | 5 +++-- packages/opencode/test/project/vcs.test.ts | 9 +++------ .../test/project/worktree-remove.test.ts | 6 +++--- packages/opencode/test/project/worktree.test.ts | 8 +++----- .../test/provider/amazon-bedrock.test.ts | 5 +++-- .../opencode/test/provider/digitalocean.test.ts | 3 ++- .../test/provider/header-timeout.test.ts | 7 +++---- packages/opencode/test/question/question.test.ts | 9 ++++----- .../opencode/test/session/revert-compact.test.ts | 15 ++++++--------- packages/opencode/test/skill/discovery.test.ts | 5 +++-- .../test/tool/external-directory.test.ts | 3 ++- packages/opencode/test/tool/question.test.ts | 13 +++---------- packages/opencode/test/tool/tool-define.test.ts | 5 +++-- packages/opencode/test/tool/truncation.test.ts | 16 +++++++--------- 36 files changed, 112 insertions(+), 106 deletions(-) diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts index faffb2733333..22285974d83a 100644 --- a/packages/core/src/observability.ts +++ b/packages/core/src/observability.ts @@ -1,6 +1,7 @@ export * as Observability from "./observability" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "./effect/layer-node" import { Effect, Layer, Logger, References } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { OtlpSerialization } from "effect/unstable/observability" @@ -19,3 +20,5 @@ export const layer = Layer.unwrap( return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) }), ) + +export const node = LayerNode.make({ name: "observability", layer, deps: [] }) diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index e0b3af99f88a..ea76cd91f86a 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { Message, Part } from "@opencode-ai/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Context, Effect, Layer, Ref } from "effect" @@ -202,6 +203,8 @@ export const layer = Layer.effect( export const defaultLayer = layer +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + function makeSession(input: StoreInput): Info { return { id: input.id, diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 42851fc19d46..756d873f9c78 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer, Option } from "effect" import { sql } from "drizzle-orm" @@ -13,9 +14,10 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) it.live("list returns empty when no accounts exist", () => Effect.gen(function* () { diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 8a72754f0ec9..8271b28265c2 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@opencode-ai/sdk/v2" import { Effect, ManagedRuntime } from "effect" import { ACPEvent } from "@/acp/event" @@ -30,7 +31,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } diff --git a/packages/opencode/test/acp/permission.test.ts b/packages/opencode/test/acp/permission.test.ts index eaf3218e9cd7..99da5e6ac13f 100644 --- a/packages/opencode/test/acp/permission.test.ts +++ b/packages/opencode/test/acp/permission.test.ts @@ -6,6 +6,7 @@ import type { SessionUpdate, } from "@agentclientprotocol/sdk" import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { createTwoFilesPatch } from "diff" import { Effect, ManagedRuntime } from "effect" import { mkdtemp, rm } from "node:fs/promises" @@ -37,7 +38,7 @@ const pollUntil = async ( } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } diff --git a/packages/opencode/test/acp/session.test.ts b/packages/opencode/test/acp/session.test.ts index c3d41ef08e4d..89968563c9d0 100644 --- a/packages/opencode/test/acp/session.test.ts +++ b/packages/opencode/test/acp/session.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import type { McpServer } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -7,7 +8,7 @@ import * as ACPError from "@/acp/error" import * as ACPSession from "@/acp/session" import { testEffect } from "../lib/effect" -const sessionTest = testEffect(ACPSession.defaultLayer) +const sessionTest = testEffect(LayerNode.compile(ACPSession.node)) const model = (providerID: string, modelID: string): ACPSession.SelectedModel => ({ providerID: ProviderV2.ID.make(providerID), diff --git a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts index a58a5ddf2fbe..6fc9197d9ae9 100644 --- a/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts +++ b/packages/opencode/test/agent/plan-mode-subagent-bypass.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { expect } from "bun:test" import { Effect } from "effect" import { Agent } from "../../src/agent/agent" @@ -6,7 +7,7 @@ import { deriveSubagentSessionPermission } from "../../src/agent/subagent-permis import { Permission } from "../../src/permission" import { testEffect } from "../lib/effect" -const it = testEffect(Agent.defaultLayer) +const it = testEffect(LayerNode.compile(Agent.node)) function testAgent(input: { name: string diff --git a/packages/opencode/test/auth/auth.test.ts b/packages/opencode/test/auth/auth.test.ts index 58ce6ea718d0..bb72be66e57c 100644 --- a/packages/opencode/test/auth/auth.test.ts +++ b/packages/opencode/test/auth/auth.test.ts @@ -1,12 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Auth } from "../../src/auth" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(Auth.defaultLayer, node)) +const it = testEffect(LayerNode.compile(Auth.node)) describe("Auth", () => { it.instance("set normalizes trailing slashes in keys", () => diff --git a/packages/opencode/test/background/job.test.ts b/packages/opencode/test/background/job.test.ts index dbcb484dc670..649f935c085a 100644 --- a/packages/opencode/test/background/job.test.ts +++ b/packages/opencode/test/background/job.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect } from "effect" import { BackgroundJob } from "@/background/job" import { testEffect } from "../lib/effect" -const it = testEffect(BackgroundJob.defaultLayer) +const it = testEffect(LayerNode.compile(BackgroundJob.node)) describe("background.job", () => { it.instance("tracks started jobs through completion", () => diff --git a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts index 7c93ab3190e4..1592b00146ec 100644 --- a/packages/opencode/test/cli/effect-cmd-instance-als.test.ts +++ b/packages/opencode/test/cli/effect-cmd-instance-als.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect } from "effect" import { fileURLToPath } from "url" @@ -6,7 +7,7 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/config/agent-color.test.ts b/packages/opencode/test/config/agent-color.test.ts index 664170696ef0..6b27cafe85a3 100644 --- a/packages/opencode/test/config/agent-color.test.ts +++ b/packages/opencode/test/config/agent-color.test.ts @@ -1,11 +1,11 @@ import { expect } from "bun:test" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Config } from "@/config/config" import { Agent as AgentSvc } from "../../src/agent/agent" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, AgentSvc.node]))) it.instance( "agent color parsed from project config", diff --git a/packages/opencode/test/effect/app-runtime-logger.test.ts b/packages/opencode/test/effect/app-runtime-logger.test.ts index ecebe5044696..a0c34244f0d9 100644 --- a/packages/opencode/test/effect/app-runtime-logger.test.ts +++ b/packages/opencode/test/effect/app-runtime-logger.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppLayer } from "../../src/effect/app-runtime" @@ -9,7 +10,8 @@ import { attach } from "../../src/effect/run-service" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const observabilityLayer = LayerNode.compile(Observability.node) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) function check(loggers: ReadonlySet>) { return { @@ -34,7 +36,7 @@ it.live("makeRuntime installs the observability logger", () => ) const current = yield* Dummy.use((svc) => svc.current()).pipe( - Effect.provide(Layer.provideMerge(layer, Observability.layer)), + Effect.provide(Layer.provideMerge(layer, observabilityLayer)), ) expect(current.size).toBeGreaterThan(0) @@ -94,6 +96,6 @@ it.instance( expect(result.directory).toBe(test.directory) expect(result.size).toBeGreaterThan(0) - }).pipe(Effect.provide(Observability.layer)), + }).pipe(Effect.provide(observabilityLayer)), { git: true }, ) diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts index 686a21d527fc..d1ccc1f3eead 100644 --- a/packages/opencode/test/filesystem/filesystem.test.ts +++ b/packages/opencode/test/filesystem/filesystem.test.ts @@ -1,12 +1,11 @@ import { describe, test, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" import path from "path" -const live = FSUtil.layer.pipe(Layer.provide(NodeFileSystem.layer)) -const { effect: it } = testEffect(live) +const { effect: it } = testEffect(LayerNode.compile(FSUtil.node)) describe("FSUtil", () => { describe("isDir", () => { diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts index c5d832cd5e23..29667f6e2f6e 100644 --- a/packages/opencode/test/image/image.test.ts +++ b/packages/opencode/test/image/image.test.ts @@ -1,20 +1,18 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit } from "effect" import { Image } from "@/image/image" +import { Config } from "@/config/config" import { MessageID, PartID, SessionID } from "@/session/schema" import path from "node:path" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const it = testEffect(LayerNode.compile(Image.node, [[Config.node, TestConfig.layer()]])) const tiny = testEffect( - Layer.mergeAll( - Image.layer.pipe( - Layer.provide( - TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) }), - ), - ), - ), + LayerNode.compile(Image.node, [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })], + ]), ) function part(mime: string, data: string) { diff --git a/packages/opencode/test/lsp/lifecycle.test.ts b/packages/opencode/test/lsp/lifecycle.test.ts index 5d0313e6d20d..17387a6bf82b 100644 --- a/packages/opencode/test/lsp/lifecycle.test.ts +++ b/packages/opencode/test/lsp/lifecycle.test.ts @@ -1,13 +1,13 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test" import path from "path" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { LSP } from "@/lsp/lsp" import * as LSPServer from "@/lsp/server" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LSP.node)) describe("LSP service lifecycle", () => { let spawnSpy: ReturnType diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index c51ed00d32f6..e6b83d678ec8 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -46,7 +47,7 @@ beforeEach(() => { // Import MCP after mocking const { MCP } = await import("../../src/mcp/index") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index ec82d4f47f1f..aabb171f9396 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -2,6 +2,7 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -275,7 +276,7 @@ beforeEach(() => { const { MCP } = await import("../../src/mcp/index") const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") -const it = testEffect(MCP.defaultLayer) +const it = testEffect(LayerNode.compile(MCP.node)) function statusName(status: Record | MCPNS.Status, server: string) { if ("status" in status) return status.status diff --git a/packages/opencode/test/patch/patch.test.ts b/packages/opencode/test/patch/patch.test.ts index c1e47a4f1d5b..0a8dd5d8ad04 100644 --- a/packages/opencode/test/patch/patch.test.ts +++ b/packages/opencode/test/patch/patch.test.ts @@ -1,4 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import * as fs from "fs/promises" import * as path from "path" @@ -7,7 +8,7 @@ import { Patch } from "../../src/patch" import { FSUtil } from "@opencode-ai/core/fs-util" import { testEffect } from "../lib/effect" -const it = testEffect(FSUtil.defaultLayer) +const it = testEffect(LayerNode.compile(FSUtil.node)) describe("Patch namespace", () => { let tempDir: string diff --git a/packages/opencode/test/permission-task.test.ts b/packages/opencode/test/permission-task.test.ts index e5d92c5815d9..09a71465d5c7 100644 --- a/packages/opencode/test/permission-task.test.ts +++ b/packages/opencode/test/permission-task.test.ts @@ -1,11 +1,12 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, test, expect } from "bun:test" import { Effect } from "effect" import { Permission } from "../src/permission" import { Config } from "@/config/config" import { testEffect } from "./lib/effect" -const it = testEffect(Config.defaultLayer) +const it = testEffect(LayerNode.compile(Config.node)) const load = Config.use.get() diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 5009d6b500b0..5a07756a78b2 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -2,16 +2,16 @@ import { afterEach, expect } from "bun:test" import { existsSync } from "node:fs" import path from "node:path" import { pathToFileURL } from "node:url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap" -import { InstanceLayer } from "../../src/project/instance-layer" import { InstanceStore } from "../../src/project/instance-store" import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { waitGlobalBusEvent } from "../server/global-bus" -const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]))) // InstanceBootstrap must run before any code touches the instance — // originally tracked by PRs #25389 and #25449, now a permanent diff --git a/packages/opencode/test/project/instance.test.ts b/packages/opencode/test/project/instance.test.ts index 491cfe93d70e..dc52a3b9f846 100644 --- a/packages/opencode/test/project/instance.test.ts +++ b/packages/opencode/test/project/instance.test.ts @@ -1,9 +1,10 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { InstanceRef } from "../../src/effect/instance-ref" import { registerDisposer } from "../../src/effect/instance-registry" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -15,7 +16,9 @@ const noopBootstrap = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(InstanceStore.defaultLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(noopBootstrap)), + LayerNode.compile(LayerNode.group([InstanceStore.node, CrossSpawnSpawner.node]), [ + [InstanceBootstrap.node, noopBootstrap], + ]), ) const setBootstrap = (run: Effect.Effect) => diff --git a/packages/opencode/test/project/migrate-global.test.ts b/packages/opencode/test/project/migrate-global.test.ts index 77de870161d9..24d8aaaaaf66 100644 --- a/packages/opencode/test/project/migrate-global.test.ts +++ b/packages/opencode/test/project/migrate-global.test.ts @@ -9,11 +9,12 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { SessionID } from "../../src/session/schema" import { $ } from "bun" import { tmpdirScoped } from "../fixture/fixture" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, Database.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function legacySessionID() { // Global-session migration covers persisted IDs from before prefixed session IDs. diff --git a/packages/opencode/test/project/project-directory.test.ts b/packages/opencode/test/project/project-directory.test.ts index cb458c30f331..112271c2c470 100644 --- a/packages/opencode/test/project/project-directory.test.ts +++ b/packages/opencode/test/project/project-directory.test.ts @@ -2,8 +2,9 @@ import { describe, expect } from "bun:test" import { $ } from "bun" import path from "path" import { eq } from "drizzle-orm" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Hash } from "@opencode-ai/core/util/hash" import { AbsolutePath } from "@opencode-ai/core/schema" import { Database } from "@opencode-ai/core/database/database" @@ -13,7 +14,7 @@ import { Project } from "@/project/project" import { tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]))) function directories(projectID: ProjectV2.ID) { return Database.Service.use(({ db }) => diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 21620adaf02d..085a3349b41f 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -1,8 +1,9 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { parsePatch } from "diff" import { Deferred, Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import fs from "fs/promises" import path from "path" import { @@ -24,11 +25,7 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const layer = Layer.mergeAll( - Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, -) +const layer = LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node])) const it = testEffect(layer) const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts index c7175780248e..795fed4ac455 100644 --- a/packages/opencode/test/project/worktree-remove.test.ts +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -2,13 +2,13 @@ import { $ } from "bun" import { describe, expect } from "bun:test" import * as fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import { Worktree } from "../../src/worktree" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(Worktree.node)) const wintest = process.platform === "win32" ? it.instance : it.instance.skip describe("Worktree.remove", () => { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index eebd0f55bdef..f0a72c4b485e 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -1,17 +1,15 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Deferred, Effect, Exit, Fiber } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect( - Layer.mergeAll(Worktree.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer), -) +const it = testEffect(LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]))) const wintest = process.platform !== "win32" ? it.instance : it.instance.skip function normalize(input: string) { diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d1cc510cd1ad..6e677631c40b 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect } from "effect" import path from "path" import { unlink } from "fs/promises" import { Global } from "@opencode-ai/core/global" @@ -12,7 +13,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node]))) const originalEnv = new Map() diff --git a/packages/opencode/test/provider/digitalocean.test.ts b/packages/opencode/test/provider/digitalocean.test.ts index 59c3f8da7c38..144335c73b34 100644 --- a/packages/opencode/test/provider/digitalocean.test.ts +++ b/packages/opencode/test/provider/digitalocean.test.ts @@ -1,4 +1,5 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Provider } from "../../src/provider/provider" import { Effect } from "effect" @@ -6,7 +7,7 @@ import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" const DIGITALOCEAN = ProviderV2.ID.make("digitalocean") -const it = testEffect(Provider.defaultLayer) +const it = testEffect(LayerNode.compile(Provider.node)) const withEnv = (values: Record, effect: Effect.Effect) => Effect.acquireUseRelease( diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index b9d8d4530675..6b88574fd792 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -1,8 +1,9 @@ import { afterEach, expect } from "bun:test" import { createServer, type Server } from "node:http" import { streamText } from "ai" -import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" @@ -17,9 +18,7 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect( - Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer), -) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node]))) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => Effect.gen(function* () { diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 5ae076b4391c..72850bd5f039 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Fiber, Layer, Queue } from "effect" import { Question } from "../../src/question" import { InstanceRef } from "../../src/effect/instance-ref" @@ -10,13 +11,11 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { EventV2Bridge } from "../../src/event-v2-bridge" -const it = testEffect( - Layer.mergeAll(Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), CrossSpawnSpawner.defaultLayer), -) +const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node])) +const it = testEffect(questionLayer) const lifecycle = testEffect( Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, + questionLayer, testInstanceStoreLayer, ), ) diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 4e71a6a36c50..58f215be7a94 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -1,29 +1,26 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import fs from "fs/promises" import path from "path" -import { Effect, Layer } from "effect" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect } from "effect" import { Session } from "@/session/session" import { SessionRevert } from "../../src/session/revert" import { MessageV2 } from "../../src/session/message-v2" import { Snapshot } from "../../src/snapshot" import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -const env = Layer.mergeAll( - Session.defaultLayer, - SessionRevert.defaultLayer, - Snapshot.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const it = testEffect( + LayerNode.compile(LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node])), ) -const it = testEffect(env) - const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") { const session = yield* Session.Service return yield* session.updateMessage({ diff --git a/packages/opencode/test/skill/discovery.test.ts b/packages/opencode/test/skill/discovery.test.ts index a5833e7bafd2..bdb60dfddc8d 100644 --- a/packages/opencode/test/skill/discovery.test.ts +++ b/packages/opencode/test/skill/discovery.test.ts @@ -1,6 +1,7 @@ import { describe, expect, beforeAll, afterAll } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Discovery } from "../../src/skill/discovery" import { Global } from "@opencode-ai/core/global" import { Filesystem } from "@/util/filesystem" @@ -18,7 +19,7 @@ let mutableFiles = ["SKILL.md"] const fixturePath = path.join(import.meta.dir, "../fixture/skills") const cacheDir = path.join(Global.Path.cache, "skills") -const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Discovery.node, FSUtil.node]))) beforeAll(async () => { await rm(cacheDir, { recursive: true, force: true }) diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 69a48bad7a93..d43accfb70dc 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -1,4 +1,5 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect } from "bun:test" import path from "path" import { Effect } from "effect" @@ -11,7 +12,7 @@ import type { Permission } from "../../src/permission" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" -const it = testEffect(CrossSpawnSpawner.defaultLayer) +const it = testEffect(LayerNode.compile(CrossSpawnSpawner.node)) const baseCtx: Omit = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 0bbc58d44256..26fb1fdbafe3 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -1,10 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Fiber, Layer, Queue } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Effect, Fiber, Queue } from "effect" import { QuestionTool } from "../../src/tool/question" import { Question } from "../../src/question" import { SessionID, MessageID } from "../../src/session/schema" import { Agent } from "../../src/agent/agent" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -20,14 +20,7 @@ const ctx = { ask: () => Effect.void, } -const it = testEffect( - Layer.mergeAll( - Question.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)), - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), -) +const it = testEffect(LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, Truncate.node, Agent.node]))) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { const events = yield* EventV2Bridge.Service diff --git a/packages/opencode/test/tool/tool-define.test.ts b/packages/opencode/test/tool/tool-define.test.ts index 08e060436221..e254e5050e90 100644 --- a/packages/opencode/test/tool/tool-define.test.ts +++ b/packages/opencode/test/tool/tool-define.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer, Schema } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Cause, Effect, Exit, Schema } from "effect" import { Agent } from "../../src/agent/agent" import { MessageID, SessionID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { Truncate } from "@/tool/truncate" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, Agent.node]))) const params = Schema.Struct({ input: Schema.String }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 6e65b5f54ca4..d575a58ffa34 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,8 +1,9 @@ import { describe, test, expect } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { filesystem } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" -import { Effect, FileSystem, Layer } from "effect" +import { Effect, FileSystem } from "effect" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Identifier } from "../../src/id/id" @@ -15,15 +16,12 @@ import { TestConfig } from "../fixture/config" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") const ROOT = path.resolve(import.meta.dir, "..", "..") -const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem]))) const configuredLayer = (cfg: ConfigV1.Info) => - Layer.mergeAll( - Truncate.defaultLayer, - NodeFileSystem.layer, - FSUtil.defaultLayer, - TestConfig.layer({ get: () => Effect.succeed(cfg) }), - ) + LayerNode.compile(LayerNode.group([Truncate.node, FSUtil.node, filesystem, Config.node]), [ + [Config.node, TestConfig.layer({ get: () => Effect.succeed(cfg) })], + ]) const configuredIt = (cfg: ConfigV1.Info) => testEffect(configuredLayer(cfg)) describe("Truncate", () => { From de185559dde22ac73cb0fc3cbb51eef7266a6748 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 16:58:51 +0000 Subject: [PATCH 030/303] chore: generate --- packages/opencode/test/project/vcs.test.ts | 4 +++- packages/opencode/test/provider/header-timeout.test.ts | 4 +++- packages/opencode/test/question/question.test.ts | 7 +------ packages/opencode/test/session/revert-compact.test.ts | 4 +++- packages/opencode/test/tool/question.test.ts | 4 +++- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 085a3349b41f..c13b108bc6a9 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -25,7 +25,9 @@ import { testEffect } from "../lib/effect" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" -const layer = LayerNode.compile(LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node])) +const layer = LayerNode.compile( + LayerNode.group([Vcs.node, Git.node, EventV2Bridge.node, FSUtil.node, CrossSpawnSpawner.node]), +) const it = testEffect(layer) const worktreeIt = testEffect(Layer.mergeAll(layer, testInstanceStoreLayer)) diff --git a/packages/opencode/test/provider/header-timeout.test.ts b/packages/opencode/test/provider/header-timeout.test.ts index 6b88574fd792..1bd27b5cdd1d 100644 --- a/packages/opencode/test/provider/header-timeout.test.ts +++ b/packages/opencode/test/provider/header-timeout.test.ts @@ -18,7 +18,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node]))) +const it = testEffect( + LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node, CrossSpawnSpawner.node])), +) it.live("headerTimeout does not abort delayed SSE body after headers arrive", () => Effect.gen(function* () { diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index 72850bd5f039..9adb1e0c9ff7 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -13,12 +13,7 @@ import { EventV2Bridge } from "../../src/event-v2-bridge" const questionLayer = LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, CrossSpawnSpawner.node])) const it = testEffect(questionLayer) -const lifecycle = testEffect( - Layer.mergeAll( - questionLayer, - testInstanceStoreLayer, - ), -) +const lifecycle = testEffect(Layer.mergeAll(questionLayer, testInstanceStoreLayer)) const askEffect = Effect.fn("QuestionTest.ask")(function* (input: { sessionID: SessionID diff --git a/packages/opencode/test/session/revert-compact.test.ts b/packages/opencode/test/session/revert-compact.test.ts index 58f215be7a94..d3d7ba7aae9b 100644 --- a/packages/opencode/test/session/revert-compact.test.ts +++ b/packages/opencode/test/session/revert-compact.test.ts @@ -18,7 +18,9 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" const it = testEffect( - LayerNode.compile(LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node])), + LayerNode.compile( + LayerNode.group([Session.node, SessionRevert.node, Snapshot.node, SessionProjector.node, CrossSpawnSpawner.node]), + ), ) const user = Effect.fn("test.user")(function* (sessionID: SessionID, agent = "default") { diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 26fb1fdbafe3..d8c6df77e8a2 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -20,7 +20,9 @@ const ctx = { ask: () => Effect.void, } -const it = testEffect(LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, Truncate.node, Agent.node]))) +const it = testEffect( + LayerNode.compile(LayerNode.group([Question.node, EventV2Bridge.node, Truncate.node, Agent.node])), +) const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) { const events = yield* EventV2Bridge.Service From d4fd5281525455f767354bc99dca9a1dc9d817b4 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 13:56:28 -0400 Subject: [PATCH 031/303] refactor(core): convert more opencode tests to nodes (#34464) --- packages/opencode/src/acp/directory.ts | 5 +++ packages/opencode/src/acp/usage.ts | 12 ++++++ .../opencode/test/account/service.test.ts | 9 +++-- packages/opencode/test/acp/directory.test.ts | 10 +++-- packages/opencode/test/acp/usage.test.ts | 37 ++++++++++--------- packages/opencode/test/agent/agent.test.ts | 14 ++----- .../test/cli/run/variant.shared.test.ts | 5 ++- packages/opencode/test/config/tui.test.ts | 3 +- .../test/effect/instance-state.test.ts | 3 +- packages/opencode/test/format/format.test.ts | 5 ++- .../test/installation/installation.test.ts | 11 +++++- packages/opencode/test/lsp/index.test.ts | 15 ++++---- packages/opencode/test/mcp/auth.test.ts | 5 ++- .../test/mcp/oauth-auto-connect.test.ts | 12 +----- .../opencode/test/mcp/oauth-browser.test.ts | 9 +---- .../test/plugin/auth-override.test.ts | 3 +- .../test/plugin/loader-shared.test.ts | 3 +- .../opencode/test/provider/provider.test.ts | 14 +++---- .../test/server/global-session-list.test.ts | 6 ++- .../test/server/httpapi-experimental.test.ts | 3 +- .../test/server/httpapi-provider.test.ts | 3 +- .../server/httpapi-schema-error-body.test.ts | 3 +- .../opencode/test/server/httpapi-sync.test.ts | 3 +- .../server/negative-tokens-regression.test.ts | 3 +- .../opencode/test/server/project-copy.test.ts | 7 ++-- .../test/server/project-init-git.test.ts | 7 ++-- .../test/server/session-actions.test.ts | 3 +- .../server/session-diff-missing-patch.test.ts | 3 +- .../test/server/session-messages.test.ts | 3 +- .../test/server/session-select.test.ts | 3 +- packages/opencode/test/session/system.test.ts | 17 +++++---- packages/opencode/test/skill/skill.test.ts | 23 +++--------- .../opencode/test/snapshot/snapshot.test.ts | 7 ++-- .../opencode/test/storage/storage.test.ts | 7 ++-- .../opencode/test/tool/apply_patch.test.ts | 10 +---- packages/opencode/test/tool/edit.test.ts | 10 +---- packages/opencode/test/tool/glob.test.ts | 10 +---- packages/opencode/test/tool/grep.test.ts | 10 +---- packages/opencode/test/tool/lsp.test.ts | 5 ++- packages/opencode/test/tool/read.test.ts | 11 ++---- packages/opencode/test/tool/shell.test.ts | 19 ++++++---- packages/opencode/test/tool/skill.test.ts | 5 +-- packages/opencode/test/tool/task.test.ts | 35 +++++++++++------- packages/opencode/test/tool/webfetch.test.ts | 10 ++++- packages/opencode/test/tool/write.test.ts | 11 ++---- 45 files changed, 209 insertions(+), 203 deletions(-) diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index f61023c1030d..3a627fe30f6e 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -2,6 +2,7 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -207,4 +208,8 @@ export const defaultLayer = layer.pipe( Layer.provide(InstanceStore.defaultLayer), ) +export const loaderNode = LayerNode.make({ service: Loader, layer: loaderLayer, deps: [Provider.node, Agent.node, Command.node, InstanceStore.node] }) + +export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] }) + export * as Directory from "./directory" diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index be64114bb820..f717d142cf42 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -2,6 +2,8 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" import { InstanceStore } from "@/project/instance-store" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -229,4 +231,14 @@ export const defaultLayer = layer.pipe( Layer.provide(InstanceStore.defaultLayer), ) +export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global) + +export const contextLimitLoaderNode = makeGlobalNode({ + service: ContextLimitLoader, + layer: contextLimitLoaderLayer, + deps: [Provider.node, InstanceStore.node], +}) + +export const node = makeGlobalNode({ service: Service, layer, deps: [messageLoaderNode, contextLimitLoaderNode] }) + export * as UsageService from "./usage" diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 04d425e2c465..0ebe69c2394b 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,4 +1,6 @@ import { expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Duration, Effect, Layer, Option, Schema } from "effect" import { sql } from "drizzle-orm" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http" @@ -25,15 +27,16 @@ const truncate = Layer.effectDiscard( yield* db.run(sql`DELETE FROM account_state`) yield* db.run(sql`DELETE FROM account`) }), -).pipe(Layer.provide(Database.defaultLayer)) +) +const truncateNode = LayerNode.make({ name: "truncate-account", layer: truncate, deps: [Database.node] }) -const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate)) +const it = testEffect(LayerNode.compile(LayerNode.group([AccountRepo.node, truncateNode]))) const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1)) const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10)) const live = (client: HttpClient.HttpClient) => - Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + LayerNode.compile(Account.node, [[httpClient, Layer.succeed(HttpClient.HttpClient, client)]]) const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( diff --git a/packages/opencode/test/acp/directory.test.ts b/packages/opencode/test/acp/directory.test.ts index e274db85cb37..75f2d508545b 100644 --- a/packages/opencode/test/acp/directory.test.ts +++ b/packages/opencode/test/acp/directory.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Directory } from "@/acp/directory" import { Command } from "@/command" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Provider } from "@/provider/provider" @@ -83,8 +84,9 @@ const snapshot = (directory: string) => { } const fakeLayer = (calls: string[]) => - Directory.layer.pipe( - Layer.provide( + LayerNode.compile(Directory.node, [ + [ + Directory.loaderNode, Layer.succeed( Directory.Loader, Directory.Loader.of({ @@ -95,8 +97,8 @@ const fakeLayer = (calls: string[]) => }), }), ), - ), - ) + ], + ]) describe("ACP directory snapshot", () => { it.effect("two concurrent callers share one load", () => { diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index d2ff139c5637..06ccfb1f5061 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SessionNotification } from "@agentclientprotocol/sdk" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { UsageService } from "@/acp/usage" @@ -97,24 +98,26 @@ const fakeLayer = (input: { readonly messages?: Effect.Effect readonly providers?: (directory: string) => Effect.Effect, unknown> }) => - UsageService.layer.pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed( - UsageService.MessageLoader, - UsageService.MessageLoader.of({ - messages: () => input.messages ?? Effect.succeed([]), - }), - ), - Layer.succeed( - UsageService.ContextLimitLoader, - UsageService.ContextLimitLoader.of({ - providers: input.providers ?? (() => Effect.succeed(providers())), - }), - ), + LayerNode.compile(UsageService.node, [ + [ + UsageService.messageLoaderNode, + Layer.succeed( + UsageService.MessageLoader, + UsageService.MessageLoader.of({ + messages: () => input.messages ?? Effect.succeed([]), + }), + ), + ], + [ + UsageService.contextLimitLoaderNode, + Layer.succeed( + UsageService.ContextLimitLoader, + UsageService.ContextLimitLoader.of({ + providers: input.providers ?? (() => Effect.succeed(providers())), + }), ), - ), - ) + ], + ]) const connection = (updates: SessionNotification[]) => ({ sessionUpdate(params: SessionNotification) { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index b1162bb7e459..eb0b1aab4a66 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -1,4 +1,5 @@ import { afterEach, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -14,18 +15,11 @@ import { Plugin } from "../../src/plugin" import { Provider } from "../../src/provider/provider" import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" const agentLayer = (flags: Partial = {}) => - Agent.layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(locationServiceMapLayer), - Layer.provide(RuntimeFlags.layer(flags)), - ) + LayerNode.compile(LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) const it = testEffect(agentLayer()) diff --git a/packages/opencode/test/cli/run/variant.shared.test.ts b/packages/opencode/test/cli/run/variant.shared.test.ts index ee9bb07325e3..3de324b5e4e4 100644 --- a/packages/opencode/test/cli/run/variant.shared.test.ts +++ b/packages/opencode/test/cli/run/variant.shared.test.ts @@ -1,5 +1,6 @@ import path from "path" import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { describe, expect, test } from "bun:test" import { Effect, FileSystem, Layer } from "effect" @@ -98,7 +99,7 @@ function userMessage( } } -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(FSUtil.node), NodeFileSystem.layer)) function remap(root: string, file: string) { if (file === Global.Path.state) { @@ -123,7 +124,7 @@ function remappedFs(root: string) { writeJson: (file, data, mode) => fs.writeJson(remap(root, file), data, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } describe("run variant shared", () => { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 0c96267a2daf..38413533c4d9 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { Global } from "@opencode-ai/core/global" @@ -11,7 +12,7 @@ import { TuiConfig } from "../../src/config/tui" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Config.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Config.node, FSUtil.node]))) const winIt = process.platform === "win32" ? it.instance : it.instance.skip const globalConfigFiles = ["opencode.json", "opencode.jsonc", "tui.json", "tui.jsonc"].map((file) => diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index d983de89a85d..148eca8d5c69 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { $ } from "bun" import { Context, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import { InstanceState } from "@/effect/instance-state" @@ -12,7 +13,7 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(CrossSpawnSpawner.node), testInstanceStoreLayer)) const access = (state: InstanceState.InstanceState, dir: string) => InstanceState.get(state).pipe(provideInstanceEffect(dir)) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index e0388a7fd3ea..53ea17cdfaee 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -1,5 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { provideTmpdirInstance, testInstanceStoreLayer, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -7,7 +8,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" -const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer)) describe("Format", () => { it.instance("status() returns empty list when no formatters are configured", () => @@ -106,7 +107,7 @@ describe("Format", () => { ) testEffect( - Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer), + Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer, testInstanceStoreLayer), ).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index aaf2a9ea022f..ac1bcf92e84f 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,4 +1,7 @@ import { describe, expect } from "bun:test" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" @@ -52,8 +55,12 @@ function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { - const appProcess = AppProcess.layer.pipe(Layer.provide(mockSpawner(spawnHandler))) - return Installation.layer.pipe(Layer.provide(mockHttpClient(httpHandler)), Layer.provide(appProcess)) + const spawnerNode = makeGlobalNode({ service: ChildProcessSpawner.ChildProcessSpawner, layer: mockSpawner(spawnHandler), deps: [] }) + const appProcessNode = makeGlobalNode({ service: AppProcess.Service, layer: AppProcess.layer, deps: [spawnerNode] }) + return LayerNode.compile(Installation.node, [ + [httpClient, mockHttpClient(httpHandler)], + [AppProcess.node, appProcessNode], + ]) } describe("installation", () => { diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts index 86f3a5dadfc0..57a7b59d1cfe 100644 --- a/packages/opencode/test/lsp/index.test.ts +++ b/packages/opencode/test/lsp/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, spyOn } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer } from "effect" import { EventV2Bridge } from "@/event-v2-bridge" import { Config } from "@/config/config" @@ -11,19 +12,17 @@ import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" const lspLayer = (flags: Parameters[0] = {}) => - LSP.layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), - Layer.provideMerge(EventV2Bridge.defaultLayer), - ) + LayerNode.compile(LayerNode.group([LSP.node, Config.node, RuntimeFlags.node, EventV2Bridge.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) -const it = testEffect(Layer.mergeAll(lspLayer(), CrossSpawnSpawner.defaultLayer)) +const it = testEffect(Layer.mergeAll(lspLayer(), LayerNode.compile(CrossSpawnSpawner.node))) const experimentalTyIt = testEffect( - Layer.mergeAll(lspLayer({ experimentalLspTy: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ experimentalLspTy: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) const fakeServerPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") const disabledDownloadIt = testEffect( - Layer.mergeAll(lspLayer({ disableLspDownload: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll(lspLayer({ disableLspDownload: true }), LayerNode.compile(CrossSpawnSpawner.node)), ) describe("lsp.spawn", () => { diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts index 0fdfe78b2af9..7ad8c4826c95 100644 --- a/packages/opencode/test/mcp/auth.test.ts +++ b/packages/opencode/test/mcp/auth.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { setTimeout as sleep } from "node:timers/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -41,14 +42,14 @@ function authFile() { : fs.writeJson(file, value, mode), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) return { layer, raw: () => raw } } function authService(layer: Layer.Layer) { return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( - Effect.provide(McpAuth.layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(layer))), + Effect.provide(LayerNode.compile(McpAuth.node, [[FSUtil.node, layer]])), ) } diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 866bf829018b..557e0a1ee7d5 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,4 +1,5 @@ import { expect, mock, beforeEach } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { testEffect } from "../lib/effect" @@ -155,16 +156,7 @@ const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - Layer.mergeAll( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - ), - McpAuth.defaultLayer, - ), + LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node])), ) const config = (name: string) => ({ diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 5797aef8d932..715616746077 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,6 @@ import { expect, mock, beforeEach } from "bun:test" import { EventEmitter } from "events" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { awaitWithTimeout, testEffect } from "../lib/effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -122,13 +123,7 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - MCP.layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - ), + LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node])), ) const service = MCP.Service as unknown as Effect.Effect diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index e68c1dd7aa13..dd659728eb6b 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" @@ -15,7 +16,7 @@ import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node]))) function layer(directory: string, plugins: string[]) { return ProviderAuth.layer.pipe( diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 017bc84d8510..285febbda578 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, spyOn } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import fs from "fs/promises" import path from "path" @@ -20,7 +21,7 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer)) function withTmp( init: (dir: string) => Promise, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 2d2e8cb8a04a..da84935433e4 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, test } from "bun:test" import { mkdir, unlink } from "fs/promises" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { ModelsDev } from "@opencode-ai/core/models-dev" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -57,14 +58,9 @@ afterEach(async () => { }) const providerLayer = (flags: Partial = {}) => - Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), + LayerNode.compile( + LayerNode.group([Provider.node, FSUtil.node, Env.node, Config.node, Auth.node, Plugin.node, ModelsDev.node, RuntimeFlags.node]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], ) const list = Provider.use.list() @@ -77,7 +73,7 @@ const paid = (providers: Record (language as { config: { baseURL: string } }).config.baseURL -const it = testEffect(Layer.mergeAll(Provider.defaultLayer, Env.defaultLayer, Plugin.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Provider.node, Env.node, Plugin.node]))) const experimentalModels = testEffect(providerLayer({ enableExperimentalModels: true })) const alphaProviderConfig = { diff --git a/packages/opencode/test/server/global-session-list.test.ts b/packages/opencode/test/server/global-session-list.test.ts index 21faa82fe332..7a13553af618 100644 --- a/packages/opencode/test/server/global-session-list.test.ts +++ b/packages/opencode/test/server/global-session-list.test.ts @@ -1,4 +1,6 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Layer } from "effect" import { Project } from "@/project/project" import { Session as SessionNs } from "@/session/session" @@ -6,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, Project.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node, Project.node, CrossSpawnSpawner.node])), +) const withSession = (input?: Parameters[0]) => Effect.acquireRelease(SessionNs.use.create(input), (created) => diff --git a/packages/opencode/test/server/httpapi-experimental.test.ts b/packages/opencode/test/server/httpapi-experimental.test.ts index 6b87039f048c..171716435697 100644 --- a/packages/opencode/test/server/httpapi-experimental.test.ts +++ b/packages/opencode/test/server/httpapi-experimental.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Fiber, Layer } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -15,7 +16,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance function request(path: string, directory: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts index f96abc239534..625ca1896b46 100644 --- a/packages/opencode/test/server/httpapi-provider.test.ts +++ b/packages/opencode/test/server/httpapi-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import path from "path" @@ -15,7 +16,7 @@ const testStateLayer = Layer.effectDiscard( ), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(testStateLayer, LayerNode.compile(FSUtil.node), httpApiLayer)) const projectOptions = { config: { formatter: false, lsp: false } } const providerID = "test-oauth-parity" const oauthURL = "https://example.com/oauth" diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index c650b3772ad9..4ea6fd38e846 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" @@ -16,7 +17,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) const text = (response: HttpClientResponse.HttpClientResponse) => response.text diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts index 9a6861c1f2ae..0577e6bf02d3 100644 --- a/packages/opencode/test/server/httpapi-sync.test.ts +++ b/packages/opencode/test/server/httpapi-sync.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Context, Effect, Layer } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync" @@ -11,7 +12,7 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const context = Context.empty() as Context.Context -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/negative-tokens-regression.test.ts b/packages/opencode/test/server/negative-tokens-regression.test.ts index b23726965fd7..453aa189d5a1 100644 --- a/packages/opencode/test/server/negative-tokens-regression.test.ts +++ b/packages/opencode/test/server/negative-tokens-regression.test.ts @@ -6,6 +6,7 @@ // strict `NonNegativeInt` schema then made every load of the message list // fail to encode, killing Desktop boot for every user with such a row. import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { eq } from "drizzle-orm" @@ -21,7 +22,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Database.node])), httpApiLayer)) function seedNegativeTokenSession() { return Effect.gen(function* () { diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 5b2deb87a993..7a691509536f 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -2,12 +2,13 @@ import { afterEach, describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { FSUtil } from "@opencode-ai/core/fs-util" import { Database } from "@opencode-ai/core/database/database" import { Snapshot } from "@/snapshot" -import { InstanceBootstrap } from "@/project/bootstrap-service" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -20,9 +21,9 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) const it = testEffect( - Layer.mergeAll(FSUtil.defaultLayer, Database.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer), + Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), testInstanceStore, httpApiLayer), ) function request(directory: string, url: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index 9deac21af9c8..e74f93aaaf60 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import path from "path" import { InstanceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Snapshot } from "../../src/snapshot" @@ -19,9 +20,9 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) -const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer)) function request(directory: string, url: string, init: RequestInit = {}) { return requestInDirectory(url, directory, init) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index a4ad3793bc72..cf27c74cbecf 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,11 +1,12 @@ import { afterEach, describe, expect, mock } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) afterEach(async () => { mock.restore() diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index 5982627b0492..d2a4211ff1a9 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -11,6 +11,7 @@ * asserts that GET /session//diff returns 200 with empty data. */ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" import { Session } from "@/session/session" @@ -24,7 +25,7 @@ import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Session.node, Storage.node])), httpApiLayer)) afterEach(async () => { await disposeAllInstances() diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index 3e66c59d5e2c..c47913bde5ab 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" import { Session as SessionNs } from "@/session/session" @@ -12,7 +13,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer)) const model = { providerID: ProviderV2.ID.make("test"), diff --git a/packages/opencode/test/server/session-select.test.ts b/packages/opencode/test/server/session-select.test.ts index a9bc84202be5..82a8ad93955c 100644 --- a/packages/opencode/test/server/session-select.test.ts +++ b/packages/opencode/test/server/session-select.test.ts @@ -1,11 +1,12 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Session } from "@/session/session" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" -const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Session.node), httpApiLayer)) describe("tui.selectSession endpoint", () => { it.instance( diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index c3ff3788d643..1484bfd631a3 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" @@ -6,7 +7,6 @@ import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" import { testEffect } from "../lib/effect" const skills: Skill.Info[] = [ @@ -43,9 +43,9 @@ const build: Agent.Info = { } const it = testEffect( - SystemPrompt.layer.pipe( - Layer.provide(locationServiceMapLayer), - Layer.provide( + LayerNode.compile(SystemPrompt.node, [ + [ + MCP.node, Layer.mock(MCP.Service, { instructions: () => Effect.succeed([ @@ -61,8 +61,9 @@ const it = testEffect( }, ]), }), - ), - Layer.provide( + ], + [ + Skill.node, Layer.succeed( Skill.Service, Skill.Service.of({ @@ -77,8 +78,8 @@ const it = testEffect( available: () => Effect.succeed(skills), }), ), - ), - ), + ], + ]), ) describe("session.system", () => { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index a2e5b6b77c6c..f365b63f0d72 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { Skill } from "../../src/skill" import { Discovery } from "../../src/skill/discovery" @@ -13,33 +14,19 @@ import { testEffect } from "../lib/effect" import path from "path" import fs from "fs/promises" -const node = CrossSpawnSpawner.defaultLayer +const node = LayerNode.compile(CrossSpawnSpawner.node) -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(Skill.node), node, testInstanceStoreLayer)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( - Skill.layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), - ), + LayerNode.compile(Skill.node, [[RuntimeFlags.node, RuntimeFlags.layer({ disableClaudeCodeSkills: true })]]), node, testInstanceStoreLayer, ), ) const itWithoutExternalSkills = testEffect( Layer.mergeAll( - Skill.layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), - ), + LayerNode.compile(Skill.node, [[RuntimeFlags.node, RuntimeFlags.layer({ disableExternalSkills: true })]]), node, testInstanceStoreLayer, ), diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 21ec0872b824..6822b6ee9e3e 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect } from "bun:test" import { $ } from "bun" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import fs from "fs/promises" import path from "path" @@ -15,7 +16,7 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer)) +const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer)) // Windows forbids both * and : in directory names. const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live @@ -74,11 +75,11 @@ const withTrackedSnapshot = ( }) const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { - const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) + const dir = yield* tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) return { path: dir, extra: yield* initialize(dir) } }) -const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(CrossSpawnSpawner.defaultLayer)) +const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => Effect.promise(async () => { diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts index afb2e93755e3..a3f3aef4464b 100644 --- a/packages/opencode/test/storage/storage.test.ts +++ b/packages/opencode/test/storage/storage.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Exit, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -11,7 +12,7 @@ import { testEffect } from "../lib/effect" const dir = path.join(Global.Path.data, "storage") -const it = testEffect(Layer.mergeAll(Storage.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer)) +const it = testEffect(LayerNode.compile(LayerNode.group([Storage.node, FSUtil.node, CrossSpawnSpawner.node]))) const scope = Effect.fnUntraced(function* () { const root = ["storage_test", crypto.randomUUID()] @@ -50,14 +51,14 @@ function remappedFs(root: string) { fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options), }) }), - ).pipe(Layer.provide(FSUtil.defaultLayer)) + ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) } // Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache // returns the outer testEffect's Storage (which uses the real FSUtil), not a new // one built on top of remappedFs. const remappedStorage = (root: string) => - Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer))) + Layer.fresh(LayerNode.compile(Storage.node, [[FSUtil.node, remappedFs(root)]])) describe("Storage", () => { it.live("round-trips JSON content", () => diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 01a09add8732..d890053ed9dc 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import path from "path" import * as fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { LSP } from "@/lsp/lsp" @@ -14,14 +15,7 @@ import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - ), + LayerNode.compile(LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node])), ) const baseCtx = { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 12db53551856..0193d39cd567 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect } from "bun:test" import path from "path" import fs from "fs/promises" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EditTool } from "../../src/tool/edit" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -30,14 +31,7 @@ afterEach(async () => { await disposeAllInstances() }) -const layer = Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - Format.defaultLayer, - EventV2Bridge.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, -) +const layer = LayerNode.compile(LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node])) const it = testEffect(layer) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 44146249907d..5c1785e69cd9 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -1,6 +1,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import { GlobTool } from "../../src/tool/glob" import { SessionID, MessageID } from "../../src/session/schema" @@ -20,14 +21,7 @@ import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" const toolLayer = (flags: Partial = {}) => - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, - ) + LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node])) const it = testEffect(toolLayer()) const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index da314d7bf3ab..74b876a7dee8 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -3,6 +3,7 @@ import { describe, expect } from "bun:test" import fs from "fs/promises" import os from "os" import path from "path" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { GrepTool } from "../../src/tool/grep" import { provideInstance, testInstanceStoreLayer, TestInstance, tmpdirScoped } from "../fixture/fixture" @@ -22,14 +23,7 @@ import { Git } from "@/git" import { Filesystem } from "@/util/filesystem" const toolLayer = (flags: Partial = {}) => - Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, - Git.defaultLayer, - ) + LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node])) const it = testEffect(toolLayer()) const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/lsp.test.ts b/packages/opencode/test/tool/lsp.test.ts index ddcf14e9ae39..8735fdb7b29d 100644 --- a/packages/opencode/test/tool/lsp.test.ts +++ b/packages/opencode/test/tool/lsp.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -56,7 +57,9 @@ const lsp = Layer.succeed( ) const it = testEffect( - Layer.mergeAll(Agent.defaultLayer, FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, lsp), + LayerNode.compile(LayerNode.group([Agent.node, FSUtil.node, CrossSpawnSpawner.node, Truncate.node, LSP.node]), [ + [LSP.node, lsp], + ]), ) const init = Effect.fn("LspToolTest.init")(function* () { diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 67205f56e384..7eaac9b45df0 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer, Stream } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" @@ -44,14 +45,8 @@ const ctx = { } const readLayer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Instruction.defaultLayer, - LSP.defaultLayer, - Ripgrep.defaultLayer, - Truncate.defaultLayer, + LayerNode.compile( + LayerNode.group([Agent.node, FSUtil.node, CrossSpawnSpawner.node, Instruction.node, LSP.node, Ripgrep.node, Truncate.node]), ) const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 7b98e721d369..a970f85d468f 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit, Layer } from "effect" import type * as Scope from "effect/Scope" import os from "os" @@ -22,13 +23,17 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { InstanceStore } from "@/project/instance-store" const shellLayer = Layer.mergeAll( - CrossSpawnSpawner.defaultLayer, - FSUtil.defaultLayer, - Plugin.defaultLayer, - Truncate.defaultLayer, - Config.defaultLayer, - Agent.defaultLayer, - RuntimeFlags.defaultLayer, + LayerNode.compile( + LayerNode.group([ + CrossSpawnSpawner.node, + FSUtil.node, + Plugin.node, + Truncate.node, + Config.node, + Agent.node, + RuntimeFlags.node, + ]), + ), testInstanceStoreLayer, ) const it = testEffect(shellLayer) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 177dbddcbb45..1c25b253cd56 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,5 +1,6 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" @@ -26,9 +27,7 @@ afterEach(async () => { await disposeAllInstances() }) -const node = CrossSpawnSpawner.defaultLayer - -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node).pipe(Layer.provide(Ripgrep.defaultLayer))) +const it = testEffect(LayerNode.compile(LayerNode.group([ToolRegistry.node, CrossSpawnSpawner.node, Ripgrep.node]))) describe("tool.skill", () => { it.instance("execute returns skill content block with files", () => diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 97bb7db065bd..6238a6a0773f 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Fiber, Layer } from "effect" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "@/background/job" @@ -33,20 +35,25 @@ const ref = { } const layer = (flags: Partial = {}) => - Layer.mergeAll( - Agent.defaultLayer, - BackgroundJob.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Session.defaultLayer, - SessionRunState.defaultLayer, - SessionStatus.defaultLayer, - Truncate.defaultLayer, - ToolRegistry.defaultLayer, - Database.defaultLayer, - RuntimeFlags.layer(flags), - ).pipe(Layer.provide(Ripgrep.defaultLayer)) + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + EventV2Bridge.node, + Config.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + SessionRunState.node, + SessionStatus.node, + Truncate.node, + ToolRegistry.node, + Database.node, + RuntimeFlags.node, + Ripgrep.node, + ]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) const it = testEffect(layer()) const background = testEffect(layer({ experimentalBackgroundSubagents: true })) diff --git a/packages/opencode/test/tool/webfetch.test.ts b/packages/opencode/test/tool/webfetch.test.ts index fdf5210b9c13..05599a6784e6 100644 --- a/packages/opencode/test/tool/webfetch.test.ts +++ b/packages/opencode/test/tool/webfetch.test.ts @@ -1,6 +1,8 @@ import { describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { Agent } from "../../src/agent/agent" import { Truncate } from "@/tool/truncate" import { WebFetchTool } from "../../src/tool/webfetch" @@ -8,7 +10,11 @@ import { SessionID, MessageID } from "../../src/session/schema" import { Tool } from "@/tool/tool" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)) +const it = testEffect( + LayerNode.compile(LayerNode.group([httpClient, Truncate.node, Agent.node]), [ + [httpClient, FetchHttpClient.layer as Layer.Layer], + ]), +) const ctx = { sessionID: SessionID.make("ses_test"), diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 63a2a52aa986..664e725cd14f 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import path from "path" import fs from "fs/promises" @@ -31,14 +32,8 @@ afterEach(async () => { }) const it = testEffect( - Layer.mergeAll( - LSP.defaultLayer, - FSUtil.defaultLayer, - EventV2Bridge.defaultLayer, - Format.defaultLayer, - CrossSpawnSpawner.defaultLayer, - Truncate.defaultLayer, - Agent.defaultLayer, + LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, EventV2Bridge.node, Format.node, CrossSpawnSpawner.node, Truncate.node, Agent.node]), ), ) From d6e54e9042f2133d40359244fc010cbf2369b17d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 17:58:20 +0000 Subject: [PATCH 032/303] chore: generate --- packages/opencode/src/acp/directory.ts | 6 +++++- packages/opencode/test/agent/agent.test.ts | 7 ++++--- packages/opencode/test/format/format.test.ts | 10 ++++++++-- .../opencode/test/installation/installation.test.ts | 6 +++++- packages/opencode/test/mcp/oauth-auto-connect.test.ts | 4 +++- packages/opencode/test/mcp/oauth-browser.test.ts | 4 +++- packages/opencode/test/plugin/loader-shared.test.ts | 4 +++- packages/opencode/test/provider/provider.test.ts | 11 ++++++++++- packages/opencode/test/server/project-copy.test.ts | 6 +++++- .../opencode/test/server/project-init-git.test.ts | 4 +++- packages/opencode/test/snapshot/snapshot.test.ts | 7 +++++-- packages/opencode/test/tool/apply_patch.test.ts | 4 +++- packages/opencode/test/tool/edit.test.ts | 4 +++- packages/opencode/test/tool/glob.test.ts | 4 +++- packages/opencode/test/tool/grep.test.ts | 4 +++- packages/opencode/test/tool/read.test.ts | 10 +++++++++- packages/opencode/test/tool/write.test.ts | 10 +++++++++- 17 files changed, 84 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index 3a627fe30f6e..282020aa81a7 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -208,7 +208,11 @@ export const defaultLayer = layer.pipe( Layer.provide(InstanceStore.defaultLayer), ) -export const loaderNode = LayerNode.make({ service: Loader, layer: loaderLayer, deps: [Provider.node, Agent.node, Command.node, InstanceStore.node] }) +export const loaderNode = LayerNode.make({ + service: Loader, + layer: loaderLayer, + deps: [Provider.node, Agent.node, Command.node, InstanceStore.node], +}) export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] }) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index eb0b1aab4a66..efeaf408a259 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -17,9 +17,10 @@ import { Skill } from "../../src/skill" import { Truncate } from "../../src/tool/truncate" const agentLayer = (flags: Partial = {}) => - LayerNode.compile(LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]), [ - [RuntimeFlags.node, RuntimeFlags.layer(flags)], - ]) + LayerNode.compile( + LayerNode.group([Agent.node, Plugin.node, Provider.node, Auth.node, Config.node, Skill.node, RuntimeFlags.node]), + [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], + ) const it = testEffect(agentLayer()) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 53ea17cdfaee..72adc848f430 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -8,7 +8,9 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../../src/format" import * as Formatter from "../../src/format/formatter" -const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer), +) describe("Format", () => { it.instance("status() returns empty list when no formatters are configured", () => @@ -107,7 +109,11 @@ describe("Format", () => { ) testEffect( - Layer.mergeAll(LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), NodeFileSystem.layer, testInstanceStoreLayer), + Layer.mergeAll( + LayerNode.compile(LayerNode.group([Format.node, CrossSpawnSpawner.node])), + NodeFileSystem.layer, + testInstanceStoreLayer, + ), ).live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.use.status(), { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index ac1bcf92e84f..9db6b0d125c0 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -55,7 +55,11 @@ function testLayer( httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, spawnHandler?: (cmd: string, args: readonly string[]) => string | { code: number; stdout?: string; stderr?: string }, ) { - const spawnerNode = makeGlobalNode({ service: ChildProcessSpawner.ChildProcessSpawner, layer: mockSpawner(spawnHandler), deps: [] }) + const spawnerNode = makeGlobalNode({ + service: ChildProcessSpawner.ChildProcessSpawner, + layer: mockSpawner(spawnHandler), + deps: [], + }) const appProcessNode = makeGlobalNode({ service: AppProcess.Service, layer: AppProcess.layer, deps: [spawnerNode] }) return LayerNode.compile(Installation.node, [ [httpClient, mockHttpClient(httpHandler)], diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 557e0a1ee7d5..febbe0d0bdc7 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -156,7 +156,9 @@ const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node])), + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), + ), ) const config = (name: string) => ({ diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 715616746077..07b157ecc98b 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -123,7 +123,9 @@ const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback") const { FSUtil } = await import("@opencode-ai/core/fs-util") const { CrossSpawnSpawner } = await import("@opencode-ai/core/cross-spawn-spawner") const mcpTest = testEffect( - LayerNode.compile(LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node])), + LayerNode.compile( + LayerNode.group([MCP.node, McpAuth.node, EventV2Bridge.node, Config.node, CrossSpawnSpawner.node, FSUtil.node]), + ), ) const service = MCP.Service as unknown as Effect.Effect diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index 285febbda578..b30f6d15cb3e 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -21,7 +21,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node])), testInstanceStoreLayer), +) function withTmp( init: (dir: string) => Promise, diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index da84935433e4..3fc948acddbc 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -59,7 +59,16 @@ afterEach(async () => { const providerLayer = (flags: Partial = {}) => LayerNode.compile( - LayerNode.group([Provider.node, FSUtil.node, Env.node, Config.node, Auth.node, Plugin.node, ModelsDev.node, RuntimeFlags.node]), + LayerNode.group([ + Provider.node, + FSUtil.node, + Env.node, + Config.node, + Auth.node, + Plugin.node, + ModelsDev.node, + RuntimeFlags.node, + ]), [[RuntimeFlags.node, RuntimeFlags.layer(flags)]], ) diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 7a691509536f..4c40908f1fc4 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -23,7 +23,11 @@ afterEach(async () => { const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) const it = testEffect( - Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), testInstanceStore, httpApiLayer), + Layer.mergeAll( + LayerNode.compile(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), + testInstanceStore, + httpApiLayer, + ), ) function request(directory: string, url: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index e74f93aaaf60..dc5f92cd6e5a 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -22,7 +22,9 @@ afterEach(async () => { const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) -const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), +) function request(directory: string, url: string, init: RequestInit = {}) { return requestInDirectory(url, directory, init) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 6822b6ee9e3e..a39624087c63 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -16,7 +16,9 @@ import { } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer)) +const it = testEffect( + Layer.mergeAll(LayerNode.compile(LayerNode.group([Snapshot.node, FSUtil.node])), testInstanceStoreLayer), +) // Windows forbids both * and : in directory names. const nonWindowsIt = process.platform === "win32" ? it.live.skip : it.live @@ -79,7 +81,8 @@ const bootstrapScoped = Effect.fn("SnapshotTest.bootstrapScoped")(function* () { return { path: dir, extra: yield* initialize(dir) } }) -const scopedGitTmpdir = () => tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) +const scopedGitTmpdir = () => + tmpdirScoped({ git: true }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))) const cleanupWorktree = (repo: string, worktree: string, files: string[] = []) => Effect.promise(async () => { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index d890053ed9dc..e394d8084f9a 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -15,7 +15,9 @@ import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" const it = testEffect( - LayerNode.compile(LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node])), + LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), + ), ) const baseCtx = { diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 0193d39cd567..46155a50918e 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -31,7 +31,9 @@ afterEach(async () => { await disposeAllInstances() }) -const layer = LayerNode.compile(LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node])) +const layer = LayerNode.compile( + LayerNode.group([LSP.node, FSUtil.node, Format.node, EventV2Bridge.node, Truncate.node, Agent.node]), +) const it = testEffect(layer) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 5c1785e69cd9..018b1a0b9a20 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -21,7 +21,9 @@ import { Permission } from "../../src/permission" import type * as Tool from "../../src/tool/tool" const toolLayer = (flags: Partial = {}) => - LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node])) + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), + ) const it = testEffect(toolLayer()) const full = (p: string) => (process.platform === "win32" ? Filesystem.normalizePath(p) : p) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 74b876a7dee8..81b74aa8baf0 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -23,7 +23,9 @@ import { Git } from "@/git" import { Filesystem } from "@/util/filesystem" const toolLayer = (flags: Partial = {}) => - LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node])) + LayerNode.compile( + LayerNode.group([CrossSpawnSpawner.node, FSUtil.node, Ripgrep.node, Truncate.node, Agent.node, Git.node]), + ) const it = testEffect(toolLayer()) const rooted = testEffect(Layer.mergeAll(toolLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 7eaac9b45df0..c1ef61b227dd 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -46,7 +46,15 @@ const ctx = { const readLayer = (flags: Partial = {}) => LayerNode.compile( - LayerNode.group([Agent.node, FSUtil.node, CrossSpawnSpawner.node, Instruction.node, LSP.node, Ripgrep.node, Truncate.node]), + LayerNode.group([ + Agent.node, + FSUtil.node, + CrossSpawnSpawner.node, + Instruction.node, + LSP.node, + Ripgrep.node, + Truncate.node, + ]), ) const it = testEffect(Layer.mergeAll(readLayer(), testInstanceStoreLayer)) diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 664e725cd14f..4f897dc87591 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -33,7 +33,15 @@ afterEach(async () => { const it = testEffect( LayerNode.compile( - LayerNode.group([LSP.node, FSUtil.node, EventV2Bridge.node, Format.node, CrossSpawnSpawner.node, Truncate.node, Agent.node]), + LayerNode.group([ + LSP.node, + FSUtil.node, + EventV2Bridge.node, + Format.node, + CrossSpawnSpawner.node, + Truncate.node, + Agent.node, + ]), ), ) From 762588c251b7e0ceb239e047da4c04e3c2b5fedc Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 14:25:33 -0400 Subject: [PATCH 033/303] refactor(core): convert prompt tests to nodes (#34470) --- packages/opencode/test/session/prompt.test.ts | 152 +++++++++--------- 1 file changed, 73 insertions(+), 79 deletions(-) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bb98a867faee..491ad06aaf47 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,10 +1,10 @@ -import { NodeFileSystem } from "@effect/platform-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { eq } from "drizzle-orm" import { EventV2Bridge } from "@/event-v2-bridge" -import { FetchHttpClient } from "effect/unstable/http" import { expect } from "bun:test" import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer } from "effect" import path from "path" @@ -156,10 +156,6 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(EventV2Bridge.defaultLayer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) - const processorCreateStarted: Array<() => void> = [] const blockingProcessor = Layer.succeed( SessionProcessor.Service, @@ -168,80 +164,75 @@ const blockingProcessor = Layer.succeed( }), ) +const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true }) + +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, +]) + function makePrompt(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - makeMcp(input?.mcpInstructions), - FSUtil.defaultLayer, - BackgroundJob.defaultLayer, - status, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Git.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = - input?.processor === "blocking" - ? blockingProcessor - : SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - ) - const compact = SessionCompaction.layer.pipe( - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(proc), - Layer.provideMerge(deps), - ) - return SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provide( - SystemPrompt.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(locationServiceMapLayer), - Layer.provide(deps), - ), - ), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp(input?.mcpInstructions)], + [RuntimeFlags.node, runtimeFlags], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(promptRoot, [...replacements, [SessionProcessor.node, blockingProcessor]]) + } + return LayerNode.compile(promptRoot, replacements) } function makeHttp(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { - return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) + const root = LayerNode.group([promptRoot, testLLMServerNode]) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp(input?.mcpInstructions)], + [RuntimeFlags.node, runtimeFlags], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(root, [...replacements, [SessionProcessor.node, blockingProcessor]]) + } + return LayerNode.compile(root, replacements) } function makeHttpNoLLMServer(input?: { mcpInstructions?: MCP.ServerInstructions[]; processor?: "blocking" }) { @@ -697,9 +688,12 @@ noLLMServer.instance.skip( }) const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(SessionV2.defaultLayer), - Effect.provide(SessionExecution.noopLayer), - Effect.provide(locationServiceMapLayer), + Effect.provide( + LayerNode.compile(SessionV2.node, [ + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locationServiceMapLayer], + ]), + ), ) const { db } = yield* Database.Service const row = yield* db From be14739cce49f057c35e540cdaef05caa20256ab Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 14:51:13 -0400 Subject: [PATCH 034/303] refactor(core): convert config tests to nodes (#34474) --- packages/opencode/test/config/config.test.ts | 55 ++++++++------------ 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 02ace5366880..08e6264be9ff 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,13 +1,14 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Cause, Effect, Exit, Layer, Option } from "effect" import { NamedError } from "@opencode-ai/core/util/error" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" -import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" @@ -41,13 +42,6 @@ import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" -/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ -const infra = CrossSpawnSpawner.defaultLayer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) - -const testFlock = EffectFlock.defaultLayer - const unexpectedHttp = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) @@ -104,15 +98,14 @@ const configLayer = ( client?: HttpClient.HttpClient } = {}, ) => - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(Env.defaultLayer), - Layer.provide(options.auth ?? AuthTest.empty), - Layer.provide(options.account ?? AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), - Layer.provideMerge(FSUtil.defaultLayer), + LayerNode.compile( + LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), + [ + [Auth.node, options.auth ?? AuthTest.empty], + [Account.node, options.account ?? AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)], + ], ) const layer = configLayer() @@ -171,7 +164,7 @@ const withInstanceDir = (dir: string, effect: Effect.Effect) = Effect.provideService(TestInstance, { directory: dir }), provideInstanceEffect(dir), Effect.provide(testInstanceStoreLayer), - Effect.provide(CrossSpawnSpawner.defaultLayer), + Effect.provide(LayerNode.compile(CrossSpawnSpawner.node)), ) const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => @@ -325,7 +318,7 @@ it.effect("creates global jsonc config with schema when no global configs exist" const content = yield* FSUtil.use.readFileString(path.join(dir, "opencode.jsonc")) expect(content).toContain('"$schema": "https://opencode.ai/config.json"') - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) @@ -340,7 +333,7 @@ it.effect("does not create global config when OPENCODE_CONFIG_DIR is set", () => yield* Config.use.get().pipe(provideInstanceEffect(dir)) expect(yield* FSUtil.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ), ) }), @@ -930,7 +923,7 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR yield* Effect.addFinalizer(() => FSUtil.use.chmod(readonly, 0o755).pipe(Effect.ignore)) yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => @@ -948,7 +941,7 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => ) expect(yield* FSUtil.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") - }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))), ) // Note: deduplication and serialization of npm installs is now handled by the @@ -1533,16 +1526,12 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Effect.scoped, Effect.provide( Layer.mergeAll( - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth(server.url.origin)), - Layer.provide(AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), - ), + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, wellKnownAuth(server.url.origin)], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, FetchHttpClient.layer], + ]), testInstanceStoreLayer, ), ), From d8f9388610ba8e02d063a2a57d9ab8b16e0d4487 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 18:52:55 +0000 Subject: [PATCH 035/303] chore: generate --- packages/opencode/test/config/config.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 08e6264be9ff..4b68725852ae 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -98,15 +98,12 @@ const configLayer = ( client?: HttpClient.HttpClient } = {}, ) => - LayerNode.compile( - LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), - [ - [Auth.node, options.auth ?? AuthTest.empty], - [Account.node, options.account ?? AccountTest.empty], - [Npm.node, NpmTest.noop], - [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)], - ], - ) + LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ + [Auth.node, options.auth ?? AuthTest.empty], + [Account.node, options.account ?? AccountTest.empty], + [Npm.node, NpmTest.noop], + [httpClient, Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)], + ]) const layer = configLayer() From b9dae8593ce1b55747eca6f8e5d591611e9cd63b Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 15:42:52 -0400 Subject: [PATCH 036/303] refactor(opencode): migrate compaction and workspace tests to layer nodes (#34478) --- .../test/plugin/workspace-adapter.test.ts | 54 ++------ .../opencode/test/project/project.test.ts | 55 ++++---- .../opencode/test/session/compaction.test.ts | 125 ++++++++---------- 3 files changed, 90 insertions(+), 144 deletions(-) diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 30428980bbfb..77eca1411a5a 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,63 +1,35 @@ import { afterEach, describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" -import { FSUtil } from "@opencode-ai/core/fs-util" +import { Npm } from "@opencode-ai/core/npm" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Workspace } from "../../src/control-plane/workspace" import { Plugin } from "../../src/plugin/index" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" -import { Project } from "../../src/project/project" -import { Vcs } from "../../src/project/vcs" import { InstanceState } from "../../src/effect/instance-state" -import { Session } from "../../src/session/session" -import { SessionPrompt } from "../../src/session/prompt" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const configLayer = Config.layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const workspaceLayer = Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })), -) + const it = testEffect( - Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), + AppNodeBuilder.build(LayerNode.group([Plugin.node, Workspace.node, InstanceStore.node, Ripgrep.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [InstanceBootstrap.node, noopBootstrapLayer], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, experimentalWorkspaces: true })], + ]), ) afterEach(async () => { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 05e205cd8704..804b92b08ec4 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -1,5 +1,4 @@ import { describe, expect } from "bun:test" -import { EventV2Bridge } from "@/event-v2-bridge" import { Project } from "@/project/project" import { $ } from "bun" import path from "path" @@ -15,19 +14,17 @@ import { SessionID } from "@/session/schema" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { Cause, Effect, Exit, Layer, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { AppProcess } from "@opencode-ai/core/process" import { ProjectV2 } from "@opencode-ai/core/project" -import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const encoder = new TextEncoder() -const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer) -const it = testEffect(layer) +const projectTestNode = LayerNode.group([Project.node, Database.node, CrossSpawnSpawner.node]) +const it = testEffect(AppNodeBuilder.build(projectTestNode)) function remoteProjectID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) @@ -65,41 +62,37 @@ function mockGitFailure(failArg: string) { }), ) }), - ).pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) + ).pipe(Layer.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) } function projectLayerWithFailure(failArg: string) { - return Project.layer.pipe( - Layer.provide(AppProcess.layer.pipe(Layer.provide(mockGitFailure(failArg)))), - Layer.provide(mockGitFailure(failArg)), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) + return AppNodeBuilder.build(Project.node, [ + [ProjectV2.node, projectV2FailureLayer()], + [CrossSpawnSpawner.node, mockGitFailure(failArg)], + ]) } -function projectLayerWithRuntimeFlags(flags: Parameters[0]) { - return Project.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.layer(flags)), +function projectV2FailureLayer() { + return Layer.succeed( + ProjectV2.Service, + ProjectV2.Service.of({ + directories: () => Effect.succeed([]), + resolve: (input) => + Effect.succeed({ + id: ProjectV2.ID.global, + directory: input, + vcs: { type: "git" as const, store: input }, + }), + commit: () => Effect.void, + }), ) } const failureIt = (failArg: string) => - testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + testEffect(AppNodeBuilder.build(projectTestNode, [[Project.node, projectLayerWithFailure(failArg)]])) const iconDiscoveryIt = testEffect( - Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), + AppNodeBuilder.build(projectTestNode, [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalIconDiscovery: true })]]), ) function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 641daeead4a0..c02ea5911d00 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -7,12 +7,9 @@ import { APICallError } from "ai" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema } from "effect" import * as Stream from "effect/Stream" import { Config } from "@/config/config" -import { Image } from "@/image/image" -import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" import { Token } from "@/util/token" -import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { provideTmpdirInstance, TestInstance } from "../fixture/fixture" import { Session as SessionNs } from "@/session/session" @@ -21,12 +18,11 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SessionV2 } from "@opencode-ai/core/session" -import { locationServiceMapLayer } from "@opencode-ai/core/location-services" import { SessionExecution } from "@opencode-ai/core/session/execution" +import { SessionProjector } from "@opencode-ai/core/session/projector" -import type { Provider } from "@/provider/provider" +import { Provider } from "@/provider/provider" import * as SessionProcessorModule from "../../src/session/processor" -import { Snapshot } from "../../src/snapshot" import { ProviderTest } from "../fake/provider" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -35,6 +31,8 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { LLMEvent, Usage } from "@opencode-ai/llm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const summary = Layer.succeed( SessionSummary.Service, @@ -210,7 +208,7 @@ function fake( } satisfies SessionProcessorModule.SessionProcessor.Handle } -function layer(result: "continue" | "compact") { +function processorLayer(result: "continue" | "compact") { return Layer.succeed( SessionProcessorModule.SessionProcessor.Service, SessionProcessorModule.SessionProcessor.Service.of({ @@ -221,38 +219,28 @@ function layer(result: "continue" | "compact") { function cfg(compaction?: ConfigV1.Info["compaction"]) { const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info - return TestConfig.layer({ - get: () => Effect.succeed({ ...base, compaction }), - }) + return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) })) } -const deps = Layer.mergeAll( - wide().layer, - layer("continue"), - Agent.defaultLayer, - Plugin.defaultLayer, - EventV2Bridge.defaultLayer, - Config.defaultLayer, - RuntimeFlags.layer({ experimentalEventSystem: true }), - Database.defaultLayer, - EventV2Bridge.defaultLayer, -) - -const env = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, - SessionCompaction.layer.pipe(Layer.provide(SessionNs.defaultLayer), Layer.provideMerge(deps)), -) +const defaultProvider = wide() +const compactionTestNode = LayerNode.group([ + SessionCompaction.node, + SessionNs.node, + SessionProjector.node, + Database.node, + EventV2Bridge.node, + CrossSpawnSpawner.node, +]) +const env = AppNodeBuilder.build(compactionTestNode, [ + [Provider.node, defaultProvider.layer], + [SessionProcessorModule.SessionProcessor.node, processorLayer("continue")], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], +]) const it = testEffect(env) -const compactionEnv = Layer.mergeAll( - SessionNs.defaultLayer, - Database.defaultLayer, - EventV2Bridge.defaultLayer, - CrossSpawnSpawner.defaultLayer, +const compactionEnv = AppNodeBuilder.build( + LayerNode.group([SessionNs.node, SessionProjector.node, Database.node, EventV2Bridge.node, CrossSpawnSpawner.node]), ) const itCompaction = testEffect(compactionEnv) @@ -260,7 +248,7 @@ type CompactionProcessOptions = { result?: "continue" | "compact" llm?: Layer.Layer plugin?: Layer.Layer - provider?: ReturnType + provider?: ReturnType config?: Layer.Layer } @@ -269,30 +257,25 @@ function withCompaction(options?: CompactionProcessOptions) { } function compactionProcessLayer(options?: CompactionProcessOptions) { - const events = EventV2Bridge.defaultLayer - const status = SessionStatus.layer.pipe(Layer.provide(events)) - const processor = options?.llm - ? SessionProcessorModule.SessionProcessor.layer.pipe( - Layer.provide(summary), - Layer.provide(Image.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(status), - ) - : layer(options?.result ?? "continue") - return Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, events, status).pipe( - Layer.provide(SessionNs.defaultLayer), - Layer.provide((options?.provider ?? wide()).layer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(options?.llm ?? LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(options?.plugin ?? Plugin.defaultLayer), - Layer.provide(status), - Layer.provide(events), - Layer.provide(options?.config ?? Config.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provide(EventV2Bridge.defaultLayer), - ) + const replacements: LayerNode.Replacements = [ + [Provider.node, (options?.provider ?? wide()).layer], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], + [SessionSummary.node, summary], + ] + if (!options?.llm) { + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) + } + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, + [LLM.node, options.llm], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) } function createSummaryCompaction(sessionID: SessionID) { @@ -318,7 +301,7 @@ function llm() { push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { queue.push(stream) }, - layer: Layer.succeed( + llmLayer: Layer.succeed( LLM.Service, LLM.Service.of({ stream: (input) => { @@ -614,9 +597,7 @@ describe("session.compaction.create", () => { }) const v2 = yield* SessionV2.Service.use((svc) => svc.messages({ sessionID: info.id })).pipe( - Effect.provide(SessionV2.defaultLayer), - Effect.provide(SessionExecution.noopLayer), - Effect.provide(locationServiceMapLayer), + Effect.provide(AppNodeBuilder.build(SessionV2.node, [[SessionExecution.node, SessionExecution.noopLayer]])), ) expect(v2.at(-1)).toMatchObject({ type: "compaction", @@ -1014,7 +995,7 @@ describe("session.compaction.process", () => { expect(part?.type).toBe("compaction") expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("yyyy") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 20 }) })) }, { git: true }, ) @@ -1051,7 +1032,7 @@ describe("session.compaction.process", () => { expect(part?.tail_start_id).toBeUndefined() expect(captured).toContain("recent image turn") expect(captured).toContain("Attached image/png: big.png") - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1102,7 +1083,7 @@ describe("session.compaction.process", () => { expect(filtered[1]?.info.role).toBe("assistant") expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true) expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) })) }, { git: true }, ) @@ -1263,7 +1244,7 @@ describe("session.compaction.process", () => { expect(Cause.hasInterrupts(exit.cause)).toBe(true) expect(Date.now() - start).toBeLessThan(250) } - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, { timeout: 10_000 }, @@ -1336,7 +1317,7 @@ describe("session.compaction.process", () => { expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false) // Sanity: the text part still got through. expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1372,7 +1353,7 @@ describe("session.compaction.process", () => { expect(summary?.info.role).toBe("assistant") expect(summary?.parts.some((part) => part.type === "tool")).toBe(false) - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1409,7 +1390,7 @@ describe("session.compaction.process", () => { expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1451,7 +1432,7 @@ describe("session.compaction.process", () => { expect(captured.match(/summary one/g)?.length).toBe(1) expect(captured).toContain("## Constraints & Preferences") expect(captured).toContain("## Progress") - }).pipe(withCompaction({ llm: stub.layer })) + }).pipe(withCompaction({ llm: stub.llmLayer })) }, { git: true }, ) @@ -1493,7 +1474,7 @@ describe("session.compaction.process", () => { expect( filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), ).toBe(true) - }).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })) }) itCompaction.instance( From 0ebe74b625591e666fb3a9415ff60b8a21c4ba44 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 15:55:58 -0400 Subject: [PATCH 037/303] refactor(opencode): migrate llm tests to layer nodes (#34479) --- .../agent/plugin-agent-regression.test.ts | 47 +++++++---------- .../test/control-plane/workspace.test.ts | 39 +++++++------- packages/opencode/test/plugin/trigger.test.ts | 36 +++++-------- .../test/session/llm-native-recorded.test.ts | 40 ++++----------- packages/opencode/test/session/llm.test.ts | 51 ++++++++----------- 5 files changed, 78 insertions(+), 135 deletions(-) diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index a9a26c722678..0249aaeb847f 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,16 +1,15 @@ import { expect } from "bun:test" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" -import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { Npm } from "@opencode-ai/core/npm" +import { Effect } from "effect" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" +import { Provider } from "../../src/provider/provider" +import { Skill } from "../../src/skill" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" @@ -18,6 +17,8 @@ import { ProviderTest } from "../fake/provider" import { SkillTest } from "../fake/skill" import { testEffect } from "../lib/effect" import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" // `it.instance` skips InstanceBootstrap so LSP / MCP don't spin up — those // services hang during scope teardown on Windows and aren't needed @@ -25,30 +26,16 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants" const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href const provider = ProviderTest.fake() -const configLayer = Config.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([Agent.node, Plugin.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [Provider.node, provider.layer], + [Skill.node, SkillTest.empty], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), ) -const pluginLayer = Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) -const agentLayer = Agent.layer.pipe( - Layer.provide(configLayer), - Layer.provide(AuthTest.empty), - Layer.provide(SkillTest.empty), - Layer.provide(provider.layer), - Layer.provide(pluginLayer), - Layer.provide(locationServiceMapLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), -) - -const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer)) it.instance( "plugin-registered agents appear in Agent.list", diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 0b680bc91ad6..36385ca56b2e 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -5,9 +5,8 @@ import Http from "node:http" import path from "node:path" import { NodeHttpServer } from "@effect/platform-node" import { Effect, Exit, Fiber, Layer, Schema } from "effect" -import { FetchHttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { eq } from "drizzle-orm" -import { FSUtil } from "@opencode-ai/core/fs-util" import { GlobalBus, type GlobalEvent } from "@/bus/global" import { Database } from "@opencode-ai/core/database/database" import { ProjectV2 } from "@opencode-ai/core/project" @@ -16,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Session as SessionNs } from "@/session/session" import { SessionID } from "@/session/schema" import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { EventSequenceTable } from "@opencode-ai/core/event/sql" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideTmpdirInstance, requireInstance, TestInstance } from "../fixture/fixture" @@ -27,13 +27,10 @@ import type { Target, WorkspaceAdapter, WorkspaceInfo } from "../../src/control- import * as Workspace from "../../src/control-plane/workspace" import { InstanceStore } from "@/project/instance-store" import { InstanceBootstrap } from "@/project/bootstrap" -import { Auth } from "@/auth" -import { SessionPrompt } from "@/session/prompt" -import { Project } from "@/project/project" -import { Vcs } from "@/project/vcs" import { RuntimeFlags } from "@/effect/runtime-flags" -import { EventV2Bridge } from "@/event-v2-bridge" import { Ripgrep } from "@opencode-ai/core/ripgrep" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" const originalEnv = { OPENCODE_AUTH_CONTENT: process.env.OPENCODE_AUTH_CONTENT, @@ -44,26 +41,24 @@ const originalEnv = { } const workspaceLayer = (experimentalWorkspaces: boolean) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(SessionNs.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer))), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + SessionNs.node, + SessionProjector.node, + Database.node, + InstanceStore.node, + Ripgrep.node, + ]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + ], ) const testServerLayer = Layer.mergeAll( NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }), workspaceLayer(true), - SessionNs.defaultLayer, - Database.defaultLayer, ) const it = testEffect(testServerLayer) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 7bd9e33527f3..07fd8e2f21bc 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,14 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { FSUtil } from "@opencode-ai/core/fs-util" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Npm } from "@opencode-ai/core/npm" import path from "path" import { pathToFileURL } from "url" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Config } from "../../src/config/config" -import { Env } from "../../src/env" +import { Account } from "../../src/account/account" +import { Auth } from "../../src/auth" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" @@ -19,25 +16,16 @@ import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const configLayer = Config.layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(AuthTest.empty), - Layer.provide(AccountTest.empty), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), -) const it = testEffect( - Layer.mergeAll( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(configLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })), - ), - CrossSpawnSpawner.defaultLayer, - ), + AppNodeBuilder.build(LayerNode.group([Plugin.node, CrossSpawnSpawner.node]), [ + [Auth.node, AuthTest.empty], + [Account.node, AccountTest.empty], + [Npm.node, NpmTest.noop], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true })], + ]), ) const systemHook = "experimental.chat.system.transform" diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index d17d7f8e5ab7..7ca09910bf80 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,6 +1,5 @@ import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { FSUtil } from "@opencode-ai/core/fs-util" import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder } from "@opencode-ai/http-recorder" import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal" @@ -10,14 +9,11 @@ import { Effect, Layer, Option, Schema, Stream } from "effect" import path from "node:path" import z from "zod" import { Auth } from "@/auth" -import { Config } from "@/config/config" -import { Plugin } from "@/plugin" import { Provider } from "@/provider/provider" import { Filesystem } from "@/util/filesystem" import { LLMEvent, LLMResponse } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Env } from "@/env" +import { RequestExecutor } from "@opencode-ai/llm/route" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" @@ -26,6 +22,9 @@ import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") @@ -240,7 +239,7 @@ const redactRecordedBody = (body: string) => function authLayer(scenario: RecordedScenario) { const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth - if (!replayAuth) return Auth.defaultLayer + if (!replayAuth) return undefined return Layer.mock(Auth.Service)({ get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined), all: () => Effect.succeed({ [scenario.providerID]: replayAuth }), @@ -262,15 +261,6 @@ const modelsFixture = Filesystem.readJson>( function recordedNativeLLMLayer(scenario: RecordedScenario) { const auth = authLayer(scenario) - const provider = Provider.layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(auth), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const metadata = { provider: scenario.providerID, @@ -290,21 +280,11 @@ function recordedNativeLLMLayer(scenario: RecordedScenario) { redactor: HttpRecorderInternal.Redactor.make(redact), }) : HttpRecorder.http(scenario.cassette, { directory: FIXTURES_DIR, metadata, redact }) - const recordedClient = LLMClient.layer.pipe( - Layer.provide(Layer.mergeAll(RequestExecutor.layer.pipe(Layer.provide(recordedHttp)), WebSocketExecutor.layer)), - ) - - return Layer.mergeAll( - provider, - LLM.layer.pipe( - Layer.provide(auth), - Layer.provide(Config.defaultLayer), - Layer.provide(provider), - Layer.provide(Plugin.defaultLayer), - Layer.provide(recordedClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })), - ), - ) + return AppNodeBuilder.build(LayerNode.group([Provider.node, LLM.node]), [ + [LayerNodePlatform.requestExecutor, RequestExecutor.layer.pipe(Layer.provide(recordedHttp))], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: true })], + ...(auth ? ([[Auth.node, auth]] as const) : []), + ]) } const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) => diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 868477a910de..beb41574c39f 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -9,13 +9,10 @@ import { InstanceRef } from "../../src/effect/instance-ref" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" import { LLM } from "../../src/session/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" -import { Auth } from "@/auth" -import { Config } from "@/config/config" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@opencode-ai/core/models-dev" -import { Plugin } from "@/plugin" import { testEffect } from "../lib/effect" import type { Agent } from "../../src/agent/agent" @@ -27,6 +24,9 @@ import { LLMAISDK } from "@/session/llm/ai-sdk" import { Session as SessionNs } from "@/session/session" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" type ConfigModel = NonNullable[string]["models"]>[string] @@ -52,15 +52,13 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: stri } } -const it = testEffect(Layer.mergeAll(LLM.defaultLayer, Provider.defaultLayer)) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([LLM.node, Provider.node]))) // LLM.stream returns a Stream, not an Effect, so we can't use the serviceUse proxy. const drain = (input: LLM.StreamInput) => LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)) -// drainWith builds an isolated runtime so the custom layer fully owns LLM and -// its transitive deps — `Effect.provide(layer)` over an existing runtime layers -// the new services on top, but transitive Service overrides (e.g. RequestExecutor) -// resolved through the outer LLM.defaultLayer leak through. +// drainWith builds an isolated runtime so custom replacements fully own LLM and +// its transitive deps. const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => Effect.gen(function* () { const ctx = yield* InstanceRef @@ -75,15 +73,14 @@ const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => ) }) -function llmLayerWithExecutor(executor: Layer.Layer, flags: Partial = {}) { - return LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))), - Layer.provide(RuntimeFlags.layer(flags)), - ) +function llmLayerWithExecutor(options: { + executor?: Layer.Layer + flags?: Partial +} = {}) { + return AppNodeBuilder.build(LLM.node, [ + [RuntimeFlags.node, RuntimeFlags.layer(options.flags)], + ...(options.executor ? ([[LayerNodePlatform.requestExecutor, options.executor]] as const) : []), + ]) } describe("session.llm.hasToolCalls", () => { @@ -1129,14 +1126,10 @@ describe("session.llm.stream", () => { } satisfies Agent.Info yield* drainWith( - LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(failingNativeClient), - Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })), - ), + AppNodeBuilder.build(LLM.node, [ + [LayerNodePlatform.llmClient, failingNativeClient], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalNativeLlm: false })], + ]), { user: { id: MessageID.make("msg_user-native-flag-off"), @@ -1199,7 +1192,7 @@ describe("session.llm.stream", () => { temperature: 0.2, } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native"), sessionID, @@ -1282,7 +1275,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ executor, flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-injected-tool"), sessionID, @@ -1371,7 +1364,7 @@ describe("session.llm.stream", () => { permission: [{ permission: "*", pattern: "*", action: "allow" }], } satisfies Agent.Info - yield* drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + yield* drainWith(llmLayerWithExecutor({ flags: { experimentalNativeLlm: true } }), { user: { id: MessageID.make("msg_user-native-tool"), sessionID, From 93a7b4ab7605f834981f98c112fd73e21f5fccf5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 19:58:09 +0000 Subject: [PATCH 038/303] chore: generate --- packages/opencode/test/control-plane/workspace.test.ts | 5 ++++- packages/opencode/test/session/llm.test.ts | 10 ++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/opencode/test/control-plane/workspace.test.ts b/packages/opencode/test/control-plane/workspace.test.ts index 36385ca56b2e..d33d4a1aa713 100644 --- a/packages/opencode/test/control-plane/workspace.test.ts +++ b/packages/opencode/test/control-plane/workspace.test.ts @@ -52,7 +52,10 @@ const workspaceLayer = (experimentalWorkspaces: boolean) => ]), [ [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], - [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], ], ) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index beb41574c39f..45b25a1a03cb 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -73,10 +73,12 @@ const drainWith = (layer: Layer.Layer, input: LLM.StreamInput) => ) }) -function llmLayerWithExecutor(options: { - executor?: Layer.Layer - flags?: Partial -} = {}) { +function llmLayerWithExecutor( + options: { + executor?: Layer.Layer + flags?: Partial + } = {}, +) { return AppNodeBuilder.build(LLM.node, [ [RuntimeFlags.node, RuntimeFlags.layer(options.flags)], ...(options.executor ? ([[LayerNodePlatform.requestExecutor, options.executor]] as const) : []), From aae0e89519367917909afbb0fea3027af2c31193 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 16:04:58 -0400 Subject: [PATCH 039/303] refactor(opencode): use layer nodes in plugin tests (#34495) --- .../test/plugin/auth-override.test.ts | 51 ++++++++----------- .../test/plugin/loader-shared.test.ts | 14 ++--- 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index dd659728eb6b..37d9068c26c7 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -2,46 +2,39 @@ import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" -import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Auth } from "@/auth" -import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Config } from "@/config/config" const it = testEffect(LayerNode.compile(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node]))) -function layer(directory: string, plugins: string[]) { - return ProviderAuth.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer()), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ - spec: plugin, - source: path.join(directory, "opencode.json"), - scope: "local" as const, - })), - }), - directories: () => Effect.succeed([directory]), +function providerAuthLayer(directory: string, plugins: string[]) { + return LayerNode.compile(ProviderAuth.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), }), - ), - ), - ), - ) + directories: () => Effect.succeed([directory]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer()], + ]) } describe("plugin.auth-override", () => { @@ -74,10 +67,10 @@ describe("plugin.auth-override", () => { const plain = yield* tmpdirScoped({ git: true }) const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href - const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(layer(tmp.directory, [plugin]))) + const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) const plainMethods = yield* ProviderAuth.use .methods() - .pipe(Effect.provide(layer(plain, [])), provideInstance(plain)) + .pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain)) const copilot = methods[ProviderV2.ID.make("github-copilot")] expect(copilot).toBeDefined() diff --git a/packages/opencode/test/plugin/loader-shared.test.ts b/packages/opencode/test/plugin/loader-shared.test.ts index b30f6d15cb3e..005a6e78cb55 100644 --- a/packages/opencode/test/plugin/loader-shared.test.ts +++ b/packages/opencode/test/plugin/loader-shared.test.ts @@ -6,13 +6,13 @@ import path from "path" import { pathToFileURL } from "url" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Config } from "@/config/config" import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" const { Plugin } = await import("../../src/plugin/index") const { PluginLoader } = await import("../../src/plugin/loader") const { readPackageThemes } = await import("../../src/plugin/shared") -const { EventV2Bridge } = await import("../../src/event-v2-bridge") const { Npm } = await import("@opencode-ai/core/npm") const { TestConfig } = await import("../fixture/config") const { RuntimeFlags } = await import("../../src/effect/runtime-flags") @@ -48,10 +48,9 @@ function load(dir: string, flags?: Parameters[0]) { yield* plugin.list() }).pipe( Effect.provide( - Plugin.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })), - Layer.provide( + LayerNode.compile(Plugin.node, [ + [ + Config.node, TestConfig.layer({ get: () => Effect.succeed({ @@ -60,8 +59,9 @@ function load(dir: string, flags?: Parameters[0]) { }), directories: () => Effect.succeed([dir]), }), - ), - ), + ], + [RuntimeFlags.node, RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })], + ]), ), provideInstance(dir), ) From 4d3294727c9a4df576e7fe4c95bc4b3a12cf4ec0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 20:07:10 +0000 Subject: [PATCH 040/303] chore: generate --- packages/opencode/test/plugin/auth-override.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index 37d9068c26c7..ca8a2ff69a17 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -67,7 +67,9 @@ describe("plugin.auth-override", () => { const plain = yield* tmpdirScoped({ git: true }) const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href - const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) + const methods = yield* ProviderAuth.use + .methods() + .pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) const plainMethods = yield* ProviderAuth.use .methods() .pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain)) From 15bcbb1d7aa63fe18a4c02f90f9f57a57977c4b3 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 16:22:50 -0400 Subject: [PATCH 041/303] refactor(opencode): migrate session tests to layer nodes (#34494) --- packages/opencode/test/mcp/auth.test.ts | 17 +++++----- .../opencode/test/permission/next.test.ts | 14 ++++----- .../opencode/test/session/instruction.test.ts | 31 +++++++++++-------- .../opencode/test/session/session.test.ts | 26 +++++++--------- 4 files changed, 43 insertions(+), 45 deletions(-) diff --git a/packages/opencode/test/mcp/auth.test.ts b/packages/opencode/test/mcp/auth.test.ts index 7ad8c4826c95..5aa099983a31 100644 --- a/packages/opencode/test/mcp/auth.test.ts +++ b/packages/opencode/test/mcp/auth.test.ts @@ -1,9 +1,8 @@ import { expect, test } from "bun:test" import { setTimeout as sleep } from "node:timers/promises" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { McpAuth } from "../../src/mcp/auth" function authFile() { @@ -11,7 +10,7 @@ function authFile() { let activeWrites = 0 let sawOverlap = false - const layer = Layer.effect( + const fsLayer = Layer.effect( FSUtil.Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -42,14 +41,14 @@ function authFile() { : fs.writeJson(file, value, mode), }) }), - ).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) + ).pipe(Layer.provide(AppNodeBuilder.build(FSUtil.node))) - return { layer, raw: () => raw } + return { fsLayer, raw: () => raw } } -function authService(layer: Layer.Layer) { +function authService(fsLayer: Layer.Layer) { return McpAuth.Service.use((auth) => Effect.succeed(auth)).pipe( - Effect.provide(LayerNode.compile(McpAuth.node, [[FSUtil.node, layer]])), + Effect.provide(AppNodeBuilder.build(McpAuth.node, [[FSUtil.node, fsLayer]])), ) } @@ -58,8 +57,8 @@ test("serializes concurrent auth file updates across service instances", async ( await Effect.runPromise( Effect.gen(function* () { - const first = yield* authService(file.layer) - const second = yield* authService(file.layer) + const first = yield* authService(file.fsLayer) + const second = yield* authService(file.fsLayer) yield* Effect.all( [ diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index e784350055a1..ec2a641667b5 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -4,21 +4,19 @@ import os from "os" import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" import { EventV2Bridge } from "../../src/event-v2-bridge" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { Database } from "@opencode-ai/core/database/database" import { Permission } from "../../src/permission" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { TestInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" -const events = EventV2Bridge.defaultLayer const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const env = Layer.mergeAll( - Permission.layer.pipe(Layer.provide(Database.defaultLayer), Layer.provide(events)), - events, - CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), +const env = AppNodeBuilder.build( + LayerNode.group([Permission.node, EventV2Bridge.node, CrossSpawnSpawner.node, InstanceStore.node]), + [[InstanceBootstrap.node, noopBootstrap]], ) const it = testEffect(env) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index 53ccf06e120a..f2ec95c61839 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -2,34 +2,39 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import path from "path" import { Effect, FileSystem, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { NodeFileSystem } from "@effect/platform-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Instruction } from "../../src/session/instruction" import type { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { Global } from "@opencode-ai/core/global" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { provideInstance, provideTmpdirInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { TestConfig } from "../fixture/config" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer, testInstanceStoreLayer)) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem, InstanceStore.node]), [ + [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + ]), +) -const configLayer = TestConfig.layer() +const configLayer = Layer.succeed(Config.Service, TestConfig.make()) const instructionLayer = (global: Partial, flags: Partial = {}) => - Instruction.layer.pipe( - Layer.provide(configLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Global.layerWith(global)), - Layer.provide(RuntimeFlags.layer(flags)), - ) + AppNodeBuilder.build(Instruction.node, [ + [Config.node, configLayer], + [Global.node, Global.layerWith(global)], + [RuntimeFlags.node, RuntimeFlags.layer(flags)], + ]) const provideInstruction = (global: Partial, flags?: Partial) => diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index c82f713d2b0f..a839e6da30cd 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" -import { Database } from "@opencode-ai/core/database/database" import { EventV2 } from "@opencode-ai/core/event" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Deferred, Effect, Exit, Layer } from "effect" @@ -8,26 +7,23 @@ import { Session as SessionNs } from "@/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, PartID, type SessionID } from "../../src/session/schema" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" +import { provideInstance, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" import { EventV2Bridge } from "@/event-v2-bridge" import { GlobalBus } from "@/bus/global" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { InstanceStore } from "@/project/instance-store" +import { InstanceBootstrap } from "@/project/bootstrap" const it = testEffect( - Layer.mergeAll( - SessionNs.layer.pipe( - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provideMerge(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })), - Layer.provide(BackgroundJob.defaultLayer), - ), - CrossSpawnSpawner.defaultLayer, - testInstanceStoreLayer, + AppNodeBuilder.build( + LayerNode.group([SessionNs.node, EventV2Bridge.node, SessionProjector.node, CrossSpawnSpawner.node, InstanceStore.node]), + [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], + [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + ], ), ) From 9151af70454b1c93c7b547e6815917fe7eccd176 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 20:24:42 +0000 Subject: [PATCH 042/303] chore: generate --- packages/opencode/test/session/instruction.test.ts | 5 ++++- packages/opencode/test/session/session.test.ts | 13 +++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts index f2ec95c61839..cdbc6e66d70d 100644 --- a/packages/opencode/test/session/instruction.test.ts +++ b/packages/opencode/test/session/instruction.test.ts @@ -23,7 +23,10 @@ import { Config } from "@/config/config" const it = testEffect( AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, LayerNodePlatform.filesystem, InstanceStore.node]), [ - [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], ]), ) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index a839e6da30cd..d10918198649 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -19,10 +19,19 @@ import { InstanceBootstrap } from "@/project/bootstrap" const it = testEffect( AppNodeBuilder.build( - LayerNode.group([SessionNs.node, EventV2Bridge.node, SessionProjector.node, CrossSpawnSpawner.node, InstanceStore.node]), + LayerNode.group([ + SessionNs.node, + EventV2Bridge.node, + SessionProjector.node, + CrossSpawnSpawner.node, + InstanceStore.node, + ]), [ [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })], - [InstanceBootstrap.node, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))], + [ + InstanceBootstrap.node, + Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), + ], ], ), ) From 7a035d7fc0cc0168f1c675ba2a1924b6d5876728 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 16:35:10 -0400 Subject: [PATCH 043/303] refactor(opencode): bind instance bootstrap node (#34502) --- packages/opencode/src/acp/directory.ts | 3 ++- packages/opencode/src/acp/usage.ts | 3 ++- packages/opencode/src/control-plane/workspace.ts | 10 ++++++++-- packages/opencode/src/effect/app-runtime.ts | 6 ++++-- packages/opencode/src/project/bootstrap.ts | 4 ++-- packages/opencode/src/project/instance-layer.ts | 11 ----------- packages/opencode/src/project/instance-store.ts | 8 +++++--- .../src/server/routes/instance/httpapi/server.ts | 3 ++- packages/opencode/src/worktree/index.ts | 6 ++++-- .../opencode/test/control-plane/workspace.test.ts | 2 +- packages/opencode/test/fixture/fixture.ts | 3 ++- packages/opencode/test/fixture/workspace.ts | 4 ++-- packages/opencode/test/permission/next.test.ts | 2 +- .../opencode/test/plugin/workspace-adapter.test.ts | 3 +-- .../test/project/instance-bootstrap.test.ts | 9 +++++++-- packages/opencode/test/project/instance.test.ts | 2 +- .../opencode/test/project/worktree-remove.test.ts | 4 +++- packages/opencode/test/project/worktree.test.ts | 8 +++++++- packages/opencode/test/provider/provider.test.ts | 10 ++++++---- .../test/server/httpapi-instance-context.test.ts | 6 ++++-- .../test/server/httpapi-promptasync-context.test.ts | 6 ++++-- packages/opencode/test/server/httpapi-sdk.test.ts | 3 ++- .../opencode/test/server/httpapi-session.test.ts | 13 +++++++------ .../opencode/test/server/httpapi-workspace.test.ts | 6 +++--- packages/opencode/test/server/project-copy.test.ts | 2 +- .../opencode/test/server/project-init-git.test.ts | 2 +- 26 files changed, 82 insertions(+), 57 deletions(-) delete mode 100644 packages/opencode/src/project/instance-layer.ts diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index 282020aa81a7..ed6270debd3d 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -1,6 +1,7 @@ import { Agent } from "@/agent/agent" import { Command } from "@/command" import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ProviderV2 } from "@opencode-ai/core/provider" @@ -205,7 +206,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Provider.defaultLayer), Layer.provide(Agent.defaultLayer), Layer.provide(Command.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), + Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), ) export const loaderNode = LayerNode.make({ diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index f717d142cf42..32b46e8b9187 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -1,6 +1,7 @@ import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk" import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2" import { InstanceRef } from "@/effect/instance-ref" +import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -228,7 +229,7 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(contextLimitLoaderLayer), Layer.provide(Provider.defaultLayer), - Layer.provide(InstanceStore.defaultLayer), + Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), ) export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 9fdfba01c740..51d808d29e42 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -601,7 +601,11 @@ export const layer = Layer.effect( }), fallback: "", response: "text", - }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + }).pipe( + Effect.provide( + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), + ), + ) : "" if (sourcePatch) { @@ -617,7 +621,9 @@ export const layer = Layer.effect( body: HttpBody.jsonUnsafe({ patch: sourcePatch }), }), fallback: { applied: false }, - }).pipe(Effect.provide(InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)))) + }).pipe( + Effect.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), + ) } if (input.workspaceID === null) { diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880a4..a31ff70ea1b3 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -38,7 +38,8 @@ import { Command } from "@/command" import { Truncate } from "@/tool/truncate" import { ToolRegistry } from "@/tool/registry" import { Format } from "@/format" -import { InstanceLayer } from "@/project/instance-layer" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" import { Workspace } from "@/control-plane/workspace" @@ -51,6 +52,7 @@ import { memoMap } from "@opencode-ai/core/effect/memo-map" import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, @@ -101,7 +103,7 @@ export const AppLayer = Layer.mergeAll( SessionShare.defaultLayer, ).pipe( Layer.provideMerge(Ripgrep.defaultLayer), - Layer.provideMerge(InstanceLayer.layer), + Layer.provideMerge(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 0cabf446a583..2e32b4b11c36 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -1,4 +1,4 @@ -import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { Plugin } from "../plugin" import { Format } from "../format" import { LSP } from "@/lsp/lsp" @@ -62,7 +62,7 @@ export const defaultLayer: Layer.Layer = layer.pipe( ]), ) -export const node = LayerNode.make({ +export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Config.node, Format.node, LSP.node, Plugin.node, Project.node, ShareNext.node, Snapshot.node, Vcs.node], diff --git a/packages/opencode/src/project/instance-layer.ts b/packages/opencode/src/project/instance-layer.ts deleted file mode 100644 index a7e2bfcb7b62..000000000000 --- a/packages/opencode/src/project/instance-layer.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Effect, Layer } from "effect" -import { InstanceStore } from "./instance-store" - -export const layer = Layer.unwrap( - Effect.promise(async () => { - const { InstanceBootstrap } = await import("./bootstrap") - return InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)) - }), -) - -export * as InstanceLayer from "./instance-layer" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 6877fe56817e..c581c0a9f227 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,4 +1,5 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { makeGlobalNode, Node } from "@opencode-ai/core/effect/app-node" import { GlobalBus } from "@/bus/global" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { WorkspaceContext } from "@/control-plane/workspace-context" @@ -8,7 +9,6 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Context, Deferred, Duration, Effect, Exit, Layer, Scope } from "effect" import { type InstanceContext } from "./instance-context" import { InstanceBootstrap } from "./bootstrap-service" -import { InstanceBootstrap as InstanceBootstrapGraph } from "./bootstrap" import * as Project from "./project" export interface LoadInput { @@ -204,10 +204,12 @@ export const layer: Layer.Layer [ [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], [ - InstanceBootstrap.node, + InstanceStore.bootstrapNode, Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })), ], ], diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index f9898ede0d7d..22e7f9c67695 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -9,6 +9,7 @@ import type * as Scope from "effect/Scope" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" import type { InstanceContext } from "../../src/project/instance-context" @@ -17,7 +18,7 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -export const testInstanceStoreLayer = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)) +export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) export async function provideTestInstance(input: { directory: string diff --git a/packages/opencode/test/fixture/workspace.ts b/packages/opencode/test/fixture/workspace.ts index 46335d33615b..11fa48d5d55e 100644 --- a/packages/opencode/test/fixture/workspace.ts +++ b/packages/opencode/test/fixture/workspace.ts @@ -1,5 +1,6 @@ import { FetchHttpClient } from "effect/unstable/http" import { Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Auth } from "../../src/auth" @@ -25,6 +26,5 @@ export const workspaceLayerWithRuntimeFlags = (overrides: Partial { diff --git a/packages/opencode/test/project/worktree.test.ts b/packages/opencode/test/project/worktree.test.ts index f0a72c4b485e..e2a7e747232f 100644 --- a/packages/opencode/test/project/worktree.test.ts +++ b/packages/opencode/test/project/worktree.test.ts @@ -5,11 +5,17 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { Cause, Deferred, Effect, Exit, Fiber } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]))) +const it = testEffect( + LayerNode.compile(LayerNode.group([Worktree.node, FSUtil.node, Git.node]), [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + ]), +) const wintest = process.platform !== "win32" ? it.instance : it.instance.skip function normalize(input: string) { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 3fc948acddbc..3fd354eea2dc 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -17,7 +17,8 @@ import { Provider } from "@/provider/provider" import { RuntimeFlags } from "@/effect/runtime-flags" import { Filesystem } from "@/util/filesystem" -import { InstanceLayer } from "@/project/instance-layer" +import { InstanceBootstrap } from "@/project/bootstrap" +import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -1760,8 +1761,9 @@ it.instance( // Tests that need plugin file setup or multi-instance flows fall back to a // scoped tmpdir + provideInstance pattern via it.effect. +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]) const provideMultiInstance = (eff: Effect.Effect) => - eff.pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) it.effect("plugin config providers persist after instance dispose", () => Effect.gen(function* () { @@ -1864,7 +1866,7 @@ it.effect("opencode loader keeps paid models when config apiKey is present", () Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) const none = paid(yield* listIn(noneDir)) const keyedCount = paid(yield* listIn(keyedDir)) @@ -1883,7 +1885,7 @@ it.effect("opencode loader keeps paid models when auth exists", () => Provider.use .list() .pipe(provideInstanceEffect(directory)) - .pipe(Effect.provide(InstanceLayer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)) + .pipe(Effect.provide(instanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) const none = paid(yield* listIn(noneDir)) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index d2977b147f22..07c722ff4393 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,6 +1,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -12,7 +13,8 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" @@ -51,7 +53,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceLayer.layer, + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), Project.defaultLayer, workspaceLayer, ).pipe(Layer.provide(Ripgrep.defaultLayer)), diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index eb864077160c..8775cd1f7040 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -10,6 +10,7 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Scope } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Stream from "effect/Stream" import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" @@ -20,7 +21,8 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceLayer } from "../../src/project/instance-layer" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { @@ -56,7 +58,7 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - InstanceLayer.layer, + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), Project.defaultLayer, workspaceLayer, ).pipe(Layer.provide(Ripgrep.defaultLayer)), diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 63cc3edb2c18..79fdd41e6d72 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -7,6 +7,7 @@ import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { validateSession } from "../../src/cli/tui/validate-session" @@ -34,7 +35,7 @@ const it = testEffect( Layer.mergeAll( FSUtil.defaultLayer, CrossSpawnSpawner.defaultLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)), + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]), Database.defaultLayer, httpApiLayer, ), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 828ed3373a97..6515872fd826 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -9,6 +9,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServ import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -38,14 +39,14 @@ import { pollWithTimeout, testEffect } from "../lib/effect" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), + Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), ) -const instanceStoreLayer = InstanceStore.defaultLayer.pipe( - Layer.provide( +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [ + InstanceStore.bootstrapNode, Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), - ), -) + ], +]) const servedRoutes: Layer.Layer = HttpRouter.serve( HttpApiApp.routes, { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 6282572b109a..4a19f6b75dc8 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -3,6 +3,7 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer, Stream } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -24,15 +25,14 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(InstanceStore.defaultLayer), - Layer.provide(InstanceBootstrap.defaultLayer), + Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), ) const it = testEffect( Layer.mergeAll( Project.defaultLayer, Session.defaultLayer, workspaceLayer, - InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)), + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), Database.defaultLayer, httpApiLayer, ).pipe(Layer.provide(Ripgrep.defaultLayer)), diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 4c40908f1fc4..83f5173ed2af 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -21,7 +21,7 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) +const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( Layer.mergeAll( LayerNode.compile(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index dc5f92cd6e5a..f469d8bb04da 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -20,7 +20,7 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceBootstrap.node, noopBootstrap]]) +const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), From 7d33a6f7c9fff5202f77d4294b1d387a4f1f2563 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 20:36:56 +0000 Subject: [PATCH 044/303] chore: generate --- packages/opencode/src/control-plane/workspace.ts | 4 +++- packages/opencode/test/fixture/fixture.ts | 4 +++- packages/opencode/test/provider/provider.test.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 51d808d29e42..e5952e12e5df 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -622,7 +622,9 @@ export const layer = Layer.effect( }), fallback: { applied: false }, }).pipe( - Effect.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), + Effect.provide( + LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), + ), ) } diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index 22e7f9c67695..fc5a253b154f 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -18,7 +18,9 @@ import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) +export const testInstanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, noopBootstrap], +]) export async function provideTestInstance(input: { directory: string diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 3fd354eea2dc..82c9f10c5e92 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1761,7 +1761,9 @@ it.instance( // Tests that need plugin file setup or multi-instance flows fall back to a // scoped tmpdir + provideInstance pattern via it.effect. -const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]) +const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], +]) const provideMultiInstance = (eff: Effect.Effect) => eff.pipe(Effect.provide(instanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) From 3726052307a869a64922ff10e71b16cc2206d64e Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 16:50:59 -0400 Subject: [PATCH 045/303] refactor(opencode): use layer nodes in server tests (#34503) --- packages/opencode/test/fixture/workspace.ts | 32 +++++++++++-------- .../server/httpapi-instance-context.test.ts | 8 +---- .../httpapi-promptasync-context.test.ts | 8 +---- .../opencode/test/server/httpapi-sdk.test.ts | 22 ++++++------- .../test/server/httpapi-session.test.ts | 32 +++++++------------ .../opencode/test/server/httpapi-ui.test.ts | 25 ++++++++------- .../server/httpapi-workspace-routing.test.ts | 5 +-- .../test/server/httpapi-workspace.test.ts | 24 +++++++------- .../opencode/test/server/project-copy.test.ts | 5 +-- .../test/server/project-init-git.test.ts | 5 +-- .../opencode/test/server/session-list.test.ts | 21 ++++-------- 11 files changed, 83 insertions(+), 104 deletions(-) diff --git a/packages/opencode/test/fixture/workspace.ts b/packages/opencode/test/fixture/workspace.ts index 11fa48d5d55e..cf7585571590 100644 --- a/packages/opencode/test/fixture/workspace.ts +++ b/packages/opencode/test/fixture/workspace.ts @@ -1,5 +1,4 @@ -import { FetchHttpClient } from "effect/unstable/http" -import { Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -15,16 +14,21 @@ import { SessionPrompt } from "../../src/session/prompt" import { EventV2Bridge } from "../../src/event-v2-bridge" export const workspaceLayerWithRuntimeFlags = (overrides: Partial) => - Workspace.layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(RuntimeFlags.layer(overrides)), - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), + AppNodeBuilder.build( + LayerNode.group([ + Workspace.node, + Auth.node, + Session.node, + SessionPrompt.node, + Project.node, + Vcs.node, + Database.node, + EventV2Bridge.node, + FSUtil.node, + InstanceStore.node, + ]), + [ + [InstanceStore.bootstrapNode, InstanceBootstrap.node], + [RuntimeFlags.node, RuntimeFlags.layer(overrides)], + ], ) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 07c722ff4393..6a758cc41c47 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -1,7 +1,6 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Effect, Fiber, Layer, Schema } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -9,12 +8,9 @@ import { mkdir } from "node:fs/promises" import path from "node:path" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap" -import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle" @@ -53,10 +49,8 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), - Project.defaultLayer, workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + ), ) const instanceContextTestLayer = Layer.mergeAll( diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index 8775cd1f7040..c56ebcf3c18f 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -10,19 +10,15 @@ import { NodeHttpServer, NodeServices } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer, Schema, Scope } from "effect" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Stream from "effect/Stream" import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http" import * as Socket from "effect/unstable/socket/Socket" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi" import { mkdir } from "node:fs/promises" import { registerAdapter } from "../../src/control-plane/adapters" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref" -import { InstanceBootstrap } from "../../src/project/bootstrap" -import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { @@ -58,10 +54,8 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), - Project.defaultLayer, workspaceLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + ), ) const instanceContextTestLayer = Layer.mergeAll( diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 79fdd41e6d72..8fcd348b5bf9 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -5,13 +5,14 @@ import { Deferred, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" import { HttpServer } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { validateSession } from "../../src/cli/tui/validate-session" -import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -30,15 +31,13 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Database } from "@opencode-ai/core/database/database" import { httpApiLayer } from "./httpapi-layer" -const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const appLayer = AppNodeBuilder.build( + LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], +) const it = testEffect( - Layer.mergeAll( - FSUtil.defaultLayer, - CrossSpawnSpawner.defaultLayer, - LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]), - Database.defaultLayer, - httpApiLayer, - ), + Layer.mergeAll(appLayer, httpApiLayer), ) const original = { @@ -56,6 +55,7 @@ type TestServices = | FSUtil.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service + | SessionNs.Service | HttpServer.HttpServer type TestScope = Scope.Scope | TestServices @@ -324,7 +324,7 @@ function seedMessage(directory: string, sessionID: string) { }) return { message, part } }), - ).pipe(Effect.provide(SessionNs.defaultLayer)), + ), ), ) } diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 6515872fd826..ae7f4e7ee8c6 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -7,15 +7,15 @@ import path from "node:path" import { Cause, Config, Effect, Exit, Layer } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http" import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Flag } from "@opencode-ai/core/flag/flag" -import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { registerAdapter } from "../../src/control-plane/adapters" import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" -import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service" import { InstanceStore } from "../../src/project/instance-store" import { Project } from "../../src/project/project" @@ -38,15 +38,14 @@ import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), +const noopBootstrapLayer = Layer.succeed( + InstanceBootstrapService.Service, + InstanceBootstrapService.Service.of({ run: Effect.void }), +) +const appLayer = AppNodeBuilder.build( + LayerNode.group([InstanceStore.node, Project.node, Session.node, Workspace.node, Database.node, Ripgrep.node]), + [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) -const instanceStoreLayer = LayerNode.compile(InstanceStore.node, [ - [ - InstanceStore.bootstrapNode, - Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })), - ], -]) const servedRoutes: Layer.Layer = HttpRouter.serve( HttpApiApp.routes, { @@ -60,14 +59,7 @@ const httpApiLayer = servedRoutes.pipe( Layer.provideMerge(NodeServices.layer), ) const it = testEffect( - Layer.mergeAll( - instanceStoreLayer, - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + Layer.mergeAll(appLayer, httpApiLayer), ) function pathFor(path: string, params: Record) { @@ -433,7 +425,7 @@ describe("session HttpApi", () => { cwd: sessionDirectory, root: sessionDirectory, }) - }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + }).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))), ) it.instance( @@ -869,7 +861,7 @@ describe("session HttpApi", () => { pathSession: yield* createSession(), pathlessSession: yield* createSession(), } - }).pipe(Effect.provideService(TestInstance, { directory: currentDir }), Effect.provide(Session.defaultLayer)), + }).pipe(Effect.provideService(TestInstance, { directory: currentDir })), ) yield* clearSessionPath(pathlessSession.id) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index 43d1d30a94b5..e90220817dab 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,7 +1,8 @@ import { createHash } from "node:crypto" import { describe, expect } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { ConfigProvider, Effect, Layer } from "effect" +import { ConfigProvider, Effect, Layer, Option } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { HttpClient, HttpClientRequest, @@ -39,7 +40,15 @@ const testStateLayer = Layer.effectDiscard( }), ) -const it = testEffect(Layer.mergeAll(testStateLayer, FSUtil.defaultLayer, RuntimeFlags.layer())) +const fsUtilLayer = AppNodeBuilder.build(FSUtil.node) +const it = testEffect(Layer.mergeAll(testStateLayer, fsUtilLayer, RuntimeFlags.layer())) + +function authConfigLayer(input?: { password?: string; username?: string }) { + return ServerAuth.Config.layer({ + password: input?.password === undefined ? Option.none() : Option.some(input.password), + username: input?.username ?? "opencode", + }) +} function restoreEnv(key: string, value: string | undefined) { if (value === undefined) { @@ -94,18 +103,12 @@ function uiApp(input?: { ) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer(input)))), Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, input?.client ?? httpClient(new Response("ui")), RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }), HttpServer.layerServices, - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), ]), ), { disableLogger: true }, @@ -141,7 +144,7 @@ function routeOrderingApp() { }), ).pipe( Layer.provide([ - FSUtil.defaultLayer, + fsUtilLayer, RuntimeFlags.layer({ disableEmbeddedWebUi: true }), httpClient(new Response("ui"), (request) => { proxiedUrl = request.url diff --git a/packages/opencode/test/server/httpapi-workspace-routing.test.ts b/packages/opencode/test/server/httpapi-workspace-routing.test.ts index 6b2474476779..2f6195a87ebc 100644 --- a/packages/opencode/test/server/httpapi-workspace-routing.test.ts +++ b/packages/opencode/test/server/httpapi-workspace-routing.test.ts @@ -21,7 +21,6 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types" import { Workspace } from "../../src/control-plane/workspace" import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql" import { Database } from "@opencode-ai/core/database/database" -import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Project } from "../../src/project/project" import { Session } from "../../src/session/session" import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace" @@ -55,11 +54,9 @@ const it = testEffect( testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, - Database.defaultLayer, - Project.defaultLayer, workspaceLayer, Socket.layerWebSocketConstructorGlobal, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + ), ) type ProxiedRequest = { diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 4a19f6b75dc8..33f3920c2d6b 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -2,8 +2,9 @@ import { afterEach, describe, expect, mock } from "bun:test" import { mkdir } from "node:fs/promises" import path from "node:path" import { Effect, Layer, Stream } from "effect" -import { Flag } from "@opencode-ai/core/flag/flag" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Flag } from "@opencode-ai/core/flag/flag" import { registerAdapter } from "../../src/control-plane/adapters" import { WorkspaceV2 } from "@opencode-ai/core/workspace" import type { WorkspaceAdapter } from "../../src/control-plane/types" @@ -24,18 +25,19 @@ import { testEffect } from "../lib/effect" import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES -const workspaceLayer = Workspace.defaultLayer.pipe( - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), +const appLayer = AppNodeBuilder.build( + LayerNode.group([ + Project.node, + Session.node, + Workspace.node, + InstanceStore.node, + Database.node, + Ripgrep.node, + ]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ) const it = testEffect( - Layer.mergeAll( - Project.defaultLayer, - Session.defaultLayer, - workspaceLayer, - LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), - Database.defaultLayer, - httpApiLayer, - ).pipe(Layer.provide(Ripgrep.defaultLayer)), + Layer.mergeAll(appLayer, httpApiLayer), ) function request(path: string, directory: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/project-copy.test.ts b/packages/opencode/test/server/project-copy.test.ts index 83f5173ed2af..0f783622a6cc 100644 --- a/packages/opencode/test/server/project-copy.test.ts +++ b/packages/opencode/test/server/project-copy.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test" import { $ } from "bun" import fs from "fs/promises" import path from "path" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { HttpClientResponse } from "effect/unstable/http" @@ -21,10 +22,10 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( Layer.mergeAll( - LayerNode.compile(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), + AppNodeBuilder.build(LayerNode.group([FSUtil.node, Database.node, Snapshot.node])), testInstanceStore, httpApiLayer, ), diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts index f469d8bb04da..4474bea7d416 100644 --- a/packages/opencode/test/server/project-init-git.test.ts +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { Effect, Layer } from "effect" @@ -20,10 +21,10 @@ afterEach(async () => { }) const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) -const testInstanceStore = LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) +const testInstanceStore = AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]) const it = testEffect( - Layer.mergeAll(LayerNode.compile(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), + Layer.mergeAll(AppNodeBuilder.build(LayerNode.group([FSUtil.node, Snapshot.node])), testInstanceStore, httpApiLayer), ) function request(directory: string, url: string, init: RequestInit = {}) { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 213e3cdce3cc..3d0e0ab12f4c 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Database } from "@opencode-ai/core/database/database" import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session as SessionNs } from "@/session/session" @@ -9,23 +11,12 @@ import path from "path" import { SessionTable } from "@opencode-ai/core/session/sql" import { eq } from "drizzle-orm" import { testEffect } from "../lib/effect" -import { EventV2Bridge } from "@/event-v2-bridge" -import { Storage } from "@/storage/storage" import { RuntimeFlags } from "@/effect/runtime-flags" -import { BackgroundJob } from "@/background/job" const layer = (experimentalWorkspaces: boolean) => - Layer.mergeAll( - Database.defaultLayer, - SessionNs.layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionProjector.defaultLayer), - Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })), - Layer.provide(BackgroundJob.defaultLayer), - ), + AppNodeBuilder.build( + LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), + [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })]], ) const it = testEffect(layer(false)) const itWorkspaces = testEffect(layer(true)) From fd213e6df6ba050b83a31d4ae9427c7099de3205 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:51:52 -0500 Subject: [PATCH 046/303] fix(mcp): prefer content over structured output (#34505) --- packages/opencode/src/mcp/catalog.ts | 2 +- packages/opencode/test/mcp/catalog.test.ts | 48 ++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/mcp/catalog.test.ts diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 15eb5af40d6f..cc80a117e16d 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -72,7 +72,7 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe .filter((text) => text.trim()) .join("\n\n") || "MCP tool returned an error", ) - if (result.structuredContent === undefined || result.structuredContent === null) return result + if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result return { ...result, content: [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }], diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts new file mode 100644 index 000000000000..7dbfa40f5404 --- /dev/null +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { McpCatalog } from "@/mcp/catalog" + +const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any + +function clientReturning(result: unknown) { + return { + callTool: async () => result, + } as unknown as Client +} + +function mcpTool() { + return { + name: "screenshot", + description: "Take a screenshot", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + } as any +} + +describe("McpCatalog.convertTool", () => { + test("preserves content when structuredContent is also present", async () => { + const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] + const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ content, structuredContent }) + }) + + test("falls back to structuredContent only when content is absent", async () => { + const structuredContent = { results: [{ title: "one" }] } + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + + const output = await converted.execute?.({}, options) + + expect(output).toMatchObject({ + structuredContent, + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + }) + }) + +}) From 78235385ddfa27d62aa64d9f23deef9ae073947b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 20:53:48 +0000 Subject: [PATCH 047/303] chore: generate --- packages/opencode/src/mcp/catalog.ts | 3 ++- packages/opencode/test/mcp/catalog.test.ts | 1 - .../test/server/httpapi-instance-context.test.ts | 9 +-------- .../test/server/httpapi-promptasync-context.test.ts | 9 +-------- packages/opencode/test/server/httpapi-sdk.test.ts | 4 +--- .../opencode/test/server/httpapi-session.test.ts | 4 +--- .../opencode/test/server/httpapi-workspace.test.ts | 13 ++----------- packages/opencode/test/server/session-list.test.ts | 7 +++---- 8 files changed, 11 insertions(+), 39 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index cc80a117e16d..3f985eeb94dc 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -72,7 +72,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe .filter((text) => text.trim()) .join("\n\n") || "MCP tool returned an error", ) - if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result + if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) + return result return { ...result, content: [{ type: "text" as const, text: JSON.stringify(result.structuredContent) }], diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 7dbfa40f5404..55cabaef7699 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -44,5 +44,4 @@ describe("McpCatalog.convertTool", () => { content: [{ type: "text", text: JSON.stringify(structuredContent) }], }) }) - }) diff --git a/packages/opencode/test/server/httpapi-instance-context.test.ts b/packages/opencode/test/server/httpapi-instance-context.test.ts index 6a758cc41c47..4c881eed7700 100644 --- a/packages/opencode/test/server/httpapi-instance-context.test.ts +++ b/packages/opencode/test/server/httpapi-instance-context.test.ts @@ -44,14 +44,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - workspaceLayer, - ), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-promptasync-context.test.ts b/packages/opencode/test/server/httpapi-promptasync-context.test.ts index c56ebcf3c18f..044a77fd2225 100644 --- a/packages/opencode/test/server/httpapi-promptasync-context.test.ts +++ b/packages/opencode/test/server/httpapi-promptasync-context.test.ts @@ -49,14 +49,7 @@ const testStateLayer = Layer.effectDiscard( const workspaceLayer = workspaceLayerWithRuntimeFlags({ experimentalWorkspaces: true }) -const it = testEffect( - Layer.mergeAll( - testStateLayer, - NodeHttpServer.layerTest, - NodeServices.layer, - workspaceLayer, - ), -) +const it = testEffect(Layer.mergeAll(testStateLayer, NodeHttpServer.layerTest, NodeServices.layer, workspaceLayer)) const instanceContextTestLayer = Layer.mergeAll( instanceContextLayer, diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 8fcd348b5bf9..c81b3b771b24 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -36,9 +36,7 @@ const appLayer = AppNodeBuilder.build( LayerNode.group([FSUtil.node, CrossSpawnSpawner.node, InstanceStore.node, Database.node, SessionNs.node]), [[InstanceStore.bootstrapNode, noopBootstrapLayer]], ) -const it = testEffect( - Layer.mergeAll(appLayer, httpApiLayer), -) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) const original = { OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index ae7f4e7ee8c6..187d4de3661a 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -58,9 +58,7 @@ const httpApiLayer = servedRoutes.pipe( Layer.provideMerge(NodeHttpServer.layerTest), Layer.provideMerge(NodeServices.layer), ) -const it = testEffect( - Layer.mergeAll(appLayer, httpApiLayer), -) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function pathFor(path: string, params: Record) { return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path) diff --git a/packages/opencode/test/server/httpapi-workspace.test.ts b/packages/opencode/test/server/httpapi-workspace.test.ts index 33f3920c2d6b..4cb9f55c847e 100644 --- a/packages/opencode/test/server/httpapi-workspace.test.ts +++ b/packages/opencode/test/server/httpapi-workspace.test.ts @@ -26,19 +26,10 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES const appLayer = AppNodeBuilder.build( - LayerNode.group([ - Project.node, - Session.node, - Workspace.node, - InstanceStore.node, - Database.node, - Ripgrep.node, - ]), + LayerNode.group([Project.node, Session.node, Workspace.node, InstanceStore.node, Database.node, Ripgrep.node]), [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ) -const it = testEffect( - Layer.mergeAll(appLayer, httpApiLayer), -) +const it = testEffect(Layer.mergeAll(appLayer, httpApiLayer)) function request(path: string, directory: string, init: RequestInit = {}) { return requestInDirectory(path, directory, init) diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 3d0e0ab12f4c..354a578b23c9 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -14,10 +14,9 @@ import { testEffect } from "../lib/effect" import { RuntimeFlags } from "@/effect/runtime-flags" const layer = (experimentalWorkspaces: boolean) => - AppNodeBuilder.build( - LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), - [[RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })]], - ) + AppNodeBuilder.build(LayerNode.group([Database.node, SessionNs.node, SessionProjector.node]), [ + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces })], + ]) const it = testEffect(layer(false)) const itWorkspaces = testEffect(layer(true)) From 884c256033958475be4feba69b7e6bf72caaf0ed Mon Sep 17 00:00:00 2001 From: Dustin Deus Date: Mon, 29 Jun 2026 23:28:04 +0200 Subject: [PATCH 048/303] fix(console): cancel upstream provider requests (#34467) Co-authored-by: starptech --- .../app/src/routes/zen/util/handler.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 3e9ceaaa5000..1c6c94286e8e 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -216,6 +216,9 @@ export async function handler( return headers })(), body: reqBody, + // Propagate caller disconnects to the upstream provider request so + // abandoned Console requests do not leave orphaned inference work open. + signal: input.request.signal, }) if (providerInfo.id.startsWith("console.")) { @@ -311,9 +314,10 @@ export async function handler( const streamConverter = createStreamPartConverter(providerInfo.format, opts.format) const usageParser = providerInfo.createUsageParser() const binaryDecoder = providerInfo.createBinaryStreamDecoder() + let reader: ReadableStreamDefaultReader | undefined const stream = new ReadableStream({ start(c) { - const reader = res.body?.getReader() + reader = res.body?.getReader() const decoder = new TextDecoder() const encoder = new TextEncoder() @@ -399,6 +403,11 @@ export async function handler( return pump() }, + cancel() { + // When the downstream caller stops reading, release the upstream + // response body instead of keeping the provider/inference stream alive. + return reader?.cancel() + }, }) return new Response(stream, { status: resStatus, @@ -406,6 +415,15 @@ export async function handler( headers: resHeaders, }) } catch (error: any) { + // The caller disconnected before we finished. Because the outbound provider + // request shares input.request.signal, an aborted caller surfaces here as an + // AbortError. There is no client left to receive a body, so skip the error + // metric and 500 and return a quiet client-closed response. + if (input.request.signal.aborted || error?.name === "AbortError") { + logger.debug("REQUEST ABORTED BY CALLER") + return new Response(null, { status: 499 }) + } + logger.metric({ "error.type": error.constructor.name, "error.message": error.message, From d71454c701c708a4ce3279b32ce9051991b1deab Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:51:06 -0500 Subject: [PATCH 049/303] fix(core): expose models.dev modes as models (#34521) --- packages/core/src/plugin/models-dev.ts | 153 ++++++++++++------- packages/core/test/plugin/models-dev.test.ts | 99 ++++++++++++ 2 files changed, 200 insertions(+), 52 deletions(-) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index 1adc979d256f..075a6ed093c6 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,7 +1,7 @@ import { define } from "./internal" +import type { ModelV2Info } from "@opencode-ai/sdk/v2/types" import { Effect, Stream } from "effect" import { EventV2 } from "../event" -import { ModelV2 } from "../model" import { ModelsDev } from "../models-dev" import { ProviderV2 } from "../provider" @@ -10,7 +10,7 @@ function released(date: string) { return Number.isFinite(time) ? time : 0 } -function cost(input: ModelsDev.Model["cost"]) { +function cost(input: ModelsDev.Model["cost"]): ModelV2Info["cost"] { const base = { input: input?.input ?? 0, output: input?.output ?? 0, @@ -19,30 +19,101 @@ function cost(input: ModelsDev.Model["cost"]) { write: input?.cache_write ?? 0, }, } - if (!input?.context_over_200k) return [base] return [ base, - { - tier: { - type: "context" as const, - size: 200_000, - }, - input: input.context_over_200k.input, - output: input.context_over_200k.output, + ...(input?.tiers?.map((item) => ({ + tier: item.tier, + input: item.input, + output: item.output, cache: { - read: input.context_over_200k.cache_read ?? 0, - write: input.context_over_200k.cache_write ?? 0, + read: item.cache_read ?? 0, + write: item.cache_write ?? 0, }, - }, + })) ?? []), + ...(input?.context_over_200k + ? [ + { + tier: { + type: "context" as const, + size: 200_000, + }, + input: input.context_over_200k.input, + output: input.context_over_200k.output, + cache: { + read: input.context_over_200k.cache_read ?? 0, + write: input.context_over_200k.cache_write ?? 0, + }, + }, + ] + : []), ] } -function variants(model: ModelsDev.Model) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - body: { ...(item.provider?.body ?? {}) }, - })) +function mergeCost(base: ModelV2Info["cost"], override: ModelsDev.Model["cost"] | undefined) { + if (!override) return base + const next = cost(override) + const [baseDefault, ...baseTiers] = base + const [nextDefault, ...nextTiers] = next + const tierKey = (item: ModelV2Info["cost"][number]) => `${item.tier?.type ?? "base"}:${item.tier?.size ?? 0}` + const merge = (left: ModelV2Info["cost"][number], right: ModelV2Info["cost"][number]) => ({ + ...left, + ...right, + tier: right.tier ?? left.tier, + cache: { ...left.cache, ...right.cache }, + }) + const tiers = new Map(baseTiers.map((item) => [tierKey(item), item])) + for (const item of nextTiers) { + const current = tiers.get(tierKey(item)) + tiers.set(tierKey(item), current ? merge(current, item) : item) + } + return [merge(baseDefault ?? { input: 0, output: 0, cache: { read: 0, write: 0 } }, nextDefault), ...tiers.values()] +} + +function modeName(model: ModelsDev.Model, mode: string) { + return `${model.name} ${mode.charAt(0).toUpperCase()}${mode.slice(1)}` +} + +function applyModel( + draft: ModelV2Info, + model: ModelsDev.Model, + input: { + readonly name?: string + readonly cost?: ModelV2Info["cost"] + readonly request?: NonNullable["modes"]>[string]["provider"] + } = {}, +) { + draft.name = input.name ?? model.name + draft.family = model.family + draft.api = model.provider?.npm + ? { + id: model.id, + type: "aisdk", + package: model.provider.npm, + url: model.provider.api, + } + : { + id: model.id, + type: "native", + url: model.provider?.api, + settings: {}, + } + draft.capabilities = { + tools: model.tool_call, + input: [...(model.modalities?.input ?? [])], + output: [...(model.modalities?.output ?? [])], + } + draft.variants = [] + draft.time.released = released(model.release_date) + draft.cost = input.cost ?? cost(model.cost) + draft.status = model.status ?? "active" + draft.enabled = true + draft.limit = { + context: model.limit.context, + input: model.limit.input, + output: model.limit.output, + } + Object.assign(draft.request.headers, input.request?.headers ?? {}) + Object.assign(draft.request.body, input.request?.body ?? {}) } export const ModelsDevPlugin = define({ @@ -89,39 +160,17 @@ export const ModelsDevPlugin = define({ }) for (const model of Object.values(item.models)) { - const modelID = ModelV2.ID.make(model.id) - catalog.model.update(providerID, modelID, (draft) => { - draft.name = model.name - draft.family = model.family ? ModelV2.Family.make(model.family) : undefined - draft.api = model.provider?.npm - ? { - id: draft.api.id, - type: "aisdk", - package: model.provider?.npm, - url: model.provider.api, - } - : { - id: draft.api.id, - type: "native", - url: model.provider?.api, - settings: {}, - } - draft.capabilities = { - tools: model.tool_call, - input: [...(model.modalities?.input ?? [])], - output: [...(model.modalities?.output ?? [])], - } - draft.variants = variants(model) - draft.time.released = released(model.release_date) - draft.cost = cost(model.cost) - draft.status = model.status ?? "active" - draft.enabled = true - draft.limit = { - context: model.limit.context, - input: model.limit.input, - output: model.limit.output, - } - }) + const baseCost = cost(model.cost) + catalog.model.update(providerID, model.id, (draft) => applyModel(draft, model, { cost: baseCost })) + for (const [mode, options] of Object.entries(model.experimental?.modes ?? {})) { + catalog.model.update(providerID, `${model.id}-${mode}`, (draft) => + applyModel(draft, model, { + name: modeName(model, mode), + cost: mergeCost(baseCost, options.cost), + request: options.provider, + }), + ) + } } } }), diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 0858bd433b45..6c0f7e070296 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -8,8 +8,10 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" import { Location } from "@opencode-ai/core/location" +import { ModelV2 } from "@opencode-ai/core/model" import { ModelsDev } from "@opencode-ai/core/models-dev" import { ModelsDevPlugin } from "@opencode-ai/core/plugin/models-dev" +import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -25,6 +27,103 @@ const layer = AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.no const it = testEffect(layer) describe("ModelsDevPlugin", () => { + it.effect("projects models.dev modes as separate models instead of variants", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + const catalog = yield* Catalog.Service + const models = ModelsDev.Service.of({ + get: () => + Effect.succeed({ + acme: { + id: "acme", + name: "Acme", + env: [], + npm: "@ai-sdk/openai-compatible", + api: "https://api.acme.test/v1", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + family: "gpt", + release_date: "2026-01-01", + attachment: false, + reasoning: true, + temperature: true, + tool_call: true, + cost: { + input: 2.5, + output: 15, + tiers: [ + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache_read: 0.25, + }, + ], + context_over_200k: { input: 5, output: 22.5, cache_read: 0.5 }, + }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + experimental: { + modes: { + fast: { + cost: { input: 5, output: 30, cache_read: 0.5 }, + provider: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + }, + }, + }, + }, + }, + }, + } satisfies Record), + refresh: () => Effect.void, + }) + + yield* ModelsDevPlugin.effect( + host({ + catalog: catalogHost(catalog), + integration: integrationHost(integrations), + }), + ).pipe(Effect.provideService(ModelsDev.Service, models)) + + const providerID = ProviderV2.ID.make("acme") + const base = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4")) + const fast = yield* catalog.model.get(providerID, ModelV2.ID.make("gpt-5.4-fast")) + + expect(base?.variants).toEqual([]) + expect(base?.request.body).toEqual({}) + expect(fast).toMatchObject({ + id: "gpt-5.4-fast", + providerID: "acme", + name: "GPT-5.4 Fast", + api: { id: "gpt-5.4" }, + request: { + headers: { "x-mode": "fast" }, + body: { service_tier: "priority" }, + }, + variants: [], + }) + expect(fast?.cost).toEqual([ + { input: 5, output: 30, cache: { read: 0.5, write: 0 } }, + { + tier: { type: "context", size: 272_000 }, + input: 3, + output: 18, + cache: { read: 0.25, write: 0 }, + }, + { + tier: { type: "context", size: 200_000 }, + input: 5, + output: 22.5, + cache: { read: 0.5, write: 0 }, + }, + ]) + }), + ) + it.effect("registers key methods for providers with environment variables", () => Effect.acquireUseRelease( Effect.sync(() => { From e9d4ca7e0fb595ef3771ddbdf587adc1fe2067be Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 29 Jun 2026 18:57:28 -0400 Subject: [PATCH 050/303] zen: track region --- packages/console/app/src/i18n/ar.ts | 3 + packages/console/app/src/i18n/br.ts | 3 + packages/console/app/src/i18n/da.ts | 3 + packages/console/app/src/i18n/de.ts | 3 + packages/console/app/src/i18n/en.ts | 3 + packages/console/app/src/i18n/es.ts | 3 + packages/console/app/src/i18n/fr.ts | 3 + packages/console/app/src/i18n/it.ts | 3 + packages/console/app/src/i18n/ja.ts | 3 + packages/console/app/src/i18n/ko.ts | 3 + packages/console/app/src/i18n/no.ts | 3 + packages/console/app/src/i18n/pl.ts | 3 + packages/console/app/src/i18n/ru.ts | 3 + packages/console/app/src/i18n/th.ts | 3 + packages/console/app/src/i18n/tr.ts | 3 + packages/console/app/src/i18n/uk.ts | 3 + packages/console/app/src/i18n/zh.ts | 3 + packages/console/app/src/i18n/zht.ts | 3 + .../console/app/src/lib/request-country.ts | 5 + .../workspace/[id]/go/lite-section.module.css | 34 + .../routes/workspace/[id]/go/lite-section.tsx | 48 + .../[id]/settings/settings-section.tsx | 1 + .../app/src/routes/zen/util/handler.ts | 21 +- .../migration.sql | 1 + .../snapshot.json | 3157 +++++++++++++++++ .../console/core/src/schema/workspace.sql.ts | 3 +- packages/console/core/src/workspace.ts | 28 +- 27 files changed, 3343 insertions(+), 9 deletions(-) create mode 100644 packages/console/app/src/lib/request-country.ts create mode 100644 packages/console/core/migrations/20260626161712_oval_callisto/migration.sql create mode 100644 packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index cb2ad816c9bb..32610c22e540 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -646,6 +646,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام", "workspace.lite.subscription.selectProvider": 'اختر "OpenCode Go" كمزود في إعدادات opencode الخاصة بك لاستخدام نماذج Go.', + "workspace.lite.providers.title": "المزودون", + "workspace.lite.providers.description": "تحكم في المزودين المستخدمين للتوجيه.", + "workspace.lite.providers.useChina": "تفعيل النماذج المستضافة في الصين", "workspace.lite.black.message": "أنت مشترك حاليًا في OpenCode Black أو في قائمة الانتظار. يرجى إلغاء الاشتراك أولاً إذا كنت ترغب في التبديل إلى Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 5c9410556f95..98b91bdb2a15 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -656,6 +656,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Use seu saldo disponível após atingir os limites de uso", "workspace.lite.subscription.selectProvider": 'Selecione "OpenCode Go" como provedor na sua configuração do opencode para usar os modelos Go.', + "workspace.lite.providers.title": "Provedores", + "workspace.lite.providers.description": "Controle quais provedores são usados para roteamento.", + "workspace.lite.providers.useChina": "Ativar modelos hospedados na China", "workspace.lite.black.message": "Você está atualmente inscrito no OpenCode Black ou na lista de espera. Por favor, cancele a assinatura primeiro se desejar mudar para o Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 4bd3f46a5241..de6f0e5594e7 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -652,6 +652,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Brug din tilgængelige saldo, når du har nået forbrugsgrænserne", "workspace.lite.subscription.selectProvider": 'Vælg "OpenCode Go" som udbyder i din opencode-konfiguration for at bruge Go-modeller.', + "workspace.lite.providers.title": "Udbydere", + "workspace.lite.providers.description": "Styr, hvilke udbydere der bruges til routing.", + "workspace.lite.providers.useChina": "Aktivér modeller hostet i Kina", "workspace.lite.black.message": "Du abonnerer i øjeblikket på OpenCode Black eller er på venteliste. Afmeld venligst først, hvis du vil skifte til Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 7619ec2e78c0..0453e9f0bf5c 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -655,6 +655,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Nutze dein verfügbares Guthaben, nachdem die Nutzungslimits erreicht sind", "workspace.lite.subscription.selectProvider": 'Wähle "OpenCode Go" als Anbieter in deiner opencode-Konfiguration, um Go-Modelle zu verwenden.', + "workspace.lite.providers.title": "Anbieter", + "workspace.lite.providers.description": "Steuere, welche Anbieter für das Routing verwendet werden.", + "workspace.lite.providers.useChina": "In China gehostete Modelle aktivieren", "workspace.lite.black.message": "Du hast derzeit OpenCode Black abonniert oder stehst auf der Warteliste. Bitte kündige zuerst, wenn du zu Go wechseln möchtest.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 97a8ede15bed..f740d5cd1201 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -649,6 +649,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Use your available balance after reaching the usage limits", "workspace.lite.subscription.selectProvider": 'Select "OpenCode Go" as the provider in your opencode configuration to use Go models.', + "workspace.lite.providers.title": "Providers", + "workspace.lite.providers.description": "Control which providers are used for routing.", + "workspace.lite.providers.useChina": "Enable models hosted in China", "workspace.lite.black.message": "You're currently subscribed to OpenCode Black or on the waitlist. Please unsubscribe first if you'd like to switch to Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 4006b33576b6..a59b7f3fc7b2 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -656,6 +656,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Usa tu saldo disponible después de alcanzar los límites de uso", "workspace.lite.subscription.selectProvider": 'Selecciona "OpenCode Go" como proveedor en tu configuración de opencode para usar los modelos Go.', + "workspace.lite.providers.title": "Proveedores", + "workspace.lite.providers.description": "Controla qué proveedores se usan para el enrutamiento.", + "workspace.lite.providers.useChina": "Activar modelos alojados en China", "workspace.lite.black.message": "Actualmente estás suscrito a OpenCode Black o estás en la lista de espera. Por favor, cancela la suscripción primero si deseas cambiar a Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index b722de9ff709..5ac4816bd7cb 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -662,6 +662,9 @@ export const dict = { "Utilisez votre solde disponible après avoir atteint les limites d'utilisation", "workspace.lite.subscription.selectProvider": 'Sélectionnez "OpenCode Go" comme fournisseur dans votre configuration opencode pour utiliser les modèles Go.', + "workspace.lite.providers.title": "Fournisseurs", + "workspace.lite.providers.description": "Contrôlez les fournisseurs utilisés pour le routage.", + "workspace.lite.providers.useChina": "Activer les modèles hébergés en Chine", "workspace.lite.black.message": "Vous êtes actuellement abonné à OpenCode Black ou sur liste d'attente. Veuillez d'abord vous désabonner si vous souhaitez passer à Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index e93f40bf4dd1..85098164773c 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -654,6 +654,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Usa il tuo saldo disponibile dopo aver raggiunto i limiti di utilizzo", "workspace.lite.subscription.selectProvider": 'Seleziona "OpenCode Go" come provider nella tua configurazione opencode per utilizzare i modelli Go.', + "workspace.lite.providers.title": "Provider", + "workspace.lite.providers.description": "Controlla quali provider vengono usati per il routing.", + "workspace.lite.providers.useChina": "Abilita modelli ospitati in Cina", "workspace.lite.black.message": "Attualmente sei abbonato a OpenCode Black o sei in lista d'attesa. Annulla l'iscrizione prima se desideri passare a Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index f00b749a33c0..3cb6f5398566 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -654,6 +654,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "利用限度額に達したら利用可能な残高を使用する", "workspace.lite.subscription.selectProvider": "Go モデルを使用するには、opencode の設定で「OpenCode Go」をプロバイダーとして選択してください。", + "workspace.lite.providers.title": "プロバイダー", + "workspace.lite.providers.description": "ルーティングに使用するプロバイダーを管理します。", + "workspace.lite.providers.useChina": "中国でホストされているモデルを有効にする", "workspace.lite.black.message": "現在 OpenCode Black を購読中、またはウェイティングリストに登録されています。Go に切り替える場合は、先に登録を解除してください。", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 42b980e26e35..6ca91d25c79d 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -646,6 +646,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "사용 한도 도달 후에는 보유 잔액 사용", "workspace.lite.subscription.selectProvider": 'Go 모델을 사용하려면 opencode 설정에서 "OpenCode Go"를 공급자로 선택하세요.', + "workspace.lite.providers.title": "공급자", + "workspace.lite.providers.description": "라우팅에 사용할 공급자를 제어합니다.", + "workspace.lite.providers.useChina": "중국에서 호스팅되는 모델 활성화", "workspace.lite.black.message": "현재 OpenCode Black을 구독 중이거나 대기 명단에 등록되어 있습니다. Go로 전환하려면 먼저 구독을 취소해 주세요.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 4c5a16babd7f..6c13b546fe4e 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -653,6 +653,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Bruk din tilgjengelige saldo etter å ha nådd bruksgrensene", "workspace.lite.subscription.selectProvider": 'Velg "OpenCode Go" som leverandør i opencode-konfigurasjonen din for å bruke Go-modeller.', + "workspace.lite.providers.title": "Leverandører", + "workspace.lite.providers.description": "Kontroller hvilke leverandører som brukes til ruting.", + "workspace.lite.providers.useChina": "Aktiver modeller hostet i Kina", "workspace.lite.black.message": "Du abonnerer for øyeblikket på OpenCode Black eller står på venteliste. Vennligst avslutt abonnementet først hvis du vil bytte til Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 0cb643a2c123..956813263756 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -654,6 +654,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Użyj dostępnego salda po osiągnięciu limitów użycia", "workspace.lite.subscription.selectProvider": 'Wybierz "OpenCode Go" jako dostawcę w konfiguracji opencode, aby używać modeli Go.', + "workspace.lite.providers.title": "Dostawcy", + "workspace.lite.providers.description": "Kontroluj, którzy dostawcy są używani do routingu.", + "workspace.lite.providers.useChina": "Włącz modele hostowane w Chinach", "workspace.lite.black.message": "Obecnie subskrybujesz OpenCode Black lub jesteś na liście oczekujących. Jeśli chcesz przejść na Go, najpierw anuluj subskrypcję.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index 71d8967995ec..a394b1eef034 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -660,6 +660,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Использовать доступный баланс после достижения лимитов", "workspace.lite.subscription.selectProvider": 'Выберите "OpenCode Go" в качестве провайдера в настройках opencode для использования моделей Go.', + "workspace.lite.providers.title": "Провайдеры", + "workspace.lite.providers.description": "Управляйте провайдерами, используемыми для маршрутизации.", + "workspace.lite.providers.useChina": "Включить модели, размещенные в Китае", "workspace.lite.black.message": "Вы подписаны на OpenCode Black или находитесь в списке ожидания. Пожалуйста, сначала отмените подписку, если хотите перейти на Go.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index 506de20b61db..ab654b41d7cd 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -649,6 +649,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "ใช้ยอดคงเหลือของคุณหลังจากถึงขีดจำกัดการใช้งาน", "workspace.lite.subscription.selectProvider": 'เลือก "OpenCode Go" เป็นผู้ให้บริการในการตั้งค่า opencode ของคุณเพื่อใช้โมเดล Go', + "workspace.lite.providers.title": "ผู้ให้บริการ", + "workspace.lite.providers.description": "ควบคุมผู้ให้บริการที่ใช้สำหรับการกำหนดเส้นทาง", + "workspace.lite.providers.useChina": "เปิดใช้โมเดลที่โฮสต์ในจีน", "workspace.lite.black.message": "ขณะนี้คุณสมัครสมาชิก OpenCode Black หรืออยู่ในรายการรอ โปรดยกเลิกการสมัครก่อนหากต้องการเปลี่ยนไปใช้ Go", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 288e7329dad8..041fb4315712 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -656,6 +656,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "Kullanım limitlerine ulaştıktan sonra mevcut bakiyenizi kullanın", "workspace.lite.subscription.selectProvider": 'Go modellerini kullanmak için opencode yapılandırmanızda "OpenCode Go"\'yu sağlayıcı olarak seçin.', + "workspace.lite.providers.title": "Sağlayıcılar", + "workspace.lite.providers.description": "Yönlendirme için hangi sağlayıcıların kullanılacağını kontrol edin.", + "workspace.lite.providers.useChina": "Çin'de barındırılan modelleri etkinleştir", "workspace.lite.black.message": "Şu anda OpenCode Black abonesisiniz veya bekleme listesindesiniz. Go'ya geçmek istiyorsanız lütfen önce aboneliğinizi iptal edin.", "workspace.lite.other.message": diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 6d72beb2e118..26feefb99bd8 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -652,6 +652,9 @@ export const dict = { "workspace.lite.subscription.resetsIn": "Скидається через", "workspace.lite.subscription.useBalance": "Використовуйте доступний баланс після досягнення лімітів", "workspace.lite.subscription.selectProvider": 'Виберіть "OpenCode Go" як провайдера в конфігурації opencode.', + "workspace.lite.providers.title": "Провайдери", + "workspace.lite.providers.description": "Керуйте провайдерами, які використовуються для маршрутизації.", + "workspace.lite.providers.useChina": "Увімкнути моделі, розміщені в Китаї", "workspace.lite.black.message": "Ви вже підписані на OpenCode Black або в списку очікування. Спочатку скасуйте підписку, якщо хочете перейти на Go.", "workspace.lite.other.message": "Інший учасник цього робочого простору вже підписаний на OpenCode Go.", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index a353d52facaa..cebe72f338a8 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -631,6 +631,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "达到使用限额后使用您的可用余额", "workspace.lite.subscription.selectProvider": "在你的 opencode 配置中选择「OpenCode Go」作为提供商,即可使用 Go 模型。", + "workspace.lite.providers.title": "提供商", + "workspace.lite.providers.description": "控制用于路由的提供商。", + "workspace.lite.providers.useChina": "启用部署在中国的模型", "workspace.lite.black.message": "您当前已订阅 OpenCode Black 或在候补名单中。如需切换到 Go,请先取消订阅。", "workspace.lite.other.message": "此工作区中的另一位成员已经订阅了 OpenCode Go。每个工作区只有一名成员可以订阅。", "workspace.lite.promo.description": diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index e378083c0853..1d982d061152 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -631,6 +631,9 @@ export const dict = { "workspace.lite.subscription.useBalance": "達到使用限制後使用您的可用餘額", "workspace.lite.subscription.selectProvider": "在您的 opencode 設定中選擇「OpenCode Go」作為提供商,即可使用 Go 模型。", + "workspace.lite.providers.title": "提供商", + "workspace.lite.providers.description": "控制用於路由的提供商。", + "workspace.lite.providers.useChina": "啟用部署在中國的模型", "workspace.lite.black.message": "您目前已訂閱 OpenCode Black 或在候補名單中。若要切換至 Go,請先取消訂閱。", "workspace.lite.other.message": "此工作區中的另一位成員已訂閱 OpenCode Go。每個工作區只能有一位成員訂閱。", "workspace.lite.promo.description": diff --git a/packages/console/app/src/lib/request-country.ts b/packages/console/app/src/lib/request-country.ts new file mode 100644 index 000000000000..a4e39eb60714 --- /dev/null +++ b/packages/console/app/src/lib/request-country.ts @@ -0,0 +1,5 @@ +export function countryFromRequest(request: Request | undefined) { + if (!request) return undefined + const cloudflareRequest = request as Request & { cf?: { country?: string } } + return cloudflareRequest.cf?.country ?? request.headers.get("cf-ipcountry") ?? undefined +} diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css index 5e598eeaf6ad..14389c42a627 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.module.css @@ -75,6 +75,40 @@ } } + [data-slot="providers-section"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + margin-top: var(--space-6); + padding-top: var(--space-6); + border-top: 1px solid var(--color-border-muted); + + [data-slot="providers-header"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + + h3 { + color: var(--color-text); + font-size: var(--font-size-lg); + font-weight: 600; + line-height: 1.2; + margin: 0; + } + + p { + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.5; + margin: 0; + } + } + + [data-slot="setting-row"] { + margin-top: 0; + } + } + [data-slot="toggle-label"] { position: relative; display: inline-block; diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 7461fa64ce07..59f1ce748e0b 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -5,7 +5,9 @@ import { Modal } from "~/component/modal" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" import { Actor } from "@opencode-ai/console-core/actor.js" +import { Workspace } from "@opencode-ai/console-core/workspace.js" import { Subscription } from "@opencode-ai/console-core/subscription.js" import { LiteData } from "@opencode-ai/console-core/lite.js" import { withActor } from "~/context/auth.withActor" @@ -16,6 +18,8 @@ import { useLanguage } from "~/context/language" import { formError } from "~/lib/form-error" import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time" import { createReferralFromCookie } from "~/lib/referral-invite" +import { getRequestEvent } from "solid-js/web" +import { countryFromRequest } from "~/lib/request-country" import { IconAlipay, IconUpi } from "~/component/icon" @@ -34,9 +38,11 @@ export const queryLiteSubscription = query(async (workspaceID: string) => { timeMonthlyUpdated: LiteTable.timeMonthlyUpdated, timeCreated: LiteTable.timeCreated, lite: BillingTable.lite, + region: WorkspaceTable.region, }) .from(BillingTable) .innerJoin(LiteTable, eq(LiteTable.workspaceID, BillingTable.workspaceID)) + .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, BillingTable.workspaceID)) .where(and(eq(LiteTable.workspaceID, Actor.workspace()), isNull(LiteTable.timeDeleted))) .then((r) => r[0]), ) @@ -48,6 +54,7 @@ export const queryLiteSubscription = query(async (workspaceID: string) => { return { mine, useBalance: row.lite?.useBalance ?? false, + region: row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })), rollingUsage: Subscription.analyzeRollingUsage({ limit: limits.rollingLimit, window: limits.rollingWindow, @@ -128,6 +135,24 @@ const setLiteUseBalance = action(async (form: FormData) => { ) }, "setLiteUseBalance") +const setGoProviderRouting = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID") as string | null + if (!workspaceID) return { error: formError.workspaceRequired } + const useChinaProviders = (form.get("useChinaProviders") as string | null) === "true" + + return json( + await withActor( + () => + Workspace.update({ region: useChinaProviders ? ["us", "eu", "sg"] : ["us", "eu", "sg", "cn"] }) + .then(() => ({ error: undefined })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: queryLiteSubscription.key }, + ) +}, "go.providerRouting.set") + function LiteUsageItem(props: { label: string; usage: { usagePercent: number; resetInSec: number } }) { const i18n = useI18n() @@ -159,6 +184,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) { const checkoutAction = useAction(createLiteCheckoutUrl) const checkoutSubmission = useSubmission(createLiteCheckoutUrl) const useBalanceSubmission = useSubmission(setLiteUseBalance) + const providerRoutingSubmission = useSubmission(setGoProviderRouting) const [store, setStore] = createStore({ loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi", showModal: false, @@ -232,6 +258,28 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) { + {/* +
+
+

{i18n.t("workspace.lite.providers.title")}

+

{i18n.t("workspace.lite.providers.description")}

+
+
+

{i18n.t("workspace.lite.providers.useChina")}

+ + + +
+
+ */} )}
diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx index a9950c41e4df..a6795c1ba66c 100644 --- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx @@ -43,6 +43,7 @@ const updateWorkspace = action(async (form: FormData) => { .catch((e) => ({ error: e.message as string })), workspaceID, ), + { revalidate: getWorkspaceInfo.key }, ) }, "workspace.update") diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 1c6c94286e8e..9492a1addce4 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -49,6 +49,8 @@ import { createModelTpmLimiter } from "./modelTpmLimiter" import { createModelTpsLimiter } from "./modelTpsLimiter" import { createProviderBudgetTracker } from "./providerBudgetTracker" import { accumulateUsage, HOT_WORKSPACES } from "./usageBatcher" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import { countryFromRequest } from "~/lib/request-country" type ZenData = Awaited> type RetryOptions = { @@ -125,6 +127,11 @@ export async function handler( : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) await rateLimiter?.check() const authInfo = await authenticate(modelInfo, zenApiKey) + if (authInfo && !authInfo.region) { + await Actor.provide("system", { workspaceID: authInfo.workspaceID }, () => + Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }), + ) + } const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip) const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId) const stickyProvider = await stickyTracker?.get() @@ -657,7 +664,10 @@ export async function handler( tx .select({ apiKey: KeyTable.id, - workspaceID: KeyTable.workspaceID, + workspace: { + id: WorkspaceTable.id, + region: WorkspaceTable.region, + }, billing: { balance: BillingTable.balance, paymentMethodID: BillingTable.paymentMethodID, @@ -735,13 +745,13 @@ export async function handler( if ( modelInfo.id.startsWith("alpha-") && Resource.App.stage === "production" && - !ADMIN_WORKSPACES.includes(data.workspaceID) + !ADMIN_WORKSPACES.includes(data.workspace.id) ) throw new AuthError(t("zen.api.error.modelNotSupported", { model: modelInfo.id })) logger.metric({ api_key: data.apiKey, - workspace: data.workspaceID, + workspace: data.workspace.id, user_id: data.user.id, ...(() => { if (data.billing.subscription) @@ -758,13 +768,14 @@ export async function handler( return { apiKeyId: data.apiKey, - workspaceID: data.workspaceID, + workspaceID: data.workspace.id, + region: data.workspace.region, billing: data.billing, user: data.user, black: data.black, lite: data.lite, provider: data.provider, - isFree: ADMIN_WORKSPACES.includes(data.workspaceID), + isFree: ADMIN_WORKSPACES.includes(data.workspace.id), isDisabled: !!data.timeDisabled, } } diff --git a/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql b/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql new file mode 100644 index 000000000000..ebef63c96404 --- /dev/null +++ b/packages/console/core/migrations/20260626161712_oval_callisto/migration.sql @@ -0,0 +1 @@ +ALTER TABLE `workspace` ADD `region` json; \ No newline at end of file diff --git a/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json new file mode 100644 index 000000000000..18788809de5a --- /dev/null +++ b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json @@ -0,0 +1,3157 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "d78d7057-b247-4572-9150-2532149169db", + "prevIds": [ + "a946ea14-a35d-49cc-a10a-8c93c30a3cb1" + ], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "coupon", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_sticky_provider", + "entityType": "tables" + }, + { + "name": "model_tpm_rate_limit", + "entityType": "tables" + }, + { + "name": "model_tps_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "referral_code", + "entityType": "tables" + }, + { + "name": "referral_reward", + "entityType": "tables" + }, + { + "name": "referral", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "enum('BUILDATHON','GO1MONTH50','GOFREEMONTH','GO3MONTHS100','GO6MONTHS100','GO12MONTHS100')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_redeemed", + "entityType": "columns", + "table": "coupon" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider_id", + "entityType": "columns", + "table": "model_sticky_provider" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_tpm_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "qualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "unqualify", + "entityType": "columns", + "table": "model_tps_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "code", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_code" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "referral_id", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_applied", + "entityType": "columns", + "table": "referral_reward" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "referral" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invitee_account_id", + "entityType": "columns", + "table": "referral" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "region", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": [ + "email", + "type" + ], + "name": "PRIMARY", + "table": "coupon", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": [ + "ip", + "interval" + ], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "ip" + ], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": [ + "key", + "interval" + ], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "model_sticky_provider", + "entityType": "pks" + }, + { + "columns": [ + "id", + "interval" + ], + "name": "PRIMARY", + "table": "model_tpm_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "id", + "interval" + ], + "name": "PRIMARY", + "table": "model_tps_rate_limit", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id" + ], + "name": "PRIMARY", + "table": "referral_code", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "referral_id" + ], + "name": "PRIMARY", + "table": "referral_reward", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "referral", + "entityType": "pks" + }, + { + "columns": [ + "workspace_id", + "id" + ], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": [ + "id" + ], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "code", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "code", + "entityType": "indexes", + "table": "referral_code" + }, + { + "columns": [ + { + "value": "invitee_account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "invitee_account_id", + "entityType": "indexes", + "table": "referral" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/console/core/src/schema/workspace.sql.ts b/packages/console/core/src/schema/workspace.sql.ts index 269b62a2a27f..ab729d5b3847 100644 --- a/packages/console/core/src/schema/workspace.sql.ts +++ b/packages/console/core/src/schema/workspace.sql.ts @@ -1,4 +1,4 @@ -import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" +import { json, primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { timestamps, ulid } from "../drizzle/types" export const WorkspaceTable = mysqlTable( @@ -7,6 +7,7 @@ export const WorkspaceTable = mysqlTable( id: ulid("id").notNull().primaryKey(), slug: varchar("slug", { length: 255 }), name: varchar("name", { length: 255 }).notNull(), + region: json("region").$type<("us" | "eu" | "sg" | "cn")[]>(), ...timestamps, }, (table) => [uniqueIndex("slug").on(table.slug)], diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index 8a65de2d4a09..e3c2e0d9a991 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -11,6 +11,9 @@ import { Key } from "./key" import { and, eq, isNull, sql } from "drizzle-orm" export namespace Workspace { + export const Region = z.enum(["us", "eu", "sg", "cn"]) + export type Region = z.infer + export const create = fn( z.object({ name: z.string().min(1), @@ -57,22 +60,41 @@ export namespace Workspace { export const update = fn( z.object({ - name: z.string().min(1).max(255), + name: z.string().min(1).max(255).optional(), + region: z.array(Region).min(1).optional(), }), - async ({ name }) => { + async (input) => { Actor.assertAdmin() const workspaceID = Actor.workspace() return await Database.use((tx) => tx .update(WorkspaceTable) .set({ - name, + ...("name" in input ? { name: input.name } : {}), + ...("region" in input ? { region: input.region } : {}), }) .where(eq(WorkspaceTable.id, workspaceID)), ) }, ) + export const setDefaultRegion = fn( + z.object({ + country: z.string().optional(), + }), + async (input) => { + const region: Workspace.Region[] = + input.country?.toUpperCase() === "CN" ? ["us", "eu", "sg", "cn"] : ["us", "eu", "sg"] + await Database.use((tx) => + tx + .update(WorkspaceTable) + .set({ region }) + .where(and(eq(WorkspaceTable.id, Actor.workspace()), isNull(WorkspaceTable.region))), + ) + return region + }, + ) + export const remove = fn(z.void(), async () => { await Database.use((tx) => tx From 4caec80e67ba48c33fa52256b04ea4cf20c8bdf9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 29 Jun 2026 23:03:24 +0000 Subject: [PATCH 051/303] chore: generate --- .../routes/workspace/[id]/go/lite-section.tsx | 3 +- .../snapshot.json | 114 ++++-------------- 2 files changed, 27 insertions(+), 90 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 59f1ce748e0b..3eb615185826 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -54,7 +54,8 @@ export const queryLiteSubscription = query(async (workspaceID: string) => { return { mine, useBalance: row.lite?.useBalance ?? false, - region: row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })), + region: + row.region ?? (await Workspace.setDefaultRegion({ country: countryFromRequest(getRequestEvent()?.request) })), rollingUsage: Subscription.analyzeRollingUsage({ limit: limits.rollingLimit, window: limits.rollingWindow, diff --git a/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json index 18788809de5a..ac6ab735ae7a 100644 --- a/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json +++ b/packages/console/core/migrations/20260626161712_oval_callisto/snapshot.json @@ -2,9 +2,7 @@ "version": "6", "dialect": "mysql", "id": "d78d7057-b247-4572-9150-2532149169db", - "prevIds": [ - "a946ea14-a35d-49cc-a10a-8c93c30a3cb1" - ], + "prevIds": ["a946ea14-a35d-49cc-a10a-8c93c30a3cb1"], "ddl": [ { "name": "account", @@ -2633,201 +2631,139 @@ "table": "workspace" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "account", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "auth", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "benchmark", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "billing", "entityType": "pks" }, { - "columns": [ - "email", - "type" - ], + "columns": ["email", "type"], "name": "PRIMARY", "table": "coupon", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "lite", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "payment", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "subscription", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "usage", "entityType": "pks" }, { - "columns": [ - "ip", - "interval" - ], + "columns": ["ip", "interval"], "name": "PRIMARY", "table": "ip_rate_limit", "entityType": "pks" }, { - "columns": [ - "ip" - ], + "columns": ["ip"], "name": "PRIMARY", "table": "ip", "entityType": "pks" }, { - "columns": [ - "key", - "interval" - ], + "columns": ["key", "interval"], "name": "PRIMARY", "table": "key_rate_limit", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "model_sticky_provider", "entityType": "pks" }, { - "columns": [ - "id", - "interval" - ], + "columns": ["id", "interval"], "name": "PRIMARY", "table": "model_tpm_rate_limit", "entityType": "pks" }, { - "columns": [ - "id", - "interval" - ], + "columns": ["id", "interval"], "name": "PRIMARY", "table": "model_tps_rate_limit", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "key", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "model", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "provider", "entityType": "pks" }, { - "columns": [ - "workspace_id" - ], + "columns": ["workspace_id"], "name": "PRIMARY", "table": "referral_code", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "referral_id" - ], + "columns": ["workspace_id", "referral_id"], "name": "PRIMARY", "table": "referral_reward", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "referral", "entityType": "pks" }, { - "columns": [ - "workspace_id", - "id" - ], + "columns": ["workspace_id", "id"], "name": "PRIMARY", "table": "user", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "name": "PRIMARY", "table": "workspace", "entityType": "pks" @@ -3154,4 +3090,4 @@ } ], "renames": [] -} \ No newline at end of file +} From 60b6229c4e7b39447709c7dab40222ea5d06adeb Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 29 Jun 2026 19:57:05 -0400 Subject: [PATCH 052/303] zen: track region --- packages/console/app/src/i18n/ar.ts | 2 ++ packages/console/app/src/i18n/br.ts | 2 ++ packages/console/app/src/i18n/da.ts | 2 ++ packages/console/app/src/i18n/de.ts | 2 ++ packages/console/app/src/i18n/en.ts | 2 ++ packages/console/app/src/i18n/es.ts | 2 ++ packages/console/app/src/i18n/fr.ts | 2 ++ packages/console/app/src/i18n/it.ts | 2 ++ packages/console/app/src/i18n/ja.ts | 2 ++ packages/console/app/src/i18n/ko.ts | 2 ++ packages/console/app/src/i18n/no.ts | 2 ++ packages/console/app/src/i18n/pl.ts | 2 ++ packages/console/app/src/i18n/ru.ts | 2 ++ packages/console/app/src/i18n/th.ts | 2 ++ packages/console/app/src/i18n/tr.ts | 2 ++ packages/console/app/src/i18n/uk.ts | 2 ++ packages/console/app/src/i18n/zh.ts | 1 + packages/console/app/src/i18n/zht.ts | 1 + .../console/app/src/routes/zen/util/error.ts | 1 + .../app/src/routes/zen/util/handler.ts | 31 ++++++++++++++++--- 20 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 32610c22e540..3a172c5d9be7 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -369,6 +369,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "zen.api.error.modelDisabled": "النموذج معطل", + "zen.api.error.regionNotAllowed": + "هذا النموذج مستضاف في الصين. إذا كنت ترغب في استخدام هذا النموذج، فعّله في إعداداتك: {{consoleGoUrl}}", "zen.api.error.trialEnded": "انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 98b91bdb2a15..2c1b81c5b44f 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -377,6 +377,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "zen.api.error.modelDisabled": "O modelo está desabilitado", + "zen.api.error.regionNotAllowed": + "Este modelo está hospedado na China. Se você quiser usar este modelo, ative-o nas suas configurações: {{consoleGoUrl}}", "zen.api.error.trialEnded": "A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index de6f0e5594e7..8df09f3dbaea 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -373,6 +373,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "zen.api.error.modelDisabled": "Modellen er deaktiveret", + "zen.api.error.regionNotAllowed": + "Denne model hostes i Kina. Hvis du vil bruge denne model, skal du aktivere den i dine indstillinger: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 0453e9f0bf5c..fd8060e545f2 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -376,6 +376,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "zen.api.error.modelDisabled": "Modell ist deaktiviert", + "zen.api.error.regionNotAllowed": + "Dieses Modell wird in China gehostet. Wenn du dieses Modell verwenden möchtest, aktiviere es in deinen Einstellungen: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index f740d5cd1201..88b710ac3712 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -370,6 +370,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "zen.api.error.modelDisabled": "Model is disabled", + "zen.api.error.regionNotAllowed": + "This model is hosted in China. If you would like to use this model, enable it in your settings: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index a59b7f3fc7b2..3f5cfe14220f 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -377,6 +377,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}", "zen.api.error.modelDisabled": "El modelo está deshabilitado", + "zen.api.error.regionNotAllowed": + "Este modelo está alojado en China. Si quieres usar este modelo, actívalo en tu configuración: {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index 5ac4816bd7cb..7e3de42b8d15 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -377,6 +377,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}", "zen.api.error.modelDisabled": "Le modèle est désactivé", + "zen.api.error.regionNotAllowed": + "Ce modèle est hébergé en Chine. Si vous souhaitez utiliser ce modèle, activez-le dans vos paramètres : {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index 85098164773c..39d4c3626b79 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -373,6 +373,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}", "zen.api.error.modelDisabled": "Il modello è disabilitato", + "zen.api.error.regionNotAllowed": + "Questo modello è ospitato in Cina. Se vuoi usare questo modello, abilitalo nelle tue impostazioni: {{consoleGoUrl}}", "zen.api.error.trialEnded": "La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index 3cb6f5398566..fc124adc1b83 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -374,6 +374,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}", "zen.api.error.modelDisabled": "モデルが無効です", + "zen.api.error.regionNotAllowed": + "このモデルは中国でホストされています。このモデルを使用したい場合は、設定で有効にしてください: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index 6ca91d25c79d..db21a40cc30a 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -368,6 +368,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}", "zen.api.error.modelDisabled": "모델이 비활성화되었습니다", + "zen.api.error.regionNotAllowed": + "이 모델은 중국에서 호스팅됩니다. 이 모델을 사용하려면 설정에서 활성화하세요: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 6c13b546fe4e..0cf394ce8b2e 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -374,6 +374,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}", "zen.api.error.modelDisabled": "Modellen er deaktivert", + "zen.api.error.regionNotAllowed": + "Denne modellen hostes i Kina. Hvis du vil bruke denne modellen, aktiver den i innstillingene dine: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 956813263756..48c292100d08 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -375,6 +375,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}", "zen.api.error.modelDisabled": "Model jest wyłączony", + "zen.api.error.regionNotAllowed": + "Ten model jest hostowany w Chinach. Jeśli chcesz korzystać z tego modelu, włącz go w swoich ustawieniach: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index a394b1eef034..6d77d17ee961 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -379,6 +379,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}", "zen.api.error.modelDisabled": "Модель отключена", + "zen.api.error.regionNotAllowed": + "Эта модель размещена в Китае. Если вы хотите использовать эту модель, включите её в настройках: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index ab654b41d7cd..eebe0209481b 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -370,6 +370,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}", "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน", + "zen.api.error.regionNotAllowed": + "โมเดลนี้โฮสต์อยู่ในประเทศจีน หากคุณต้องการใช้โมเดลนี้ ให้เปิดใช้งานในการตั้งค่าของคุณ: {{consoleGoUrl}}", "zen.api.error.trialEnded": "โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 041fb4315712..866f3adc9a1b 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -377,6 +377,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}", "zen.api.error.modelDisabled": "Model devre dışı", + "zen.api.error.regionNotAllowed": + "Bu model Çin'de barındırılıyor. Bu modeli kullanmak istiyorsanız ayarlarınızdan etkinleştirin: {{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index 26feefb99bd8..91c7510148e7 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -374,6 +374,8 @@ export const dict = { "zen.api.error.userMonthlyLimitReached": "Ви досягли місячного ліміту витрат ${{amount}}. Керуйте лімітами: {{membersUrl}}", "zen.api.error.modelDisabled": "Модель вимкнено", + "zen.api.error.regionNotAllowed": + "Ця модель розміщена в Китаї. Якщо ви хочете використовувати цю модель, увімкніть її в налаштуваннях: {{consoleGoUrl}}", "zen.api.error.trialEnded": "Безкоштовна акція для {{model}} закінчилася. Ви можете продовжити використання, підписавшись на OpenCode Go — {{link}}", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index cebe72f338a8..ebb0f47e7de8 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -356,6 +356,7 @@ export const dict = { "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}", "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}", "zen.api.error.modelDisabled": "模型已禁用", + "zen.api.error.regionNotAllowed": "该模型部署在中国。如果你想使用该模型,请在设置中启用它:{{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}", "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 1d982d061152..8170edf64679 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -356,6 +356,7 @@ export const dict = { "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}", "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}", "zen.api.error.modelDisabled": "模型已停用", + "zen.api.error.regionNotAllowed": "此模型部署於中國。如果你想使用此模型,請在設定中啟用它:{{consoleGoUrl}}", "zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}", "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型", diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index d17741ff70d6..5117815d2738 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -3,6 +3,7 @@ export class CreditsError extends Error {} export class MonthlyLimitError extends Error {} export class UserLimitError extends Error {} export class ModelError extends Error {} +export class RegionError extends Error {} class LimitError extends Error { retryAfter?: number diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 9492a1addce4..9be1dde33049 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -21,6 +21,7 @@ import { MonthlyLimitError, UserLimitError, ModelError, + RegionError, RateLimitError, FreeUsageLimitError, GoUsageLimitError, @@ -127,11 +128,24 @@ export async function handler( : createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request) await rateLimiter?.check() const authInfo = await authenticate(modelInfo, zenApiKey) - if (authInfo && !authInfo.region) { - await Actor.provide("system", { workspaceID: authInfo.workspaceID }, () => - Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }), - ) + const allowedRegions = authInfo?.region + ? authInfo.region + : await (async () => { + if (!authInfo) return + return Actor.provide("system", { workspaceID: authInfo.workspaceID }, () => + Workspace.setDefaultRegion({ country: countryFromRequest(input.request) }), + ) + })() + /* + if (true) { + if (!allowedRegions?.includes("unavailable")) + throw new RegionError( + t("zen.api.error.regionNotAllowed", { + consoleGoUrl: `https://opencode.ai/workspace/${authInfo.workspaceID}/go`, + }), + ) } + */ const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip) const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId) const stickyProvider = await stickyTracker?.get() @@ -444,6 +458,15 @@ export async function handler( } catch {} } + if (error instanceof RegionError) + return new Response( + JSON.stringify({ + type: "error", + error: { type: error.constructor.name, message: error.message }, + }), + { status: 403 }, + ) + // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. if ( error instanceof AuthError || From 5b6221116789b461f3b49a8077bb8dcf1b7bdee1 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 21:21:28 -0400 Subject: [PATCH 053/303] refactor(opencode): build runtimes from layer nodes (#34515) --- packages/opencode/src/acp/service.ts | 25 +++-- packages/opencode/src/cli/cmd/debug/scrap.ts | 3 +- packages/opencode/src/config/tui.ts | 8 +- packages/opencode/src/effect/app-runtime.ts | 104 +++++++++--------- .../opencode/src/effect/bootstrap-runtime.ts | 12 +- packages/opencode/src/installation/index.ts | 11 +- packages/opencode/src/plugin/tui/runtime.ts | 3 +- .../instance/httpapi/websocket-tracker.ts | 5 +- packages/opencode/src/server/server.ts | 3 +- packages/opencode/test/config/tui.test.ts | 5 +- 10 files changed, 97 insertions(+), 82 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index f15047f78ffc..63d075f3fcc5 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -30,6 +30,7 @@ import { type SetSessionModeResponse, } from "@agentclientprotocol/sdk" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import type { AssistantMessage, Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { Context, Effect, Layer, ManagedRuntime } from "effect" import * as ACPError from "./error" @@ -569,22 +570,26 @@ export function make(input: { } function makeSessionService() { - return ManagedRuntime.make(ACPSession.defaultLayer).runSync( + return ManagedRuntime.make(AppNodeBuilder.build(ACPSession.node)).runSync( ACPSession.Service.use((service) => Effect.succeed(service)), ) } function makeDirectoryService(sdk: OpencodeClient) { return ManagedRuntime.make( - Directory.layer.pipe( - Layer.provide( - Layer.succeed( - Directory.Loader, - Directory.Loader.of({ - load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), - }), - ), - ), + AppNodeBuilder.build( + Directory.node, + [ + [ + Directory.loaderNode, + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), + ], + ], ), ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } diff --git a/packages/opencode/src/cli/cmd/debug/scrap.ts b/packages/opencode/src/cli/cmd/debug/scrap.ts index eef6e0556964..f624ee882e4c 100644 --- a/packages/opencode/src/cli/cmd/debug/scrap.ts +++ b/packages/opencode/src/cli/cmd/debug/scrap.ts @@ -7,8 +7,9 @@ export const ScrapCommand = cmd({ builder: (yargs) => yargs, async handler() { const { Project } = await import("@/project/project") + const { AppNodeBuilder } = await import("@opencode-ai/core/effect/app-node-builder") const { makeRuntime } = await import("@opencode-ai/core/effect/runtime") - const runtime = makeRuntime(Project.Service, Project.defaultLayer) + const runtime = makeRuntime(Project.Service, AppNodeBuilder.build(Project.node)) const list = await runtime.runPromise((project) => project.list()) process.stdout.write(JSON.stringify(list, null, 2) + EOL) }, diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index edc7674a9309..8f503751f379 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -2,6 +2,8 @@ export * as TuiConfig from "./tui" import path from "path" import { mergeDeep, unique } from "remeda" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" @@ -223,7 +225,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } }) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const directory = yield* CurrentWorkingDirectory @@ -257,9 +259,9 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) -const { runPromise } = makeRuntime(Service, defaultLayer) +const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) export async function waitForDependencies() { await runPromise((svc) => svc.waitForDependencies()) diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index a31ff70ea1b3..5048bbf501ee 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -53,57 +53,63 @@ import { BackgroundJob } from "@/background/job" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { SessionProjector } from "@opencode-ai/core/session/projector" -export const AppLayer = Layer.mergeAll( - Npm.defaultLayer, - FSUtil.defaultLayer, - Database.defaultLayer, - Auth.defaultLayer, - Account.defaultLayer, - Config.defaultLayer, - Git.defaultLayer, - Storage.defaultLayer, - Snapshot.defaultLayer, - Plugin.defaultLayer, - ModelsDev.defaultLayer, - Provider.defaultLayer, - ProviderAuth.defaultLayer, - Agent.defaultLayer, - Skill.defaultLayer, - Discovery.defaultLayer, - Question.defaultLayer, - Permission.defaultLayer, - Todo.defaultLayer, - Session.defaultLayer, - SessionStatus.defaultLayer, - BackgroundJob.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - SessionRunState.defaultLayer, - SessionProcessor.defaultLayer, - SessionCompaction.defaultLayer, - SessionRevert.defaultLayer, - SessionSummary.defaultLayer, - SessionPrompt.defaultLayer, - Instruction.defaultLayer, - LLM.defaultLayer, - LSP.defaultLayer, - MCP.defaultLayer, - McpAuth.defaultLayer, - Command.defaultLayer, - Truncate.defaultLayer, - ToolRegistry.defaultLayer, - Format.defaultLayer, - Project.defaultLayer, - Vcs.defaultLayer, - Workspace.defaultLayer, - Worktree.appLayer, - Installation.defaultLayer, - ShareNext.defaultLayer, - SessionShare.defaultLayer, +export const AppLayer = AppNodeBuilder.build( + LayerNode.group([ + Npm.node, + FSUtil.node, + Database.node, + Auth.node, + Account.node, + Config.node, + Git.node, + Storage.node, + Snapshot.node, + Plugin.node, + ModelsDev.node, + Provider.node, + ProviderAuth.node, + Agent.node, + Skill.node, + Discovery.node, + Question.node, + Permission.node, + Todo.node, + Session.node, + SessionProjector.node, + SessionStatus.node, + BackgroundJob.node, + RuntimeFlags.node, + EventV2Bridge.node, + SessionRunState.node, + SessionProcessor.node, + SessionCompaction.node, + SessionRevert.node, + SessionSummary.node, + SessionPrompt.node, + Instruction.node, + LLM.node, + LSP.node, + MCP.node, + McpAuth.node, + Command.node, + Truncate.node, + ToolRegistry.node, + Format.node, + InstanceStore.node, + Project.node, + Vcs.node, + Workspace.node, + Worktree.node, + Installation.node, + ShareNext.node, + SessionShare.node, + ]), + [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], ).pipe( - Layer.provideMerge(Ripgrep.defaultLayer), - Layer.provideMerge(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), + Layer.provideMerge(AppNodeBuilder.build(Ripgrep.node)), Layer.provideMerge(Observability.layer), ) diff --git a/packages/opencode/src/effect/bootstrap-runtime.ts b/packages/opencode/src/effect/bootstrap-runtime.ts index c1987a48ce94..57fe43b46ee3 100644 --- a/packages/opencode/src/effect/bootstrap-runtime.ts +++ b/packages/opencode/src/effect/bootstrap-runtime.ts @@ -1,4 +1,6 @@ import { Layer, ManagedRuntime } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Plugin } from "@/plugin" import { LSP } from "@/lsp/lsp" @@ -10,14 +12,8 @@ import { Config } from "@/config/config" import * as Observability from "@opencode-ai/core/observability" import { memoMap } from "@opencode-ai/core/effect/memo-map" -export const BootstrapLayer = Layer.mergeAll( - Config.defaultLayer, - Plugin.defaultLayer, - ShareNext.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Vcs.defaultLayer, - Snapshot.defaultLayer, +export const BootstrapLayer = AppNodeBuilder.build( + LayerNode.group([Config.node, Plugin.node, ShareNext.node, Format.node, LSP.node, Vcs.node, Snapshot.node]), ).pipe(Layer.provide(Observability.layer)) export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap }) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 698c27c09ac3..4300220255b3 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,8 +1,9 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" @@ -82,7 +83,7 @@ export class Service extends Context.Service()("@opencode/In export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient @@ -324,14 +325,12 @@ export const layer: Layer.Layer) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) -export const node = LayerNode.make({ service: Service, layer: layer, deps: [httpClient, AppProcess.node] }) - export * as Installation from "." diff --git a/packages/opencode/src/plugin/tui/runtime.ts b/packages/opencode/src/plugin/tui/runtime.ts index 4673805cf3bc..2a88ad3d5934 100644 --- a/packages/opencode/src/plugin/tui/runtime.ts +++ b/packages/opencode/src/plugin/tui/runtime.ts @@ -14,6 +14,7 @@ import { import path from "path" import { fileURLToPath } from "url" import { TuiConfig } from "@/config/tui" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { errorData, errorMessage } from "@opencode-ai/tui/util/error" import { isRecord } from "@opencode-ai/tui/util/record" import { resolveHostAttentionSoundPaths } from "@/config/tui-host-attention" @@ -1082,7 +1083,7 @@ async function load(input: { const flags = await Effect.runPromise( Effect.gen(function* () { return yield* RuntimeFlags.Service - }).pipe(Effect.provide(RuntimeFlags.defaultLayer)), + }).pipe(Effect.provide(AppNodeBuilder.build(RuntimeFlags.node))), ) const pluginOrigins = config.plugin_origins ?? (await TuiConfig.pluginOrigins()) const records = Flag.OPENCODE_PURE ? [] : pluginOrigins diff --git a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts index 7cbac4ed5f24..7e8eed89a4f7 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/websocket-tracker.ts @@ -1,4 +1,5 @@ import { Context, Effect, Layer, Option } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import * as Socket from "effect/unstable/socket/Socket" export const SERVER_CLOSING_EVENT = () => new Socket.CloseEvent(1001, "server closing") @@ -13,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/HttpApiWebSocketTracker") {} -export const layer = Layer.sync(Service)(() => { +const layer = Layer.sync(Service)(() => { const sockets = new Set() let closing = false return Service.of({ @@ -44,6 +45,8 @@ export const layer = Layer.sync(Service)(() => { }) }) +export const node = LayerNode.make({ service: Service, layer, deps: [] }) + export const register = (close: Close) => Effect.gen(function* () { const tracker = yield* Effect.serviceOption(Service) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 5145e26d6c49..440b992c1557 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,6 +1,7 @@ import "./init-projectors" import { NodeHttpServer } from "@effect/platform-node" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" @@ -102,7 +103,7 @@ function listenerLayer(opts: ListenOptions, port: number) { disableLogger: true, disableListenLog: true, }).pipe( - Layer.provideMerge(WebSocketTracker.layer), + Layer.provideMerge(AppNodeBuilder.build(WebSocketTracker.node)), Layer.provideMerge(serverLayer({ port, hostname: opts.hostname })), // Install a fresh `ConfigProvider` per listener so `Config.string(...)` // reads reflect the current `process.env`. Effect's default diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index 38413533c4d9..d00b4a4ad6b1 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import path from "path" import { pathToFileURL } from "url" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -70,12 +71,12 @@ const withPlatform = (platform: typeof process.platform, self: Effect.E const getTuiConfig = (directory: string) => TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), ) const getTuiPluginOrigins = (directory: string) => TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide(TuiConfig.defaultLayer.pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), ) it.instance("keeps server and tui plugin merge semantics aligned", () => From 2ace23793836ca2bb6b93d4cc21aace5fd88b3eb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 30 Jun 2026 01:23:03 +0000 Subject: [PATCH 054/303] chore: generate --- packages/opencode/src/acp/service.ts | 21 +++++++++------------ packages/opencode/src/effect/app-runtime.ts | 5 +---- packages/opencode/test/config/tui.test.ts | 8 ++++++-- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 63d075f3fcc5..7257efa7658c 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -577,20 +577,17 @@ function makeSessionService() { function makeDirectoryService(sdk: OpencodeClient) { return ManagedRuntime.make( - AppNodeBuilder.build( - Directory.node, + AppNodeBuilder.build(Directory.node, [ [ - [ - Directory.loaderNode, - Layer.succeed( - Directory.Loader, - Directory.Loader.of({ - load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), - }), - ), - ], + Directory.loaderNode, + Layer.succeed( + Directory.Loader, + Directory.Loader.of({ + load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"), + }), + ), ], - ), + ]), ).runSync(Directory.Service.use((service) => Effect.succeed(service))) } diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 5048bbf501ee..437e6a349328 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -108,10 +108,7 @@ export const AppLayer = AppNodeBuilder.build( SessionShare.node, ]), [[InstanceStore.bootstrapNode, InstanceBootstrap.node]], -).pipe( - Layer.provideMerge(AppNodeBuilder.build(Ripgrep.node)), - Layer.provideMerge(Observability.layer), -) +).pipe(Layer.provideMerge(AppNodeBuilder.build(Ripgrep.node)), Layer.provideMerge(Observability.layer)) const rt = ManagedRuntime.make(AppLayer, { memoMap }) type Runtime = Pick diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index d00b4a4ad6b1..cc43cf4e55de 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -71,12 +71,16 @@ const withPlatform = (platform: typeof process.platform, self: Effect.E const getTuiConfig = (directory: string) => TuiConfig.Service.use((svc) => svc.get()).pipe( - Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) const getTuiPluginOrigins = (directory: string) => TuiConfig.Service.use((svc) => svc.pluginOrigins()).pipe( - Effect.provide(AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory)))), + Effect.provide( + AppNodeBuilder.build(TuiConfig.node).pipe(Layer.provide(Layer.succeed(CurrentWorkingDirectory, directory))), + ), ) it.instance("keeps server and tui plugin merge semantics aligned", () => From 2fe68b5e91abda916cceebe27d9814c345dcb262 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:26:04 +1000 Subject: [PATCH 055/303] fix(ui): prevent tool status blank frame (#34547) Co-authored-by: opencode-agent[bot] --- .../src/components/tool-status-title.tsx | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/session-ui/src/components/tool-status-title.tsx b/packages/session-ui/src/components/tool-status-title.tsx index 589effc1f052..ef9ef6da61f5 100644 --- a/packages/session-ui/src/components/tool-status-title.tsx +++ b/packages/session-ui/src/components/tool-status-title.tsx @@ -59,24 +59,21 @@ export function ToolStatusTitle(props: { const animate = () => { const first = contentWidth(widthRef) + const next = props.active finish() - setState("animating", true) - setState("active", props.active) - const last = contentWidth(props.active ? activeRef : doneRef) - if (!first || !last) { - finish() - return - } + setState("active", next) + if (!first) return + setState("animating", true) setState("width", first) - if (first === last) { - finishTimer = setTimeout(finish, 600) - return - } - frame = requestAnimationFrame(() => { frame = undefined - setState("width", last) + const last = contentWidth(next ? activeRef : doneRef) + if (!last) { + finish() + return + } + if (first !== last) setState("width", last) finishTimer = setTimeout(finish, 600) }) } From 8f1e13f299ac249805e158b23dde4a989609300c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:38:47 +1000 Subject: [PATCH 056/303] fix(core): skip fff in node runtime (#34353) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- packages/core/src/filesystem/search.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 72c9128cfd16..95bb0197a93b 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -230,7 +230,11 @@ export const fffLayer = Layer.effect( }), ) -const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) +const isNodeRuntime = () => process.versions.bun === undefined + +const layer = Layer.unwrap( + Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || isNodeRuntime() || !Fff.available() ? ripgrepLayer : fffLayer)), +) export const locationLayer = layer From cb3e28d207ecc0e103e816574c926a40e6259ced Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:47:00 +1000 Subject: [PATCH 057/303] fix(app): collapse review pane by default (#34165) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/context/layout.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index eb97b400c89f..2e4684bbcea9 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -28,6 +28,7 @@ const DEFAULT_SIDEBAR_WIDTH = 344 const DEFAULT_FILE_TREE_WIDTH = 200 const DEFAULT_SESSION_WIDTH = 600 const DEFAULT_TERMINAL_HEIGHT = 280 +const DEFAULT_REVIEW_PANEL_OPENED = false export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] export function getAvatarColors(key?: string) { @@ -210,7 +211,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( if (!isRecord(review)) return review if (typeof review.panelOpened === "boolean") return review - const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true + const opened = + isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : DEFAULT_REVIEW_PANEL_OPENED return { ...review, panelOpened: opened, @@ -279,7 +281,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, review: { diffStyle: "split" as ReviewDiffStyle, - panelOpened: true, + panelOpened: DEFAULT_REVIEW_PANEL_OPENED, }, fileTree: { opened: false, @@ -662,7 +664,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( diffStyle: createMemo(() => store.review?.diffStyle ?? "split"), setDiffStyle(diffStyle: ReviewDiffStyle) { if (!store.review) { - setStore("review", { diffStyle, panelOpened: true }) + setStore("review", { diffStyle, panelOpened: DEFAULT_REVIEW_PANEL_OPENED }) return } setStore("review", "diffStyle", diffStyle) @@ -777,7 +779,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const key = createSessionKeyReader(sessionKey, ensureKey) const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) - const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true) + const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) function setTerminalOpened(next: boolean) { const current = store.terminal @@ -798,7 +800,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return } - const value = current.panelOpened ?? true + const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED if (value === next) return setStore("review", "panelOpened", next) } From 1f37c265747ce233a9690c6e664dde3a60e8086a Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 22:53:42 -0400 Subject: [PATCH 058/303] refactor(opencode): use layer nodes in remaining harnesses (#34516) --- packages/opencode/test/effect/runtime-flags.test.ts | 3 ++- .../test/session/structured-output-integration.test.ts | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 2e1226b38ba4..19b249182b39 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -1,10 +1,11 @@ import { describe, expect } from "bun:test" import { ConfigProvider, Effect, Layer } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { it } from "../lib/effect" const fromConfig = (input: Record) => - RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) + AppNodeBuilder.build(RuntimeFlags.node).pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input)))) const readFlags = RuntimeFlags.Service.useSync((flags) => flags) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 319b3bd728e7..9c97a94bb1d4 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test" import { SessionV1 } from "@opencode-ai/core/v1/session" import { Ripgrep } from "@opencode-ai/core/ripgrep" -import { Effect, Layer } from "effect" +import { Effect } from "effect" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Session } from "@/session/session" import { SessionPrompt } from "../../src/session/prompt" import { MessageV2 } from "../../src/session/message-v2" @@ -10,7 +12,7 @@ import { testEffect } from "../lib/effect" // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY const it = testEffect( - Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer).pipe(Layer.provide(Ripgrep.defaultLayer)), + AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node])), ) const live = hasApiKey ? it.instance : it.instance.skip From ce82b210737d5250dcdef3a2a4b800f83c65d8dd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 30 Jun 2026 02:55:10 +0000 Subject: [PATCH 059/303] chore: generate --- .../test/session/structured-output-integration.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/test/session/structured-output-integration.test.ts b/packages/opencode/test/session/structured-output-integration.test.ts index 9c97a94bb1d4..df5755f89f5e 100644 --- a/packages/opencode/test/session/structured-output-integration.test.ts +++ b/packages/opencode/test/session/structured-output-integration.test.ts @@ -11,9 +11,7 @@ import { testEffect } from "../lib/effect" // Skip tests if no API key is available const hasApiKey = !!process.env.ANTHROPIC_API_KEY -const it = testEffect( - AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node])), -) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([SessionPrompt.node, Session.node, Ripgrep.node]))) const live = hasApiKey ? it.instance : it.instance.skip describe("StructuredOutput Integration", () => { From 018ce34b3906ce2ddc3a163a3487520ee2de5c57 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:58:57 +1000 Subject: [PATCH 060/303] revert(core): skip fff in node runtime (#34549) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/core/src/filesystem/search.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 95bb0197a93b..72c9128cfd16 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -230,11 +230,7 @@ export const fffLayer = Layer.effect( }), ) -const isNodeRuntime = () => process.versions.bun === undefined - -const layer = Layer.unwrap( - Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || isNodeRuntime() || !Fff.available() ? ripgrepLayer : fffLayer)), -) +const layer = Layer.unwrap(Effect.sync(() => (Flag.OPENCODE_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer))) export const locationLayer = layer From 91561bbdfb90688a7e0a6552e388104753014792 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 23:03:57 -0400 Subject: [PATCH 061/303] refactor(opencode): remove app service layer exports (#34517) --- packages/opencode/src/acp/directory.ts | 10 +----- packages/opencode/src/acp/usage.ts | 8 +---- packages/opencode/src/agent/agent.ts | 11 +----- packages/opencode/src/command/index.ts | 8 +---- .../opencode/src/control-plane/workspace.ts | 15 +------- packages/opencode/src/lsp/lsp.ts | 8 +---- packages/opencode/src/mcp/index.ts | 12 +------ packages/opencode/src/plugin/index.ts | 8 +---- packages/opencode/src/project/bootstrap.ts | 15 +------- packages/opencode/src/provider/auth.ts | 6 +--- packages/opencode/src/provider/provider.ts | 14 +------- packages/opencode/src/session/compaction.ts | 15 +------- packages/opencode/src/session/instruction.ts | 10 +----- packages/opencode/src/session/llm.ts | 19 ++-------- packages/opencode/src/session/processor.ts | 19 +--------- packages/opencode/src/session/prompt.ts | 36 +------------------ packages/opencode/src/session/revert.ts | 13 +------ packages/opencode/src/session/run-state.ts | 7 +--- packages/opencode/src/session/session.ts | 15 +------- packages/opencode/src/session/status.ts | 4 +-- packages/opencode/src/session/summary.ts | 11 +----- packages/opencode/src/session/system.ts | 8 +---- packages/opencode/src/session/todo.ts | 4 +-- packages/opencode/src/share/session.ts | 9 +---- packages/opencode/src/share/share-next.ts | 12 +------ packages/opencode/src/skill/index.ts | 11 +----- packages/opencode/src/tool/registry.ts | 30 ++-------------- packages/opencode/src/worktree/index.ts | 17 +-------- 28 files changed, 31 insertions(+), 324 deletions(-) diff --git a/packages/opencode/src/acp/directory.ts b/packages/opencode/src/acp/directory.ts index ed6270debd3d..2301c8929a88 100644 --- a/packages/opencode/src/acp/directory.ts +++ b/packages/opencode/src/acp/directory.ts @@ -141,7 +141,7 @@ export const loaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const loader = yield* Loader @@ -201,14 +201,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(loaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), -) - export const loaderNode = LayerNode.make({ service: Loader, layer: loaderLayer, diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index 32b46e8b9187..737275768e2d 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -135,7 +135,7 @@ export const contextLimitLoaderLayer = Layer.effect( }), ) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const messageLoader = yield* MessageLoader @@ -226,12 +226,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(contextLimitLoaderLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), -) - export const messageLoaderNode = LayerNode.unbound(MessageLoader, Node.tags.values.global) export const contextLimitLoaderNode = makeGlobalNode({ diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 1b079a7db354..536a642fe49f 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -85,7 +85,7 @@ export class Service extends Context.Service()("@opencode/Ag export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -438,15 +438,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Plugin.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(locationServiceMapLayer), -) - const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 9dd882db131e..057754cd9ef8 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -55,7 +55,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Command") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -172,12 +172,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(Skill.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node, MCP.node, Skill.node] }) export * as Command from "." diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5952e12e5df..a11af085f1f9 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -150,7 +150,7 @@ export class Service extends Context.Service()("@opencode/Wo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service @@ -893,19 +893,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionPrompt.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Vcs.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - const TIMEOUT = 5000 type HistoryEvent = { diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 16fb10f7bfd3..1ab3a0b82369 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -135,7 +135,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/LSP") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -496,12 +496,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), -) - export * as Diagnostic from "./diagnostic" export const node = LayerNode.make({ diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index c72fb47b20f7..e574e20fbaac 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -194,7 +194,7 @@ export class Service extends Context.Service()("@opencode/MC export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -1003,16 +1003,6 @@ export const layer = Layer.effect( export type AuthStatus = "authenticated" | "expired" | "not_authenticated" -// --- Per-service runtime --- - -export const defaultLayer = layer.pipe( - Layer.provide(McpAuth.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 0bef89642991..1558c707c3f7 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -120,7 +120,7 @@ async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -305,12 +305,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 2e32b4b11c36..acf0caed3cdf 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -14,7 +14,7 @@ import { Service } from "./bootstrap-service" export { Service } from "./bootstrap-service" export type { Interface } from "./bootstrap-service" -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { // Yield each bootstrap dep at layer init so `run` itself has R = never. @@ -49,19 +49,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide([ - Config.defaultLayer, - Format.defaultLayer, - LSP.defaultLayer, - Plugin.defaultLayer, - Project.defaultLayer, - ShareNext.defaultLayer, - Snapshot.defaultLayer, - Vcs.defaultLayer, - ]), -) - export const node = makeGlobalNode({ service: Service, layer: layer, diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 6f21b75eaeb4..416a3ea7b0db 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -106,7 +106,7 @@ export class Service extends Context.Service()("@opencode/Pr export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const auth = yield* Auth.Service @@ -224,10 +224,6 @@ export const layer: Layer.Layer = }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)), -) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [Auth.node, Plugin.node] }) export * as ProviderAuth from "./auth" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 409d8900575a..0ece8e9ead91 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1299,7 +1299,7 @@ function modelSuggestions(provider: Info | undefined, modelID: ModelV2.ID, enabl .map((item) => item.id) } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -1949,18 +1949,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(ModelsDev.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] const smallModelFamilyPriority = ["gemini-flash", "gpt-nano", "claude-haiku"] export function sort(models: T[]) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 5bdbfa03421b..fa439e4efffa 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -153,7 +153,7 @@ export class Service extends Context.Service()("@opencode/Se export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -544,19 +544,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - ), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index df4c87df5c56..7f593550d468 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -45,7 +45,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Instruction") {} -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, FSUtil.Service | Config.Service | Global.Service | HttpClient.HttpClient | RuntimeFlags.Service @@ -224,14 +224,6 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function loaded(messages: SessionV1.WithParts[]) { return extract(messages) } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 199b4694d6b8..a99f8acff20c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -8,7 +8,7 @@ import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" import type { LLMEvent } from "@opencode-ai/llm" -import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" +import { LLMClient } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" @@ -384,26 +384,11 @@ const live: Layer.Layer< }), ) -export const layer = live.pipe(Layer.provide(Permission.defaultLayer), Layer.provide(EventV2Bridge.defaultLayer)) - -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide( - LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer))), - ), - Layer.provide(RuntimeFlags.defaultLayer), - ), -) - export const hasToolCalls = LLMRequestPrep.hasToolCalls export const node = LayerNode.make({ service: Service, - layer: layer, + layer: live, deps: [ Auth.node, Config.node, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 55ffda5eb849..d09e6ac711c0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -78,7 +78,7 @@ type StreamEvent = LLMEvent export class Service extends Context.Service()("@opencode/SessionProcessor") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const session = yield* Session.Service @@ -694,23 +694,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(LLM.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - ), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 60937588120f..6734a1f5ac13 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -110,7 +110,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionPrompt") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const status = yield* SessionStatus.Service @@ -1490,40 +1490,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), - Layer.provide(SessionCompaction.defaultLayer), - Layer.provide(SessionProcessor.defaultLayer), - Layer.provide(Command.defaultLayer), - Layer.provide(Permission.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(ToolRegistry.defaultLayer), - Layer.provide(Truncate.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - Layer.provide(Image.defaultLayer), - Layer.provide( - Layer.mergeAll( - Agent.defaultLayer, - Database.defaultLayer, - SystemPrompt.defaultLayer, - LLM.defaultLayer, - CrossSpawnSpawner.defaultLayer, - RuntimeFlags.defaultLayer, - EventV2Bridge.defaultLayer, - ), - ), - ), -) const ModelRef = Schema.Struct({ providerID: ProviderV2.ID, modelID: ModelV2.ID, diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 4ddf38e67d4e..79fef2cda97c 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -25,7 +25,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionRevert") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -137,17 +137,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(SessionRunState.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(Storage.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(SessionSummary.defaultLayer), - ), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/session/run-state.ts b/packages/opencode/src/session/run-state.ts index 17b0efaeaa7d..5cefdd04a3f3 100644 --- a/packages/opencode/src/session/run-state.ts +++ b/packages/opencode/src/session/run-state.ts @@ -26,7 +26,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionRunState") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const background = yield* BackgroundJob.Service @@ -108,11 +108,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(SessionStatus.defaultLayer), -) - const cancelBackgroundJobs = Effect.fn("SessionRunState.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, sessionID: SessionID, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index f4edf50ed688..0882f1794101 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -488,7 +488,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" permission?: Info["permission"] | null } -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service @@ -940,19 +940,6 @@ export const layer: Layer.Layer< }), ) -export const defaultLayer = layer.pipe( - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide( - SessionV2.defaultLayer.pipe( - Layer.provide(SessionExecutionLocal.defaultLayer), - Layer.provide(locationServiceMapLayer), - ), - ), - Layer.provide(RuntimeFlags.defaultLayer), -) - const cancelBackgroundJobs = Effect.fn("Session.cancelBackgroundJobs")(function* ( background: BackgroundJob.Interface, sessionID: SessionID, diff --git a/packages/opencode/src/session/status.ts b/packages/opencode/src/session/status.ts index 2a7830ba71b2..11140acfeef5 100644 --- a/packages/opencode/src/session/status.ts +++ b/packages/opencode/src/session/status.ts @@ -18,7 +18,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionStatus") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -51,8 +51,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as SessionStatus from "./status" diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index e36fe6116f88..6484730d0bd5 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -71,7 +71,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionSummary") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Session.Service @@ -145,15 +145,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer.pipe( - Layer.provide(Session.defaultLayer), - Layer.provide(Snapshot.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Config.defaultLayer), - ), -) - export const DiffInput = Schema.Struct({ sessionID: SessionID, messageID: Schema.optional(MessageID), diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index a4664b9b3763..6e5b83ec31f9 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -47,7 +47,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SystemPrompt") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const skill = yield* Skill.Service @@ -128,12 +128,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(MCP.defaultLayer), - Layer.provide(locationServiceMapLayer), -) - const locationServiceMapNode = LayerNode.make({ service: LocationServiceMap.Service, layer: locationServiceMapLayer, diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts index bdcbf0a6e3e8..cd80828de6f0 100644 --- a/packages/opencode/src/session/todo.ts +++ b/packages/opencode/src/session/todo.ts @@ -20,7 +20,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionTodo") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -69,8 +69,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer), Layer.provide(Database.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node, Database.node] }) export * as Todo from "./todo" diff --git a/packages/opencode/src/share/session.ts b/packages/opencode/src/share/session.ts index 91f4b1feef9c..a4ef77fbfd2f 100644 --- a/packages/opencode/src/share/session.ts +++ b/packages/opencode/src/share/session.ts @@ -14,7 +14,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SessionShare") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const cfg = yield* Config.Service @@ -49,13 +49,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(ShareNext.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index d10254808678..60112e10d964 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -109,7 +109,7 @@ function key(item: Data) { } } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const account = yield* Account.Service @@ -362,16 +362,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Provider.defaultLayer), - Layer.provide(Session.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 14a378ac1671..5a04ec213994 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -247,7 +247,7 @@ const loadSkills = Effect.fnUntraced(function* ( export class Service extends Context.Service()("@opencode/Skill") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const discovery = yield* Discovery.Service @@ -318,15 +318,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Discovery.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.layer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export function fmt(list: Info[], opts: { verbose: boolean }) { const described = list.filter((skill) => skill.description !== undefined) if (described.length === 0) return "No skills are currently available." diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 553fa1ebaf5c..50a0d4d242b6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -33,7 +33,6 @@ import { Glob } from "@opencode-ai/core/util/glob" import path from "path" import { pathToFileURL } from "url" import { Effect, Layer, Context } from "effect" -import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Format } from "../format" @@ -80,7 +79,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolRegistry") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -315,30 +314,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = Layer.suspend(() => - layer - .pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - Layer.provide(Question.defaultLayer), - Layer.provide(Todo.defaultLayer), - Layer.provide(Skill.defaultLayer), - Layer.provide(Agent.defaultLayer), - Layer.provide(Session.defaultLayer), - Layer.provide(BackgroundJob.defaultLayer), - Layer.provide(Provider.defaultLayer), - Layer.provide(LSP.defaultLayer), - Layer.provide(Instruction.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(Format.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Truncate.defaultLayer), - ) - .pipe(Layer.provide(Database.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer)), -) - function isZodType(value: unknown): value is z.ZodType { return typeof value === "object" && value !== null && "_zod" in value } @@ -417,7 +392,7 @@ function isJsonSchemaObject(value: unknown): value is Record { export const node = LayerNode.make({ service: Service, - layer: layer.pipe(Layer.provide(Ripgrep.defaultLayer)), + layer, deps: [ Config.node, Plugin.node, @@ -438,6 +413,7 @@ export const node = LayerNode.make({ Truncate.node, RuntimeFlags.node, Database.node, + Ripgrep.node, ], }) diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index e6f939be6206..a0c43ed2c340 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -1,7 +1,6 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { path } from "@opencode-ai/core/effect/app-node-platform" import { Global } from "@opencode-ai/core/global" -import { InstanceBootstrap } from "@/project/bootstrap" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Database } from "@opencode-ai/core/database/database" @@ -14,7 +13,6 @@ import { GlobalBus } from "@/bus/global" import { Git } from "@/git" import { Effect, Layer, Path, Schema, Scope, Context } from "effect" import { ChildProcess } from "effect/unstable/process" -import { NodePath } from "@effect/platform-node" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" @@ -131,7 +129,7 @@ export class Service extends Context.Service()("@opencode/Wo type GitResult = { code: number; text: string; stderr: string } -export const layer: Layer.Layer< +const layer: Layer.Layer< Service, never, | FSUtil.Service @@ -616,19 +614,6 @@ export const layer: Layer.Layer< }), ) -export const appLayer = layer.pipe( - Layer.provide(Git.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(Project.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - -export const defaultLayer = appLayer.pipe( - Layer.provide(LayerNode.compile(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]])), -) - export const node = LayerNode.make({ service: Service, layer: layer, From 451876b0bddae9c48ea2c14651d9741e364870d7 Mon Sep 17 00:00:00 2001 From: James Long Date: Mon, 29 Jun 2026 23:32:16 -0400 Subject: [PATCH 062/303] refactor(opencode): remove core service layer exports (#34518) --- packages/opencode/src/account/account.ts | 4 +--- packages/opencode/src/account/repo.ts | 4 +--- packages/opencode/src/acp/session.ts | 4 +--- packages/opencode/src/auth/index.ts | 4 +--- packages/opencode/src/background/job.ts | 4 +--- packages/opencode/src/config/config.ts | 12 +----------- packages/opencode/src/effect/runtime-flags.ts | 4 +--- packages/opencode/src/env/index.ts | 4 +--- packages/opencode/src/event-v2-bridge.ts | 4 +--- packages/opencode/src/format/index.ts | 8 +------- packages/opencode/src/git/index.ts | 4 +--- packages/opencode/src/image/image.ts | 4 +--- packages/opencode/src/mcp/auth.ts | 4 +--- packages/opencode/src/permission/index.ts | 4 +--- packages/opencode/src/project/instance-store.ts | 4 +--- packages/opencode/src/project/project.ts | 13 +------------ packages/opencode/src/project/vcs.ts | 4 +--- packages/opencode/src/question/index.ts | 4 +--- packages/opencode/src/skill/discovery.ts | 8 +------- packages/opencode/src/snapshot/index.ts | 8 +------- packages/opencode/src/storage/storage.ts | 4 +--- packages/opencode/src/tool/truncate.ts | 4 +--- 22 files changed, 22 insertions(+), 95 deletions(-) diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index 5772355ac45f..af8ef761eb2c 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -186,7 +186,7 @@ export class Service extends Context.Service()("@opencode/Ac export const use = serviceUse(Service) -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const repo = yield* AccountRepo.Service @@ -456,8 +456,6 @@ export const layer: Layer.Layer()("@opencode/Ac export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service @@ -166,8 +166,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] }) export * as AccountRepo from "./repo" diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index ea76cd91f86a..f6a7a56bac2f 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -94,7 +94,7 @@ export class Service extends Context.Service()("@opencode/AC type State = Map -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* Ref.make(new Map()) @@ -201,8 +201,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - export const node = LayerNode.make({ service: Service, layer, deps: [] }) function makeSession(input: StoreInput): Info { diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 0ec33eea0416..a133e88498d5 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -49,7 +49,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Auth") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fsys = yield* FSUtil.Service @@ -92,8 +92,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Auth from "." diff --git a/packages/opencode/src/background/job.ts b/packages/opencode/src/background/job.ts index 8095826efc64..261f5ad02bed 100644 --- a/packages/opencode/src/background/job.ts +++ b/packages/opencode/src/background/job.ts @@ -15,7 +15,7 @@ export { } from "@opencode-ai/core/background-job" /** Keeps the legacy service instance-scoped while sharing the core registry engine. */ -export const layer = Layer.effect( +const layer = Layer.effect( CoreBackgroundJob.Service, Effect.gen(function* () { const state = yield* InstanceState.make(() => CoreBackgroundJob.make) @@ -32,8 +32,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - export const node = LayerNode.make({ service: CoreBackgroundJob.Service, layer, deps: [] }) export * as BackgroundJob from "./job" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4db0dbcef3cd..35bd2e0cce9d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -172,7 +172,7 @@ function writableGlobal(info: Info) { return next } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -671,16 +671,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Npm.defaultLayer), - Layer.provide(FetchHttpClient.layer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 1f8e59011192..f2e5e53192a5 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -71,9 +71,7 @@ export const layer = (overrides: Partial = {}) => }), ).pipe(Layer.provide(emptyConfigLayer)) -export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie) - -export const node = LayerNode.make({ service: Service, layer: defaultLayer, deps: [] }) +export const node = LayerNode.make({ service: Service, layer: Service.defaultLayer.pipe(Layer.orDie), deps: [] }) export * as RuntimeFlags from "./runtime-flags" import { LayerNode } from "@opencode-ai/core/effect/layer-node" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index 98dd0b3004c3..5879f27fe37e 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -16,7 +16,7 @@ export class Service extends Context.Service()("@opencode/En export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const state = yield* InstanceState.make(Effect.fn("Env.state")(() => Effect.succeed({ ...process.env }))) @@ -36,8 +36,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer - export const node = LayerNode.make({ service: Service, layer: layer, deps: [] }) export * as Env from "." diff --git a/packages/opencode/src/event-v2-bridge.ts b/packages/opencode/src/event-v2-bridge.ts index 0231207334d3..8e65f2752df7 100644 --- a/packages/opencode/src/event-v2-bridge.ts +++ b/packages/opencode/src/event-v2-bridge.ts @@ -11,7 +11,7 @@ import { Context, Effect, Layer } from "effect" export class Service extends Context.Service()("@opencode/EventV2Bridge") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2.Service @@ -66,8 +66,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2.node] }) export * as EventV2Bridge from "./event-v2-bridge" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 413c147d641f..4288f83efce9 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -28,7 +28,7 @@ export class Service extends Context.Service()("@opencode/Fo export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -194,12 +194,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(Config.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const node = LayerNode.make({ service: Service, layer: layer, diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 81358ef960b3..968e394a959f 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -100,7 +100,7 @@ const kind = (code: string): Kind => { export class Service extends Context.Service()("@opencode/Git") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const appProcess = yield* AppProcess.Service @@ -343,8 +343,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(AppProcess.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [AppProcess.node] }) export * as Git from "." diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index a2edf352e480..744bd3fc9591 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -56,7 +56,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Image") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service @@ -167,8 +167,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [Config.node] }) export * as Image from "./image" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index f877824e0218..808aa3029625 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -56,7 +56,7 @@ export class Service extends Context.Service()("@opencode/Mc export const use = serviceUse(Service) -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -158,8 +158,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, EffectFlock.node] }) export * as McpAuth from "./auth" diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index e1eb13ccc80e..396d19201815 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -39,7 +39,7 @@ export function evaluate(permission: string, pattern: string, ...rulesets: Permi export class Service extends Context.Service()("@opencode/Permission") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -213,8 +213,6 @@ export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set } -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const project = yield* Project.Service @@ -202,8 +202,6 @@ export const layer: Layer.Layer()("@opencode/Pr type GitResult = { code: number; text: string; stderr: string } -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -463,17 +463,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe( - Layer.provide(EventV2Bridge.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.provide(ProjectDirectories.defaultLayer), - Layer.provide(AppProcess.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Database.defaultLayer), - Layer.provide(RuntimeFlags.defaultLayer), -) - export const use = serviceUse(Service) export const node = LayerNode.make({ diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 164bcbbd2dfd..eca56c0501a1 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -295,7 +295,7 @@ interface State { export class Service extends Context.Service()("@opencode/Vcs") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const git = yield* Git.Service @@ -418,8 +418,6 @@ export const layer: Layer.Layer()("@opencode/Question") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -156,8 +156,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [EventV2Bridge.node] }) export * as Question from "." diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index ba7d2e25a964..b56a67610f82 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -26,7 +26,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/SkillDiscovery") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -135,12 +135,6 @@ export const layer: Layer.Layer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(NodePath.layer), -) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node, path, httpClient] }) export * as Discovery from "./discovery" diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 0ab48604a58a..4da9bc3ca864 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -46,7 +46,7 @@ export interface Interface { export class Service extends Context.Service()("@opencode/Snapshot") {} -export const layer: Layer.Layer = Layer.effect( +const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -798,12 +798,6 @@ export const layer: Layer.Layer()("@opencode/Truncate") {} -export const layer = Layer.effect( +const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service @@ -151,8 +151,6 @@ export const layer = Layer.effect( }), ) -export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(NodePath.layer)) - export const node = LayerNode.make({ service: Service, layer: layer, deps: [FSUtil.node] }) export * as Truncate from "./truncate" From ba41dadc91ab125fe1397677318d3470d88df9fb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:48:58 -0500 Subject: [PATCH 063/303] ci: skip issue actions for team authors (#34556) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Aiden Cline --- .github/workflows/compliance-close.yml | 28 +++++++++++++++++-- .github/workflows/duplicate-issues.yml | 38 ++++++++++++++++++++++++++ .github/workflows/triage.yml | 19 +++++++++++++ script/github/close-issues.ts | 19 +++++++++++++ 4 files changed, 102 insertions(+), 2 deletions(-) diff --git a/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml index a6e4b9215fe4..a83824e5cf60 100644 --- a/.github/workflows/compliance-close.yml +++ b/.github/workflows/compliance-close.yml @@ -35,13 +35,37 @@ jobs: const now = Date.now(); const twoHours = 2 * 60 * 60 * 1000; const orgMemberAssociations = new Set(['OWNER', 'MEMBER']); + const agentLogin = 'opencode-agent[bot]'; + const { data: file } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/TEAM_MEMBERS', + ref: 'dev', + }); + const teamMembers = new Set( + Buffer.from(file.content, 'base64') + .toString() + .split('\n') + .map((line) => line.trim().toLowerCase()) + .filter(Boolean) + ); + + function isExempt(item) { + const login = item.user?.login?.toLowerCase(); + return ( + login === agentLogin || + orgMemberAssociations.has(item.author_association) || + (login && teamMembers.has(login)) + ); + } for (const item of items) { const isPR = !!item.pull_request; const kind = isPR ? 'PR' : 'issue'; + const login = item.user?.login; - if (orgMemberAssociations.has(item.author_association)) { - core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`); + if (isExempt(item)) { + core.info(`Skipping ${kind} #${item.number}; author ${login || 'unknown'} is exempt`); try { await github.rest.issues.removeLabel({ owner: context.repo.owner, diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index b4500e0cc1e5..3972247dafc6 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -17,12 +17,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Check duplicates and compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -132,12 +151,31 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - uses: ./.github/actions/setup-bun + if: steps.author.outputs.skip != 'true' - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Recheck compliance + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 27852a12ce4d..0350e43877c4 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -16,13 +16,32 @@ jobs: with: fetch-depth: 1 + - name: Check exempt issue author + id: author + run: | + LOGIN="${{ github.event.issue.user.login }}" + ASSOCIATION="${{ github.event.issue.author_association }}" + + if [ "$LOGIN" = "opencode-agent[bot]" ] || + [ "$ASSOCIATION" = "OWNER" ] || + [ "$ASSOCIATION" = "MEMBER" ] || + grep -qxiF "$LOGIN" .github/TEAM_MEMBERS; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "Skipping issue automation for exempt author: $LOGIN ($ASSOCIATION)" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Bun + if: steps.author.outputs.skip != 'true' uses: ./.github/actions/setup-bun - name: Install opencode + if: steps.author.outputs.skip != 'true' run: curl -fsSL https://opencode.ai/install | bash - name: Triage issue + if: steps.author.outputs.skip != 'true' env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts index 9e1f597951f1..12d7aa191f85 100755 --- a/script/github/close-issues.ts +++ b/script/github/close-issues.ts @@ -11,10 +11,20 @@ if (!token) { } const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000) +const agentLogin = "opencode-agent[bot]" +const teamMembers = new Set( + (await Bun.file(new URL("../../.github/TEAM_MEMBERS", import.meta.url)).text()) + .split("\n") + .map((line) => line.trim().toLowerCase()) + .filter(Boolean), +) +const teamAssociations = new Set(["OWNER", "MEMBER"]) type Issue = { number: number updated_at: string + author_association: string + user: { login: string } | null } const headers = { @@ -24,6 +34,11 @@ const headers = { "X-GitHub-Api-Version": "2022-11-28", } +function shouldSkip(i: Issue) { + const login = i.user?.login.toLowerCase() + return login === agentLogin || (login ? teamMembers.has(login) : false) || teamAssociations.has(i.author_association) +} + async function close(num: number) { const base = `https://api.github.com/repos/${repo}/issues/${num}` @@ -63,6 +78,10 @@ async function main() { for (const i of all) { const updated = new Date(i.updated_at) if (updated < cutoff) { + if (shouldSkip(i)) { + console.log(`Skipping stale issue #${i.number}; author ${i.user?.login ?? "unknown"} is exempt`) + continue + } stale.push(i.number) } else { console.log(`\nFound fresh issue #${i.number}, stopping`) From 0a5bed2bc2549d988ba969765ec6722615c56e01 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 30 Jun 2026 01:08:29 -0400 Subject: [PATCH 064/303] feat(tui): add yolo permission mode (#33279) Co-authored-by: Aiden Cline --- packages/opencode/src/cli/cmd/run.ts | 14 ++++- packages/opencode/src/cli/cmd/tui.ts | 7 +++ packages/tui/src/app.tsx | 57 ++++++++++++------- packages/tui/src/component/prompt/index.tsx | 3 + packages/tui/src/context/args.tsx | 1 + packages/tui/src/context/local.tsx | 8 ++- packages/tui/src/context/permission.tsx | 26 +++++++++ packages/tui/src/context/sync.tsx | 13 ++++- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 17 +++--- packages/web/src/content/docs/cli.mdx | 3 +- packages/web/src/content/docs/permissions.mdx | 20 +++++++ 11 files changed, 133 insertions(+), 36 deletions(-) create mode 100644 packages/tui/src/context/permission.tsx diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index fad09c3a7add..f7787e970577 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -233,7 +233,14 @@ export const RunCommand = effectCmd({ hidden: true, describe: "cap visible interactive replay to the newest N messages", }) - .option("dangerously-skip-permissions", { + .option("interactive", { + alias: ["i"], + type: "boolean", + describe: "run in direct interactive split-footer mode", + default: false, + }) + .option("auto", { + alias: ["yolo", "dangerously-skip-permissions"], type: "boolean", describe: "auto-approve permissions that are not explicitly denied (dangerous!)", default: false, @@ -780,7 +787,7 @@ export const RunCommand = effectCmd({ const permission = event.properties if (permission.sessionID !== sessionID) continue - if (args["dangerously-skip-permissions"]) { + if (args.auto) { await client.permission.reply({ requestID: permission.id, reply: "once", @@ -981,9 +988,12 @@ export async function runMini(input: MiniCommandInput) { variant: undefined, thinking: undefined, mini: true, + interactive: false, replay: input.replay ?? true, "replay-limit": input.replayLimit, replayLimit: input.replayLimit, + auto: false, + yolo: false, "dangerously-skip-permissions": false, dangerouslySkipPermissions: false, demo: input.demo ?? false, diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a4443d2b03d9..7b92919d6185 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -105,6 +105,12 @@ export const TuiThreadCommand = cmd({ type: "string", describe: "agent to use", }) + .option("auto", { + alias: ["yolo", "dangerously-skip-permissions"], + type: "boolean", + describe: "auto-approve permissions that are not explicitly denied (dangerous!)", + default: false, + }) .option("mini", { type: "boolean", describe: "start the minimal interactive interface", @@ -272,6 +278,7 @@ export const TuiThreadCommand = cmd({ model: args.model, prompt, fork: args.fork, + auto: args.auto, }, }), ) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index fecf71b6c8c8..5788a947a11d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -37,6 +37,7 @@ import { SyncProvider, useSync } from "./context/sync" import { DataProvider } from "./context/data" import { LocationProvider } from "./context/location" import { LocalProvider, useLocal } from "./context/local" +import { PermissionProvider } from "./context/permission" import { DialogModel } from "./component/dialog-model" import { useConnected } from "./component/use-connected" import { DialogMcp } from "./component/dialog-mcp" @@ -296,34 +297,36 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { headers={input.headers} events={input.events} > - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + @@ -926,6 +929,16 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi dialog.clear() }, }, + { + name: "permission.mode", + title: + local.permission.mode === "auto" ? "Disable auto-approve permissions" : "Enable auto-approve permissions", + category: "System", + run: () => { + local.permission.toggle() + dialog.clear() + }, + }, ].map((command) => ({ namespace: "palette", ...command, diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0b87df9241b6..0fb04c5c108b 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1442,6 +1442,9 @@ export function Prompt(props: PromptProps) { {store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)} + + auto + · diff --git a/packages/tui/src/context/args.tsx b/packages/tui/src/context/args.tsx index 8a229ffaba69..9a528f2f0ece 100644 --- a/packages/tui/src/context/args.tsx +++ b/packages/tui/src/context/args.tsx @@ -7,6 +7,7 @@ export interface Args { continue?: boolean sessionID?: string fork?: boolean + auto?: boolean } export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({ diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 39e7bc111d55..a05d315166ed 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -12,6 +12,7 @@ import { readJson, writeJsonAtomic } from "../util/persistence" import { useTheme } from "./theme" import { useToast } from "../ui/toast" import { useRoute } from "./route" +import { usePermission } from "./permission" export type LocalTheme = { secondary: RGBA @@ -56,6 +57,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const theme = useTheme().theme const route = useRoute() const paths = useTuiPaths() + const args = useArgs() + const event = useEvent() + const permission = usePermission() function isModelValid(model: { providerID: string; modelID: string }) { const provider = sync.data.provider.find((item) => item.id === model.providerID) @@ -190,7 +194,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (state.pending) save() }) - const args = useArgs() const fallbackModel = createMemo(() => { if (args.model) { const { providerID, modelID } = parseModel(args.model) @@ -446,8 +449,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (state.pending) save() }) - const event = useEvent() - const slots = createMemo(() => { const existing = new Set(sync.data.session.filter((x) => x.parentID === undefined).map((x) => x.id)) return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9) @@ -534,6 +535,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ agent, mcp, session, + permission, } return result }, diff --git a/packages/tui/src/context/permission.tsx b/packages/tui/src/context/permission.tsx new file mode 100644 index 000000000000..13a6ba822339 --- /dev/null +++ b/packages/tui/src/context/permission.tsx @@ -0,0 +1,26 @@ +import { createStore } from "solid-js/store" +import { useArgs } from "./args" +import { createSimpleContext } from "./helper" + +export type PermissionMode = "auto" | "normal" + +export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ + name: "Permission", + init: () => { + const args = useArgs() + const [store, setStore] = createStore<{ mode: PermissionMode }>({ + mode: args.auto ? "auto" : "normal", + }) + return { + get mode() { + return store.mode + }, + set(mode: PermissionMode) { + setStore("mode", mode) + }, + toggle() { + setStore("mode", (mode) => (mode === "auto" ? "normal" : "auto")) + }, + } + }, +}) diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 03db8784de49..d0511c5183e4 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -31,6 +31,7 @@ import { useArgs } from "./args" import { batch, onMount } from "solid-js" import path from "path" import { useKV } from "./kv" +import { usePermission } from "./permission" const emptyConsoleState: ConsoleState = { consoleManagedProviders: [], @@ -59,6 +60,7 @@ export const { init: () => { const startup = useTuiStartup() const kv = useKV() + const permission = usePermission() const [store, setStore] = createStore<{ status: "loading" | "partial" | "complete" provider: Provider[] @@ -165,7 +167,7 @@ export const { .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) } - event.subscribe((event, { workspace }) => { + event.subscribe((event, { directory, workspace }) => { switch (event.type) { case "server.instance.disposed": void bootstrap() @@ -187,6 +189,15 @@ export const { case "permission.asked": { const request = event.properties + if (permission.mode === "auto") { + void sdk.client.permission.reply({ + requestID: request.id, + reply: "once", + directory, + workspace, + }) + break + } const requests = store.permission[request.sessionID] if (!requests) { setStore("permission", request.sessionID, [request]) diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index 5b7872ee3070..f81c4401a61a 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -6,6 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" +import { PermissionProvider } from "../../../../src/context/permission" import { ExitProvider } from "../../../../src/context/exit" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { TestTuiContexts } from "../../../fixture/tui-environment" @@ -48,13 +49,15 @@ export async function mount(override?: FetchHandler, state?: string) { - - {}}> - - - - - + + + {}}> + + + + + + diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 0f9e5e3b1f48..b44a27f4e1d2 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -37,6 +37,7 @@ opencode [project] | {"--prompt"} | | Prompt to use | | {"--model"} | `-m` | Model to use in the form of provider/model | | {"--agent"} | | Agent to use | +| {"--auto"} | | Auto-approve permissions that are not explicitly denied | | {"--port"} | | Port to listen on | | {"--hostname"} | | Hostname to listen on | | {"--mdns"} | | Enable mDNS discovery | @@ -380,7 +381,7 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" | {"--port"} | | Port for the local server (defaults to random port) | | {"--variant"} | | Model variant (provider-specific reasoning effort) | | {"--thinking"} | | Show thinking blocks | -| {"--dangerously-skip-permissions"} | | Auto-approve permissions that are not explicitly denied (dangerous!) | +| {"--auto"} | | Auto-approve permissions that are not explicitly denied | --- diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index 19182e27491b..ccc4034dc696 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -19,6 +19,26 @@ Each permission rule resolves to one of: --- +## Auto mode + +Start OpenCode with `--auto` to automatically approve permission requests that are not explicitly denied. + +```bash +opencode --auto +``` + +You can also use auto mode with [`opencode run`](/docs/cli#run). + +```bash +opencode run --auto "Refactor this module" +``` + +Explicit `"deny"` rules are still enforced. Auto mode only changes requests that would otherwise ask for approval. + +In the TUI, open the command palette and select **Enable auto-approve permissions** or **Disable auto-approve permissions** to change modes. When auto mode is active, the prompt displays a muted `auto` indicator next to the current agent. + +--- + ## Configuration You can set permissions globally (with `*`), and override specific tools. From 797cb530e58d6311288570c9ee52bf6528681d6a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 30 Jun 2026 05:09:55 +0000 Subject: [PATCH 065/303] chore: generate --- packages/tui/src/app.tsx | 12 ++++---- packages/web/src/content/docs/cli.mdx | 40 +++++++++++++-------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5788a947a11d..979d35e198e6 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -309,12 +309,12 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { - - - + + + diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index b44a27f4e1d2..94d9ba3c75d4 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -362,26 +362,26 @@ opencode run --attach http://localhost:4096 "Explain async/await in JavaScript" #### Flags -| Flag | Short | Description | -| ------------------------------------------------------------ | ----- | -------------------------------------------------------------------------- | -| {"--command"} | | The command to run, use message for args | -| {"--continue"} | `-c` | Continue the last session | -| {"--session"} | `-s` | Session ID to continue | -| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | -| {"--share"} | | Share the session | -| {"--model"} | `-m` | Model to use in the form of provider/model | -| {"--agent"} | | Agent to use | -| {"--file"} | `-f` | File(s) to attach to message | -| {"--format"} | | Format: default (formatted) or json (raw JSON events) | -| {"--title"} | | Title for the session (uses truncated prompt if no value provided) | -| {"--attach"} | | Attach to a running opencode server (e.g., http://localhost:4096) | -| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | -| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | -| {"--dir"} | | Directory to run in, or path on the remote server when attaching | -| {"--port"} | | Port for the local server (defaults to random port) | -| {"--variant"} | | Model variant (provider-specific reasoning effort) | -| {"--thinking"} | | Show thinking blocks | -| {"--auto"} | | Auto-approve permissions that are not explicitly denied | +| Flag | Short | Description | +| ---------------------------------------- | ----- | -------------------------------------------------------------------------- | +| {"--command"} | | The command to run, use message for args | +| {"--continue"} | `-c` | Continue the last session | +| {"--session"} | `-s` | Session ID to continue | +| {"--fork"} | | Fork the session when continuing (use with `--continue` or `--session`) | +| {"--share"} | | Share the session | +| {"--model"} | `-m` | Model to use in the form of provider/model | +| {"--agent"} | | Agent to use | +| {"--file"} | `-f` | File(s) to attach to message | +| {"--format"} | | Format: default (formatted) or json (raw JSON events) | +| {"--title"} | | Title for the session (uses truncated prompt if no value provided) | +| {"--attach"} | | Attach to a running opencode server (e.g., http://localhost:4096) | +| {"--password"} | `-p` | Basic auth password (defaults to `OPENCODE_SERVER_PASSWORD`) | +| {"--username"} | `-u` | Basic auth username (defaults to `OPENCODE_SERVER_USERNAME` or `opencode`) | +| {"--dir"} | | Directory to run in, or path on the remote server when attaching | +| {"--port"} | | Port for the local server (defaults to random port) | +| {"--variant"} | | Model variant (provider-specific reasoning effort) | +| {"--thinking"} | | Show thinking blocks | +| {"--auto"} | | Auto-approve permissions that are not explicitly denied | --- From e687eb936b0d64ca1451428be93b82a6e398f736 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:31:09 +1000 Subject: [PATCH 066/303] fix(app): register export logs globally (#34352) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/app.tsx | 27 ++++++++++++++++++++++++++- packages/app/src/desktop-menu.test.ts | 13 +++++++++++++ packages/app/src/pages/layout.tsx | 12 ------------ 3 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 packages/app/src/desktop-menu.test.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5b9223b5eebc..05b17a1309ea 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -28,7 +28,7 @@ import { Show, } from "solid-js" import { Dynamic } from "solid-js/web" -import { CommandProvider } from "@/context/command" +import { CommandProvider, useCommand, type CommandOption } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" @@ -40,6 +40,7 @@ import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider, useNotification } from "@/context/notification" import { PermissionProvider } from "@/context/permission" +import { usePlatform } from "@/context/platform" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider, useSettings } from "@/context/settings" @@ -300,12 +301,36 @@ function SharedProviders(props: ParentProps) { <> + {props.children} ) } +function DesktopCommands() { + const command = useCommand() + const language = useLanguage() + const platform = usePlatform() + + command.register("desktop", () => { + const commands: CommandOption[] = [] + if (platform.platform === "desktop" && platform.exportDebugLogs) { + commands.push({ + id: "logs.export", + title: "Export logs", + category: language.t("command.category.settings"), + onSelect: () => { + void platform.exportDebugLogs?.() + }, + }) + } + return commands + }) + + return null +} + // Server-scoped providers shared by the legacy shell and the top-level new shell. type ServerScopedShellProps = ParentProps<{ directory?: () => string | undefined diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts new file mode 100644 index 000000000000..c7e36a7cb73b --- /dev/null +++ b/packages/app/src/desktop-menu.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { DESKTOP_MENU } from "./desktop-menu" + +describe("desktop menu", () => { + test("exports logs through the desktop command registry", () => { + const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( + (item) => item.type === "item" && item.label === "Export Logs...", + ) + + expect(items).toHaveLength(2) + expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) + }) +}) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 31c59579b6ff..690460d3af6e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -943,18 +943,6 @@ export default function LegacyLayout(props: ParentProps) { keybind: "mod+comma", onSelect: () => openSettings(), }, - ...(platform.platform === "desktop" && platform.exportDebugLogs - ? [ - { - id: "logs.export", - title: "Export logs", - category: language.t("command.category.settings"), - onSelect: () => { - void platform.exportDebugLogs?.() - }, - }, - ] - : []), { id: "session.previous", title: language.t("command.session.previous"), From 6b074ed71880353db7c904d42423f97d11a871b2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:32:25 +1000 Subject: [PATCH 067/303] fix(app): suppress middle-click tab auxclick (#34355) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- .../app/src/components/titlebar-tab-nav.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 9d9df7d2c9a8..499c50bf6365 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -13,6 +13,9 @@ import type { Session } from "@opencode-ai/sdk/v2" import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture" import "./titlebar-tab-nav.css" +// MouseEvent.button uses 1 for the middle/wheel button. +const MIDDLE_MOUSE_BUTTON = 1 + export function TabNavItem(props: { ref?: Ref href: string @@ -184,7 +187,12 @@ export function TabNavItem(props: { data-dragging={props.dragging} data-pressed={props.pressed} onMouseDown={(event) => { - if (event.button !== 1) return + if (event.button !== MIDDLE_MOUSE_BUTTON) return + event.preventDefault() + event.stopPropagation() + }} + onAuxClick={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return closeTab(event) }} > @@ -302,7 +310,12 @@ export function DraftTabItem(props: { class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" classList={{ invisible: props.hidden }} onMouseDown={(event) => { - if (event.button !== 1) return + if (event.button !== MIDDLE_MOUSE_BUTTON) return + event.preventDefault() + event.stopPropagation() + }} + onAuxClick={(event) => { + if (event.button !== MIDDLE_MOUSE_BUTTON) return closeTab(event) }} > From b19ae6547f35276ed1fad37a570b3bdacf71283b Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 30 Jun 2026 03:23:47 -0400 Subject: [PATCH 068/303] sync --- .../app/src/routes/zen/util/handler.ts | 115 +++++++++--------- 1 file changed, 57 insertions(+), 58 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 9be1dde33049..6b98db45054c 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -587,70 +587,69 @@ export async function handler( })) } - if (retry.retryCount !== MAX_FAILOVER_RETRIES) { - let topPriority = Infinity - const providers = allProviders - .filter((provider) => provider.weight !== 0) - .filter((provider) => !retry.excludeProviders.includes(provider.id)) - .filter((provider) => { - if (provider.budgetPriority === undefined) return true - if (!providerBudget) return true - return providerBudget.qualify(provider.id, provider.budgetPriority) - }) - .filter((provider) => { - if (!provider.tpmLimit) return true - const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0 - return usage < provider.tpmLimit * 1_000_000 - }) - .filter((provider) => { - if (!provider.tpsGoal) return true - const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { - qualify: 0, - unqualify: 0, - } - const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify - return !isLowTps - }) - .map((provider) => { - topPriority = Math.min(topPriority, provider.priority) - return provider - }) - .filter((p) => p.priority <= topPriority) - .flatMap((provider) => Array(provider.weight).fill(provider)) - - // Use the last 4 characters of session ID to select a provider - let h = 0 - const l = stickyId.length - for (let i = l - 4; i < l; i++) { - h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int - } - const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 - const provider = providers[index || 0] - - // sticky provider does not exist => use selected provider - if (!stickyProviderId) return provider - const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) - if (!stickProvider) return provider - - const preferBudgetProvider = - provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority) - - const preferTpsProvider = (() => { - if (!provider.tpsGoal) return false + // Use fallback provider if max retries reached + const fallbackProvider = allProviders.find((provider) => provider.id === modelInfo.fallbackProvider) + if (retry.retryCount === MAX_FAILOVER_RETRIES) return fallbackProvider + + let topPriority = Infinity + const providers = allProviders + .filter((provider) => provider.weight !== 0) + .filter((provider) => !retry.excludeProviders.includes(provider.id)) + .filter((provider) => { + if (provider.budgetPriority === undefined) return true + if (!providerBudget) return true + return providerBudget.qualify(provider.id, provider.budgetPriority) + }) + .filter((provider) => { + if (!provider.tpmLimit) return true + const usage = modelTpmLimits?.[`${provider.id}/${provider.model}`] ?? 0 + return usage < provider.tpmLimit * 1_000_000 + }) + .filter((provider) => { + if (!provider.tpsGoal) return true const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { qualify: 0, unqualify: 0, } - return tps.qualify > tps.unqualify * 3 - })() - - if (!preferBudgetProvider && !preferTpsProvider) return stickProvider - - return provider + const isLowTps = tps.qualify + tps.unqualify > 10 && tps.qualify < tps.unqualify + return !isLowTps + }) + .map((provider) => { + topPriority = Math.min(topPriority, provider.priority) + return provider + }) + .filter((p) => p.priority <= topPriority) + .flatMap((provider) => Array(provider.weight).fill(provider)) + + // Use the last 4 characters of session ID to select a provider + let h = 0 + const l = stickyId.length + for (let i = l - 4; i < l; i++) { + h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int } + const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 + const provider = providers[index || 0] ?? fallbackProvider + + // sticky provider does not exist => use selected provider + if (!stickyProviderId) return provider + const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) + if (!stickProvider) return provider + + const preferBudgetProvider = + provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority) + + const preferTpsProvider = (() => { + if (!provider.tpsGoal) return false + const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { + qualify: 0, + unqualify: 0, + } + return tps.qualify > tps.unqualify * 3 + })() + + if (!preferBudgetProvider && !preferTpsProvider) return stickProvider - // fallback provider - return allProviders.find((provider) => provider.id === modelInfo.fallbackProvider) + return provider })() if (!modelProvider) throw new ModelError(t("zen.api.error.noProviderAvailable")) From e34822db6beeae503fed28354bed71ac61fecff6 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:23:39 +0200 Subject: [PATCH 069/303] fix(desktop): preserve selected model during session promotion (#34466) --- packages/app/src/context/local.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 2a71bcea0141..415da747e0be 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -80,6 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const [store, setStore] = createStore<{ current?: string draft?: State + promoting?: State last?: { type: "agent" | "model" | "variant" agent?: string @@ -123,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const scope = createMemo(() => { const session = id() - if (!session) return store.draft + if (!session) return store.draft ?? store.promoting return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session)) }) @@ -136,11 +137,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!next) return if (saved.session[session] !== undefined) { handoff.delete(key) + setStore("promoting", undefined) return } setSaved("session", session, clone(next)) handoff.delete(key) + setStore("promoting", undefined) }) const configuredModel = () => { @@ -373,19 +376,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ agent, session: { reset() { - setStore("draft", undefined) + setStore({ draft: undefined, promoting: undefined }) }, promote(dir: string, session: string) { const next = clone(snapshot()) if (!next) return + const key = handoffKey(serverSDK().scope, dir, session) + handoff.set(key, next) if (dir === sdk().directory) { setSaved("session", session, next) - setStore("draft", undefined) - return } - handoff.set(handoffKey(serverSDK().scope, dir, session), next) + setStore("promoting", next) setStore("draft", undefined) }, restore(msg: { sessionID: string; agent: string; model: ModelKey }) { From 90f0576222e5aa090d312712edd38a43642b4cca Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:26:20 +0800 Subject: [PATCH 070/303] fix(session-ui): deselect review line on draft cancel (#34585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴 --- .../session-ui/src/components/line-comment-annotations.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/session-ui/src/components/line-comment-annotations.tsx b/packages/session-ui/src/components/line-comment-annotations.tsx index edff8c00ad6f..c0755d3eeb92 100644 --- a/packages/session-ui/src/components/line-comment-annotations.tsx +++ b/packages/session-ui/src/components/line-comment-annotations.tsx @@ -408,7 +408,10 @@ export function createLineCommentController( selection: formatSelectedLineLabel(range, i18n.t), mention: props.mention, onInput: note.setDraft, - onCancel: note.cancelDraft, + onCancel: () => { + note.cancelDraft() + note.select(null) + }, onSubmit: (comment) => { props.onSubmit({ comment, selection: cloneSelectedLineRange(range) }) note.cancelDraft() From 1982d9832b870898d58126f1bbe817ff80004955 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:01:38 +0800 Subject: [PATCH 071/303] fix(session-ui): align code copy tooltip with v2 style (#34459) Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- .../session-ui/src/components/markdown.css | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 0f60da229c11..a7db67a64e9c 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -327,6 +327,29 @@ } } +body[data-new-layout] [data-component="markdown"] { + [data-slot="markdown-copy-button"]::after { + box-sizing: border-box; + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 6px; + padding: 5px 6px; + border: 0; + border-radius: 4px; + background: var(--v2-background-bg-layer-01); + box-shadow: var(--v2-elevation-floating); + color: var(--v2-text-text-base); + user-select: none; + font-size: 11px; + font-weight: 530; + line-height: 12px; + letter-spacing: 0.05px; + font-variant-numeric: tabular-nums; + font-variation-settings: "slnt" 0; + } +} + body:not([data-new-layout]) [data-component="markdown"] { color: var(--text-strong); From 003c22b4a3be35e8b2bc33dabda045a56dac8aea Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:09:38 +0200 Subject: [PATCH 072/303] fix(desktop): context menu button / tab intermittent issue (#34420) Co-authored-by: Brendan Allan --- .../src/components/session-context-usage.tsx | 46 +++++++++++++------ packages/app/src/context/layout.tsx | 32 +++++++++---- .../ui/src/components/progress-circle.css | 8 +++- .../ui/src/components/progress-circle.tsx | 9 +++- 4 files changed, 71 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 0d6b761c9457..d08f2b48699f 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -26,7 +26,7 @@ function openSessionContext(args: { layout: ReturnType tabs: ReturnType["tabs"]> }) { - if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() + args.view.reviewPanel.open(args.view.reviewPanel.opened() ? "other" : "context-button") if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") void args.tabs.open("context") args.tabs.setActive("context") @@ -64,16 +64,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) + const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") + const hasOtherTabs = createMemo(() => tabs().all().some((tab) => tab !== "context" && tab !== "review")) const openContext = () => { if (!params.id) return - if (tabState.activeTab() === "context") { + const sessionView = view() + if (contextVisible()) { tabs().close("context") + if (sessionView.reviewPanel.source() === "context-button" && !hasOtherTabs()) sessionView.reviewPanel.close() return } + openSessionContext({ - view: view(), + view: sessionView, layout, tabs: tabs(), }) @@ -81,7 +86,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const circle = () => (
- +
) const circleV2 = () => ( @@ -119,10 +137,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return ( - - - {circle()} - + + {circle()} + + - - + + + + - - - + + + ) } diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 2e4684bbcea9..1487676e54f5 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -78,6 +78,7 @@ export type LocalProject = Partial & { worktree: string; expanded: bool export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string } export type ReviewDiffStyle = "unified" | "split" +export type ReviewPanelSource = "context-button" | "other" export type LayoutRoute = | { type: "home" } @@ -304,6 +305,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), ) + const [ephemeral, setEphemeral] = createStore({ + reviewPanelSource: "other" as ReviewPanelSource, + }) const MAX_SESSION_KEYS = 50 const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 @@ -780,6 +784,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} }) const terminalOpened = createMemo(() => store.terminal?.opened ?? false) const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED) + const reviewPanelSource = createMemo(() => (reviewPanelOpened() ? ephemeral.reviewPanelSource : "other")) function setTerminalOpened(next: boolean) { const current = store.terminal @@ -793,16 +798,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("terminal", "opened", next) } - function setReviewPanelOpened(next: boolean) { + function setReviewPanelOpened(next: boolean, source: ReviewPanelSource) { + const nextSource = next ? source : "other" const current = store.review if (!current) { - setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) + batch(() => { + setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next }) + setEphemeral("reviewPanelSource", nextSource) + }) return } const value = current.panelOpened ?? DEFAULT_REVIEW_PANEL_OPENED - if (value === next) return - setStore("review", "panelOpened", next) + if (value === next) { + if (ephemeral.reviewPanelSource !== nextSource) setEphemeral("reviewPanelSource", nextSource) + return + } + batch(() => { + setStore("review", "panelOpened", next) + setEphemeral("reviewPanelSource", nextSource) + }) } return { @@ -838,14 +853,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, reviewPanel: { opened: reviewPanelOpened, - open() { - setReviewPanelOpened(true) + source: reviewPanelSource, + open(source: ReviewPanelSource = "other") { + setReviewPanelOpened(true, source) }, close() { - setReviewPanelOpened(false) + setReviewPanelOpened(false, "other") }, toggle() { - setReviewPanelOpened(!reviewPanelOpened()) + setReviewPanelOpened(!reviewPanelOpened(), "other") }, }, review: { diff --git a/packages/ui/src/components/progress-circle.css b/packages/ui/src/components/progress-circle.css index afaf72af61ee..4d1e79cddc67 100644 --- a/packages/ui/src/components/progress-circle.css +++ b/packages/ui/src/components/progress-circle.css @@ -2,11 +2,15 @@ transform: rotate(-90deg); [data-slot="progress-circle-background"] { - stroke: var(--border-weak-base); + stroke: var(--progress-circle-background, var(--border-weak-base)); + } + + [data-slot="progress-circle-background-overlay"] { + stroke: var(--progress-circle-background-overlay, transparent); } [data-slot="progress-circle-progress"] { - stroke: var(--border-active); + stroke: var(--progress-circle-progress, var(--border-active)); transition: stroke-dashoffset 0.35s cubic-bezier(0.65, 0, 0.35, 1); } } diff --git a/packages/ui/src/components/progress-circle.tsx b/packages/ui/src/components/progress-circle.tsx index 992fb62e8369..7c764b5dd1c7 100644 --- a/packages/ui/src/components/progress-circle.tsx +++ b/packages/ui/src/components/progress-circle.tsx @@ -1,6 +1,6 @@ import { type ComponentProps, createMemo, splitProps } from "solid-js" -export interface ProgressCircleProps extends Pick, "class" | "classList"> { +export interface ProgressCircleProps extends Pick, "class" | "classList" | "style"> { percentage: number size?: number strokeWidth?: number @@ -43,6 +43,13 @@ export function ProgressCircle(props: ProgressCircleProps) { data-slot="progress-circle-background" stroke-width={strokeWidth()} /> + Date: Tue, 30 Jun 2026 09:11:40 +0000 Subject: [PATCH 073/303] chore: generate --- packages/app/src/components/session-context-usage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index d08f2b48699f..01eea0f55821 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -65,7 +65,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) { return usd().format(info()?.cost ?? 0) }) const contextVisible = createMemo(() => view().reviewPanel.opened() && tabState.activeTab() === "context") - const hasOtherTabs = createMemo(() => tabs().all().some((tab) => tab !== "context" && tab !== "review")) + const hasOtherTabs = createMemo(() => + tabs() + .all() + .some((tab) => tab !== "context" && tab !== "review"), + ) const openContext = () => { if (!params.id) return From f69f677a32788d19e0d28b3c392016e08f775a93 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:46:52 +0200 Subject: [PATCH 074/303] feat(desktop): add hover background to session title and single-click edit (#34589) --- .../pages/session/timeline/message-timeline.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 078ee7ada21e..570ff1d98114 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1357,7 +1357,7 @@ export function MessageTimeline(props: { "w-full": true, "pb-4": true, "pr-3": true, - "pl-4": settings.general.newLayoutDesigns(), + "pl-2": settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), }} @@ -1393,8 +1393,13 @@ export function MessageTimeline(props: { fallback={

{childTitle()}

@@ -1408,10 +1413,10 @@ export function MessageTimeline(props: { value={title.draft} disabled={titleMutation.isPending} classList={{ - "text-14-medium text-text-strong grow-1 min-w-0 pl-1 -ml-1": true, - "h-6 leading-4 rounded-[3px] focus:shadow-none focus:outline focus:outline-1 focus:outline-offset-[-1px] focus:outline-v2-border-border-focus": + "text-14-medium text-text-strong grow-1 min-w-0 pl-1 ml-1": true, + "grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(), + "rounded-[6px] -ml-2 px-2 py-1 h-6 leading-4 focus:shadow-none focus:outline focus:outline-1 focus:outline-offset-[-1px] focus:outline-v2-border-border-focus": settings.general.newLayoutDesigns(), - "rounded-[6px]": !settings.general.newLayoutDesigns(), }} style={{ "--inline-input-shadow": settings.general.newLayoutDesigns() From 4aaed4264081ead64630087aa240667f20ca4d1b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:17:55 +0800 Subject: [PATCH 075/303] fix(app): preserve macos titlebar inset (#34594) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/components/titlebar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index b826001387e1..e6c53f9d09a3 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -229,7 +229,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }} style={{ "min-height": minHeight(), - "padding-left": mac() && !mobile() ? `${84 / zoom()}px` : 0, + // Keep native macOS traffic lights clear even when the desktop window is narrow. + "padding-left": mac() ? `${84 / zoom()}px` : 0, width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` From aa56750f8b37bb3d1f9e0341022338497fc96380 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:32:30 +0800 Subject: [PATCH 076/303] fix(desktop): persist last active url (#34595) --- packages/desktop/src/renderer/index.tsx | 29 +++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index c9bd050e7097..fd91badea946 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -17,7 +17,7 @@ import { import type { UpdaterState } from "@opencode-ai/app/updater" import * as Sentry from "@sentry/solid" import type { AsyncStorage } from "@solid-primitives/storage" -import { MemoryRouter } from "@solidjs/router" +import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidjs/router" import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" @@ -63,6 +63,7 @@ const [updaterState, setUpdaterState] = createSignal({ status: "di void window.api.updater.subscribe(setUpdaterState) const deepLinkEvent = "opencode:deep-link" +const lastActiveUrlKey = "opencode.desktop.last-active-url" const emitDeepLinks = (urls: string[]) => { if (urls.length === 0) return @@ -77,6 +78,30 @@ const listenForDeepLinks = () => { return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } +function getLastActiveUrl() { + if (typeof localStorage !== "object") return "/" + try { + const value = localStorage.getItem(lastActiveUrlKey) + if (value?.startsWith("/") && !value.startsWith("//")) return value + } catch {} + return "/" +} + +function setLastActiveUrl(value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(lastActiveUrlKey, value) + } catch {} +} + +function DesktopMemoryRouter(props: BaseRouterProps) { + const history = createMemoryHistory() + const initialUrl = getLastActiveUrl() + if (initialUrl !== "/") history.set({ value: initialUrl, replace: true, scroll: false }) + onCleanup(history.listen(setLastActiveUrl)) + return +} + const createPlatform = (): Platform => { const attachmentPaths = new WeakMap() const os = (() => { @@ -367,7 +392,7 @@ render(() => { {(key) => ( - + )} From ad63cf15906b1e3bf7e188182c48af83b7771003 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:38:42 +0200 Subject: [PATCH 077/303] feat(app): hide separators around active tabs (#34591) --- packages/app/src/components/titlebar-tab-nav.css | 6 +++++- packages/app/src/components/titlebar-tab-strip.tsx | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 5fb2fa892cfd..08bed7cf26b5 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -21,7 +21,7 @@ position: relative; } -[data-titlebar-tab-slot]:not(:first-child)::before { +[data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; @@ -32,6 +32,10 @@ background: var(--v2-background-bg-layer-02); } +[data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { + display: none; +} + [data-titlebar-tab] [data-slot="tab-close"]::before { content: ""; position: absolute; diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index ef404157e408..fe476d798ec6 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -85,6 +85,7 @@ function SessionTabSlot(props: { ref={sortable.ref} data-titlebar-tab-slot data-tab-key={props.id} + data-active={props.active()} class="relative flex w-56 min-w-7 max-w-56 flex-shrink" classList={{ hidden: !session() }} > @@ -140,6 +141,7 @@ function DraftTabSlot(props: { ref={sortable.ref} data-titlebar-tab-slot data-tab-key={props.id} + data-active={props.active()} class="relative flex w-56 min-w-7 max-w-56 flex-shrink" > Date: Tue, 30 Jun 2026 18:40:20 +0800 Subject: [PATCH 078/303] fix(app): restore prompt cursor on focus (#34175) --- packages/app/src/components/prompt-input.tsx | 30 ++++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1a25d10f7884..6b5fd8844f71 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -214,6 +214,7 @@ export const PromptInput: Component = (props) => { let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement + let restoreEndOnFocus = true const mirror = { input: false } const inset = 56 @@ -593,6 +594,16 @@ export const PromptInput: Component = (props) => { }) } + const handleFocus = () => { + if (!restoreEndOnFocus) return + restoreEndOnFocus = false + requestAnimationFrame(() => { + if (document.activeElement !== editorRef) return + setCursorPosition(editorRef, prompt.cursor() ?? promptLength(prompt.current())) + queueScroll() + }) + } + const renderEditorWithCursor = (parts: Prompt) => { const cursor = currentCursor() renderEditor(parts) @@ -1380,6 +1391,11 @@ export const PromptInput: Component = (props) => { })) const newSession = () => props.variant === "new-session" + const bindEditorRef = (el: HTMLDivElement) => { + editorRef = el + restoreEndOnFocus = true + props.ref?.(el) + } const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), @@ -1463,10 +1479,7 @@ export const PromptInput: Component = (props) => {
(scrollRef = el)}>
{ - editorRef = el - props.ref?.(el) - }} + ref={bindEditorRef} role="textbox" aria-multiline="true" aria-label={designPlaceholder()} @@ -1481,6 +1494,7 @@ export const PromptInput: Component = (props) => { onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} classList={{ @@ -1501,7 +1515,7 @@ export const PromptInput: Component = (props) => {
-
+
{fileAttachmentInput()} = (props) => { >
{ - editorRef = el - props.ref?.(el) - }} + ref={bindEditorRef} role="textbox" aria-multiline="true" aria-label={placeholder()} @@ -1662,6 +1673,7 @@ export const PromptInput: Component = (props) => { onPaste={handlePaste} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} + onFocus={handleFocus} onBlur={handleBlur} onKeyDown={handleKeyDown} classList={{ From 3ca89ac79678427f3d9c6ca850e07e915c288e4d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:45:37 +0800 Subject: [PATCH 079/303] fix(app): autocomplete configured references (#34308) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: opencode-agent[bot] Co-authored-by: Brendan Allan --- packages/app/src/components/prompt-input.tsx | 59 ++++++++++++++++--- .../prompt-input/build-request-parts.test.ts | 35 +++++++++++ .../prompt-input/build-request-parts.ts | 4 +- .../components/prompt-input/slash-popover.tsx | 18 ++++++ .../src/context/global-sync/bootstrap.test.ts | 2 + .../app/src/context/global-sync/bootstrap.ts | 9 +++ .../src/context/global-sync/child-store.ts | 4 ++ .../src/context/global-sync/event-reducer.ts | 5 ++ packages/app/src/context/global-sync/types.ts | 2 + packages/app/src/context/prompt.tsx | 10 +++- packages/app/src/context/server-sync.tsx | 5 ++ 11 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 6b5fd8844f71..2058f1c527b1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -68,6 +68,7 @@ import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" +import type { ReferenceInfo } from "@opencode-ai/sdk/v2/client" export type PromptInputState = ReturnType @@ -640,6 +641,23 @@ export const PromptInput: Component = (props) => { }) } + const referenceDescription = (reference: ReferenceInfo) => + reference.source.type === "git" ? reference.source.repository : reference.source.path + + const referenceList = createMemo(() => + sync() + .data.reference.filter((reference) => !reference.hidden) + .map( + (reference): AtOption => ({ + type: "reference", + name: reference.name, + path: reference.path, + display: reference.name, + description: reference.description ?? referenceDescription(reference), + }), + ), + ) + const agentList = createMemo(() => props.controls.agents.available .filter((agent) => !agent.hidden && agent.mode !== "primary") @@ -650,14 +668,28 @@ export const PromptInput: Component = (props) => { if (!option) return if (option.type === "agent") { addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) + return } + if (option.type === "reference") { + addPart({ + type: "file", + path: option.path, + content: "@" + option.name, + start: 0, + end: 0, + mime: "application/x-directory", + filename: option.name, + }) + return + } + addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } const atKey = (x: AtOption | undefined) => { if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` + if (x.type === "agent") return `agent:${x.name}` + if (x.type === "reference") return `reference:${x.name}` + return `file:${x.path}` } const { @@ -668,30 +700,33 @@ export const PromptInput: Component = (props) => { onKeyDown: atOnKeyDown, } = useFilteredList({ items: async (query) => { + const references = referenceList() const agents = agentList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], skipFilter: (item) => item.type === "file" && !item.recent, groupBy: (item) => { + if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" if (item.recent) return "recent" return "file" }, sortGroupsBy: (a, b) => { const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 + if (category === "reference") return 0 + if (category === "agent") return 1 + if (category === "recent") return 2 + return 3 } return rank(a.category) - rank(b.category) }, @@ -757,7 +792,11 @@ export const PromptInput: Component = (props) => { const pill = document.createElement("span") pill.textContent = part.content pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) + if (part.type === "file") { + pill.setAttribute("data-path", part.path) + if (part.mime) pill.setAttribute("data-mime", part.mime) + if (part.filename) pill.setAttribute("data-filename", part.filename) + } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") pill.style.userSelect = "text" @@ -878,6 +917,8 @@ export const PromptInput: Component = (props) => { content, start: position, end: position + content.length, + ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), + ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), }) position += content.length } diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index 099200f23f1d..ab84cb6eae81 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -100,6 +100,41 @@ describe("buildRequestParts", () => { ) }) + test("preserves reference aliases as directory file parts", () => { + const result = buildRequestParts({ + prompt: [ + { + type: "file", + path: "/repo/../docs", + content: "@docs", + start: 0, + end: 5, + mime: "application/x-directory", + filename: "docs", + }, + ], + context: [], + images: [], + text: "@docs", + messageID: "msg_reference", + sessionID: "ses_reference", + sessionDirectory: "/repo/app", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + expect(filePart.mime).toBe("application/x-directory") + expect(filePart.filename).toBe("docs") + expect(filePart.url).toBe("file:///repo/../docs") + expect(filePart.source?.type).toBe("file") + if (filePart.source?.type === "file") { + expect(filePart.source.path).toBe("/repo/../docs") + expect(filePart.source.text.value).toBe("@docs") + } + } + }) + test("deduplicates context files when prompt already includes same path", () => { const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 043425356a2c..2654d1442fc9 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -102,9 +102,9 @@ export function buildRequestParts(input: BuildRequestPartsInput) { return { id: Identifier.ascending("part"), type: "file", - mime: "text/plain", + mime: attachment.mime ?? "text/plain", url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, - filename: getFilename(attachment.path), + filename: attachment.filename ?? getFilename(attachment.path), source: { type: "file", text: { diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index aa56b07961d9..e179dc63ff10 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } + | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } export interface SlashCommand { @@ -103,6 +104,23 @@ export const PromptPopover: Component = (props) => { ) } + if (item.type === "reference") { + return ( + + ) + } + const isDirectory = item.path.endsWith("/") const directory = isDirectory ? item.path : getDirectory(item.path) const filename = isDirectory ? "" : getFilename(item.path) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 2e85f4850ab0..137649c02476 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -16,6 +16,7 @@ describe("bootstrapDirectory", () => { status: "loading", agent: [], command: [], + reference: [], project: "", projectMeta: undefined, icon: undefined, @@ -67,6 +68,7 @@ describe("bootstrapDirectory", () => { }, permission: { list: async () => ({ data: [] }) }, question: { list: async () => ({ data: [] }) }, + v2: { reference: { list: async () => ({ data: { data: [] } }) } }, mcp: { status: async () => { mcpReads.push("status") diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 5da05d19cf71..b162034830ef 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -6,6 +6,7 @@ import type { Project, ProviderAuthResponse, QuestionRequest, + ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" @@ -195,6 +196,13 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), }) +export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions({ + queryKey: [scope, directory, "references"] as const, + queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + placeholderData: [], + }) + export async function bootstrapDirectory(input: { directory: string scope: ServerScope @@ -277,6 +285,7 @@ export async function bootstrapDirectory(input: { }), ), input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), + () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), () => retry(() => input.sdk.permission.list().then((x) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 65011e8b0917..3e7af233097a 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -187,6 +187,7 @@ export function createChildStoreManager(input: { const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key)) + const referenceQuery = useQuery(() => input.queryOptions.references(key)) const child = createStore({ project: "", @@ -210,6 +211,9 @@ export function createChildStoreManager(input: { status: "loading" as const, agent: [], command: [], + get reference() { + return referenceQuery.isLoading ? [] : (referenceQuery.data ?? []) + }, session: [], sessionTotal: 0, session_status: {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index bca3f857203e..15d125df3914 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -112,6 +112,7 @@ export function applyDirectoryEvent(input: { push: (directory: string) => void directory: string loadLsp: () => void + loadReferences?: () => void vcsCache?: VcsCache setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void retainedLimit?: number @@ -404,5 +405,9 @@ export function applyDirectoryEvent(input: { input.loadLsp() break } + case "reference.updated": { + input.loadReferences?.() + break + } } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 9fb86d6d35fe..c3b38cd315db 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -9,6 +9,7 @@ import type { Path, PermissionRequest, QuestionRequest, + ReferenceInfo, Session, SessionStatus, SnapshotFileDiff, @@ -34,6 +35,7 @@ export type State = { status: "loading" | "partial" | "complete" agent: Agent[] command: Command[] + reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined icon: string | undefined diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 014352506abc..5c00d02a9874 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -27,6 +27,8 @@ export interface FileAttachmentPart extends PartBase { type: "file" path: string selection?: FileSelection + mime?: string + filename?: string } export interface AgentPart extends PartBase { @@ -73,7 +75,13 @@ function isPartEqual(partA: ContentPart, partB: ContentPart) { case "text": return partB.type === "text" && partA.content === partB.content case "file": - return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection) + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) case "agent": return partB.type === "agent" && partA.name === partB.name case "image": diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index f2ee8869acf5..b01a704ba7e8 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -15,6 +15,7 @@ import { loadPathQuery, loadProjectsQuery, loadProvidersQuery, + loadReferencesQuery, } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" @@ -75,6 +76,7 @@ function makeQueryOptionsApi( path: (directory: PathKey | null) => loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), + references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), @@ -396,6 +398,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { loadLsp: () => { void queryClient.fetchQuery(queryOptionsApi.lsp(key)) }, + loadReferences: () => { + void queryClient.fetchQuery(queryOptionsApi.references(key)) + }, }) }) From 06cb0de0b19916511fe408d2fb9616e6dc7683c2 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:05:53 +0800 Subject: [PATCH 080/303] feat(app): autocomplete mcp resources (#34597) --- packages/app/src/components/prompt-input.tsx | 66 ++++++++++++++- .../prompt-input/build-request-parts.ts | 30 ++++--- .../components/prompt-input/slash-popover.tsx | 84 +++++++++++++++++-- .../src/context/global-sync/bootstrap.test.ts | 1 + .../app/src/context/global-sync/bootstrap.ts | 3 +- .../context/global-sync/child-store.test.ts | 8 +- .../src/context/global-sync/child-store.ts | 4 + packages/app/src/context/global-sync/types.ts | 4 + packages/app/src/context/prompt.tsx | 3 + packages/app/src/context/server-sync.tsx | 11 ++- 10 files changed, 191 insertions(+), 23 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2058f1c527b1..4bc73f231b4d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -664,6 +664,20 @@ export const PromptInput: Component = (props) => { .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), ) + const mcpResourceList = createMemo(() => + Object.values(sync().data.mcp_resource).map( + (resource): AtOption => ({ + type: "resource", + name: resource.name, + uri: resource.uri, + client: resource.client, + display: resource.name, + description: resource.description, + mime: resource.mimeType, + }), + ), + ) + const handleAtSelect = (option: AtOption | undefined) => { if (!option) return if (option.type === "agent") { @@ -682,6 +696,25 @@ export const PromptInput: Component = (props) => { }) return } + if (option.type === "resource") { + addPart({ + type: "file", + path: option.uri, + content: "@" + option.name, + start: 0, + end: 0, + mime: option.mime ?? "text/plain", + filename: option.name, + url: option.uri, + source: { + type: "resource", + text: { value: "@" + option.name, start: 0, end: 0 }, + clientName: option.client, + uri: option.uri, + }, + }) + return + } addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) } @@ -689,6 +722,7 @@ export const PromptInput: Component = (props) => { if (!x) return "" if (x.type === "agent") return `agent:${x.name}` if (x.type === "reference") return `reference:${x.name}` + if (x.type === "resource") return `resource:${x.client}:${x.uri}` return `file:${x.path}` } @@ -702,15 +736,16 @@ export const PromptInput: Component = (props) => { items: async (query) => { const references = referenceList() const agents = agentList() + const mcpResources = mcpResourceList() const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...references, ...agents, ...pinned] + if (!query.trim()) return [...references, ...agents, ...mcpResources, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) .map((path) => ({ type: "file", path, display: path })) - return [...references, ...agents, ...pinned, ...fileOptions] + return [...references, ...agents, ...mcpResources, ...pinned, ...fileOptions] }, key: atKey, filterKeys: ["display"], @@ -718,6 +753,7 @@ export const PromptInput: Component = (props) => { groupBy: (item) => { if (item.type === "reference") return "reference" if (item.type === "agent") return "agent" + if (item.type === "resource") return "resource" if (item.recent) return "recent" return "file" }, @@ -725,8 +761,9 @@ export const PromptInput: Component = (props) => { const rank = (category: string) => { if (category === "reference") return 0 if (category === "agent") return 1 - if (category === "recent") return 2 - return 3 + if (category === "resource") return 2 + if (category === "recent") return 3 + return 4 } return rank(a.category) - rank(b.category) }, @@ -796,6 +833,12 @@ export const PromptInput: Component = (props) => { pill.setAttribute("data-path", part.path) if (part.mime) pill.setAttribute("data-mime", part.mime) if (part.filename) pill.setAttribute("data-filename", part.filename) + if (part.url) pill.setAttribute("data-url", part.url) + if (part.source?.type === "resource") { + pill.setAttribute("data-source-type", part.source.type) + pill.setAttribute("data-source-client-name", part.source.clientName) + pill.setAttribute("data-source-uri", part.source.uri) + } } if (part.type === "agent") pill.setAttribute("data-name", part.name) pill.setAttribute("contenteditable", "false") @@ -911,6 +954,19 @@ export const PromptInput: Component = (props) => { const pushFile = (file: HTMLElement) => { const content = file.textContent ?? "" + const source = + file.dataset.sourceType === "resource" && file.dataset.sourceClientName && file.dataset.sourceUri + ? { + type: "resource" as const, + text: { + value: content, + start: position, + end: position + content.length, + }, + clientName: file.dataset.sourceClientName, + uri: file.dataset.sourceUri, + } + : undefined parts.push({ type: "file", path: file.dataset.path!, @@ -919,6 +975,8 @@ export const PromptInput: Component = (props) => { end: position + content.length, ...(file.dataset.mime ? { mime: file.dataset.mime } : {}), ...(file.dataset.filename ? { filename: file.dataset.filename } : {}), + ...(file.dataset.url ? { url: file.dataset.url } : {}), + ...(source ? { source } : {}), }) position += content.length } diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 2654d1442fc9..c5bdd730c461 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -99,21 +99,31 @@ export function buildRequestParts(input: BuildRequestPartsInput) { const files = input.prompt.filter(isFileAttachment).map((attachment) => { const path = absolute(input.sessionDirectory, attachment.path) + const source = attachment.source + ? { + ...attachment.source, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + } + : { + type: "file" as const, + text: { + value: attachment.content, + start: attachment.start, + end: attachment.end, + }, + path, + } return { id: Identifier.ascending("part"), type: "file", mime: attachment.mime ?? "text/plain", - url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, + url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`, filename: attachment.filename ?? getFilename(attachment.path), - source: { - type: "file", - text: { - value: attachment.content, - start: attachment.start, - end: attachment.end, - }, - path, - }, + source, } satisfies PromptRequestPart }) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index e179dc63ff10..218a4fae7812 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } + | { type: "resource"; name: string; uri: string; client: string; display: string; description?: string; mime?: string } | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } @@ -104,18 +105,89 @@ export const PromptPopover: Component = (props) => { ) } + if (item.type === "resource") { + return ( + + ) + } + if (item.type === "reference") { return ( ) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 137649c02476..73de5b9ce6db 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -36,6 +36,7 @@ describe("bootstrapDirectory", () => { question: {}, mcp_ready: true, mcp: {}, + mcp_resource: {}, lsp_ready: true, lsp: [], vcs: undefined, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index b162034830ef..8f253a975a25 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,7 +19,7 @@ import type { ServerSession } from "../server-session" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" -import { loadMcpQuery } from "../server-sync" +import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" @@ -348,6 +348,7 @@ export async function bootstrapDirectory(input: { ), () => Promise.resolve(input.loadSessions(input.directory)), input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { const project = getFilename(input.directory) diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index 31c7519d8462..ddbfffe1adca 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -34,7 +34,9 @@ const queryOptionsApi = { }), agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }), mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }), + mcpResources: (directory: string) => ({ queryKey: [directory, "mcpResources"], queryFn: async () => ({}) }), lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }), + references: (directory: string) => ({ queryKey: [directory, "references"], queryFn: async () => [] }), sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }), } as unknown as QueryOptionsApi @@ -197,14 +199,18 @@ describe("createChildStoreManager", () => { try { if (!manager) throw new Error("manager required") const [store, setStore] = manager.child("/project", { bootstrap: false }) - expect(querySingles.length - offset).toBe(4) + expect(querySingles.length - offset).toBe(6) const query = querySingles[offset + 1] + const resourceQuery = querySingles[offset + 2] if (!query) throw new Error("query required") + if (!resourceQuery) throw new Error("resource query required") expect(query().enabled).toBe(false) + expect(resourceQuery().enabled).toBe(false) setStore("status", "complete") manager.child("/project", { bootstrap: false, mcp: true }) expect(query().enabled).toBe(true) + expect(resourceQuery().enabled).toBe(true) expect(store.mcp).toEqual({ demo: { status: "disabled" } }) expect(mcpLoads).toEqual(["/project"]) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 3e7af233097a..53b9536182e7 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -185,6 +185,7 @@ export function createChildStoreManager(input: { const pathQuery = useQuery(() => input.queryOptions.path(key)) const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) + const mcpResourceQuery = useQuery(() => ({ ...input.queryOptions.mcpResources(key), enabled: mcpEnabled() })) const lspQuery = useQuery(() => input.queryOptions.lsp(key)) const providerQuery = useQuery(() => input.queryOptions.providers(key)) const referenceQuery = useQuery(() => input.queryOptions.references(key)) @@ -231,6 +232,9 @@ export function createChildStoreManager(input: { get mcp() { return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) }, + get mcp_resource() { + return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {}) + }, get lsp_ready() { return !lspQuery.isLoading }, diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index c3b38cd315db..86b489cd090b 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -3,6 +3,7 @@ import type { Command, Config, LspStatus, + McpResource, McpStatus, Message, Part, @@ -65,6 +66,9 @@ export type State = { mcp: { [name: string]: McpStatus } + mcp_resource: { + [key: string]: McpResource + } lsp_ready: boolean lsp: LspStatus[] vcs: VcsInfo | undefined diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 5c00d02a9874..c36149351624 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -12,6 +12,7 @@ import { useTabs, type Tab } from "./tabs" import { ServerConnection } from "./server" import { requireServerKey } from "@/utils/session-route" import { useSettings } from "./settings" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" interface PartBase { content: string @@ -29,6 +30,8 @@ export interface FileAttachmentPart extends PartBase { selection?: FileSelection mime?: string filename?: string + url?: string + source?: FilePartSource } export interface AgentPart extends PartBase { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index b01a704ba7e8..9ff032a09df0 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,4 +1,4 @@ -import type { Config, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" +import type { Config, McpResource, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" @@ -57,6 +57,13 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), }) +export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => + queryOptions>({ + queryKey: [scope, directory, "mcpResources"] as const, + queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + placeholderData: {}, + }) + export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => queryOptions({ queryKey: [scope, directory, "lsp"] as const, @@ -78,6 +85,7 @@ function makeQueryOptionsApi( agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), + mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -489,6 +497,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, refresh: async () => { await queryClient.refetchQueries(queryOptionsApi.mcp(key)) + await queryClient.refetchQueries(queryOptionsApi.mcpResources(key)) }, }) }, From bd83177c48da82adcc70b50e0033b7b37f1fda02 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 30 Jun 2026 11:07:49 +0000 Subject: [PATCH 081/303] chore: generate --- .../app/src/components/prompt-input/slash-popover.tsx | 10 +++++++++- packages/app/src/context/server-sync.tsx | 9 ++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 218a4fae7812..a84cbcca0894 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -7,7 +7,15 @@ import { getDirectory, getFilename } from "@opencode-ai/core/util/path" export type AtOption = | { type: "agent"; name: string; display: string } - | { type: "resource"; name: string; uri: string; client: string; display: string; description?: string; mime?: string } + | { + type: "resource" + name: string + uri: string + client: string + display: string + description?: string + mime?: string + } | { type: "reference"; name: string; path: string; display: string; description: string } | { type: "file"; path: string; display: string; recent?: boolean } diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 9ff032a09df0..9b337aa07655 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,4 +1,11 @@ -import type { Config, McpResource, OpencodeClient, Path, Project, ProviderAuthResponse } from "@opencode-ai/sdk/v2/client" +import type { + Config, + McpResource, + OpencodeClient, + Path, + Project, + ProviderAuthResponse, +} from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js" From b329fcceb21e2061bcdb24173fdfbc4d25f73414 Mon Sep 17 00:00:00 2001 From: runvip <164729189+runvip@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:17:44 +0700 Subject: [PATCH 082/303] fix(docs): fix Russian translation for index.mdx (#34001) Co-authored-by: Shoubhit Dash --- packages/web/src/content/docs/ru/index.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/ru/index.mdx b/packages/web/src/content/docs/ru/index.mdx index d7a6cb1ffb60..3208b32b5e00 100644 --- a/packages/web/src/content/docs/ru/index.mdx +++ b/packages/web/src/content/docs/ru/index.mdx @@ -126,7 +126,7 @@ curl -fsSL https://opencode.ai/install | bash В настоящее время добавляется поддержка установки opencode в Windows с помощью Bun. -Вы также можете получить двоичный файл в разделе [Releases](https://github.com/anomalyco/opencode/releases). +Вы также можете получить бинарный файл в разделе [Releases](https://github.com/anomalyco/opencode/releases). --- @@ -238,7 +238,7 @@ How is authentication handled in @packages/functions/src/api/index.ts From this screen, the user can undelete a note or permanently delete it. ``` - Вы хотите предоставить opencode достаточно подробностей, чтобы понять, чего вы хотите. Это помогает + Нужно предоставить opencode достаточно подробностей, чтобы ему понять, чего вы хотите. Это помогает поговорить с ним так, как будто вы разговариваете с младшим разработчиком в своей команде. :::tip @@ -271,7 +271,7 @@ How is authentication handled in @packages/functions/src/api/index.ts ``` - И попросить его внести изменения. + И попросите его внести изменения. ```bash frame="none" Sounds good! Go ahead and make the changes. From 8e10ab0aa78fda983040d29ee73c16c66b8c418b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:40:09 -0500 Subject: [PATCH 083/303] chore: bump venice provider (#34629) --- bun.lock | 14 +++++++++----- packages/core/package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index ab6d6929ae69..aabc44e32fbe 100644 --- a/bun.lock +++ b/bun.lock @@ -334,7 +334,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:", @@ -637,7 +637,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", @@ -5429,7 +5429,7 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.0.2", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.47", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.27" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-aoa05nI3BTK5aGbjBflq+Gfln2AHAkwNbWuGGvCzUIsOfp5Y3iPD4O4PUGDAEiWVJWbjpPn0KfDa0H/HebwsaA=="], + "venice-ai-sdk-provider": ["venice-ai-sdk-provider@2.1.1", "", { "dependencies": { "@ai-sdk/openai-compatible": "^2.0.51", "@ai-sdk/provider": "^3.0.10", "@ai-sdk/provider-utils": "^4.0.30" }, "peerDependencies": { "ai": "^6.0.90" } }, "sha512-w3OHkuzzKZ3r2TOxER6myBYzZJNoDqol+DUHu3NnfBN/GETnUVxecZJab0CHQQ8GZc0jjzpFymepjcLDPS4SQg=="], "verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="], @@ -6449,11 +6449,11 @@ "unzipper/fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], - "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.32" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SoPSkrL5cbNQnAljRsJ7pOzJ2FmWgnhC0lfFOda873ycCdFJL1A+h3Ib7mX2spcv3XnNaO13y/45/0RyqNWlIQ=="], "venice-ai-sdk-provider/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], - "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.32", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Kwj499fTcN9bP/AfGoPU7JWIXeP6VZqKI6omsH062c9E2G4gdjeJczkz4z/tYSkzYjLE2AI3DtZbMfs6D7vn2Q=="], "verror/core-util-is": ["core-util-is@1.0.2", "", {}, "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ=="], @@ -7073,6 +7073,10 @@ "unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@3.0.12", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sj9DWTJ2Ze0WR9qsiOPqoqzNx3OxL6iMxHImbhvoe9qOspekbzxNDMiJ4TIGfYHYh9w4OmBjz3prvqhzTi96+Q=="], + "venice-ai-sdk-provider/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "vitest/@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/packages/core/package.json b/packages/core/package.json index edd9827c486b..c540e442927a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -121,7 +121,7 @@ "npm-package-arg": "13.0.2", "semver": "^7.6.3", "turndown": "7.2.0", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "which": "6.0.1", "xdg-basedir": "5.1.0", "zod": "catalog:" diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fba8d9b5bee4..09667e590722 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -143,7 +143,7 @@ "tree-sitter-powershell": "0.25.10", "turndown": "7.2.0", "ulid": "catalog:", - "venice-ai-sdk-provider": "2.0.2", + "venice-ai-sdk-provider": "2.1.1", "vscode-jsonrpc": "8.2.1", "web-tree-sitter": "0.25.10", "ws": "8.21.0", From eaf42c4ab0d383095e35c06d5d023464fb8f7af8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 30 Jun 2026 13:55:24 +0000 Subject: [PATCH 084/303] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 5809bf7ff3d8..769d63a0d3e8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-JXQ9PAqqRlJtHa8T3ZxqdRRyxC+0ip+2wSnehvKXUbI=", - "aarch64-linux": "sha256-nI+RaxDXmAcjhSjCtIvyi292xBEg0E5NXaoyGrJE69s=", - "aarch64-darwin": "sha256-UleKpm7Khf3kibm4BqZJ9bmu0N2kOjNd77g1Bd2tmfw=", - "x86_64-darwin": "sha256-V4AQH868dOfVhEj1mKTnZlhpZwFqBYsHS28Tx9VXHkE=" + "x86_64-linux": "sha256-An7vZmA+sVlxoSOMK4vQvkq3KJOF6Rm9IBU62LN3Hkg=", + "aarch64-linux": "sha256-Hx4trrBLxRDyY4HQc96EQZUa8abIeetNvE8GtRFvrrc=", + "aarch64-darwin": "sha256-/eXdkybUkDCrfQRCkZHsgCa3BM/OfaQzH/z7OxwlpVM=", + "x86_64-darwin": "sha256-flhsuFXikpI8tzK7nTwIVF/jzpmb5p+BWxGa/5vhN54=" } } From 63e24880e5a414178bb0cff942e7c1a29e71fdd9 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:12:22 +0200 Subject: [PATCH 085/303] feat(desktop): polish inline session title editing (#34607) --- .../src/pages/session/timeline/message-timeline.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 570ff1d98114..044240abd6d0 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -736,8 +736,9 @@ export function MessageTimeline(props: { if (!sessionID() || parentID()) return setTitle({ editing: true, draft: titleLabel() ?? "" }) requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() + if (!titleRef) return + titleRef.focus() + titleRef.select() }) } @@ -1369,7 +1370,7 @@ export function MessageTimeline(props: { "pr-3": !settings.general.newLayoutDesigns(), }} > -
+
-