Compare commits

..

18 Commits

Author SHA1 Message Date
Aiden Cline 8c2df36405 refactor(opencode): name deferred MCP instructions 2026-06-29 08:48:46 -05:00
Aiden Cline d3e2382f53 refactor(opencode): separate deferred MCP prompt 2026-06-29 08:22:26 -05:00
Aiden Cline 421e33e531 fix(opencode): remove deferred MCP step gate 2026-06-29 08:17:53 -05:00
Aiden Cline c2d619741b refactor(opencode): reuse deferred MCP resolution 2026-06-29 07:59:55 -05:00
Aiden Cline 0bff5bf389 fix(opencode): avoid deferred MCP search at step limit 2026-06-29 00:39:48 -05:00
Aiden Cline ca359adafa fix(opencode): honor disabled deferred MCP tools 2026-06-29 00:32:08 -05:00
Aiden Cline b8f1ff424a refactor(opencode): validate deferred tool schemas 2026-06-29 00:24:51 -05:00
Aiden Cline d31d9d2287 Merge branch 'dev' into deferred-tools 2026-06-29 00:03:16 -05:00
Aiden Cline dc295c9bd3 Revert "test(core): fix layer node type expectation"
This reverts commit a2fc3057e7.
2026-06-28 23:42:40 -05:00
Aiden Cline a2fc3057e7 test(core): fix layer node type expectation 2026-06-28 23:36:10 -05:00
Aiden Cline 0629920fc5 Merge remote-tracking branch 'origin/dev' into deferred-tools 2026-06-28 23:34:31 -05:00
Aiden Cline b027484365 test(opencode): make deferred MCP fixture resource-free 2026-06-28 23:33:42 -05:00
Aiden Cline e8b6ff6be3 refactor(opencode): simplify deferred MCP tools 2026-06-28 22:52:04 -05:00
Aiden Cline 9a90e14c90 feat(opencode): list deferred MCP servers 2026-06-28 21:51:32 -05:00
Aiden Cline 8e84d9663b refactor(opencode): tidy deferred tool test fixtures 2026-06-28 21:40:45 -05:00
Aiden Cline 4ce8c1dd4d Revert "fix(opencode): avoid deferred MCP search near step limit"
This reverts commit 15bc8ab920.
2026-06-28 21:28:58 -05:00
Aiden Cline 15bc8ab920 fix(opencode): avoid deferred MCP search near step limit 2026-06-28 21:24:28 -05:00
Aiden Cline 0ebbdd71be feat(opencode): defer large MCP tool catalogs 2026-06-28 21:09:26 -05:00
292 changed files with 3547 additions and 4596 deletions
-14
View File
@@ -34,25 +34,11 @@ jobs:
const now = Date.now(); const now = Date.now();
const twoHours = 2 * 60 * 60 * 1000; const twoHours = 2 * 60 * 60 * 1000;
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
for (const item of items) { for (const item of items) {
const isPR = !!item.pull_request; const isPR = !!item.pull_request;
const kind = isPR ? 'PR' : 'issue'; 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({ const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner, owner: context.repo.owner,
repo: context.repo.repo, repo: context.repo.repo,
+1 -7
View File
@@ -38,7 +38,6 @@ jobs:
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created: opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
Issue number: ${{ github.event.issue.number }} 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 }}. Lookup this issue with gh issue view ${{ github.event.issue.number }}.
@@ -50,8 +49,6 @@ jobs:
Check whether the issue follows our contributing guidelines and issue templates. 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: This project has three issue templates that every issue MUST use one of:
1. Bug Report - requires a Description field with real content 1. Bug Report - requires a Description field with real content
@@ -86,7 +83,7 @@ jobs:
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: 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 and the author association is not OWNER or MEMBER, start the comment with: If the issue is NOT compliant, start the comment with:
<!-- issue-compliance --> <!-- issue-compliance -->
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 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
@@ -151,12 +148,9 @@ jobs:
} }
run: | run: |
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited. 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 }}. 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. 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: This project has three issue templates that every issue MUST use one of:
+3 -7
View File
@@ -791,7 +791,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const scrollSlashActiveIntoView = () => { // Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive() const activeId = slashActive()
if (!activeId || !slashPopoverRef) return if (!activeId || !slashPopoverRef) return
@@ -799,7 +800,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
}) })
} })
const selectPopoverActive = () => { const selectPopoverActive = () => {
if (store.popover === "at") { if (store.popover === "at") {
const items = atFlat() const items = atFlat()
@@ -1286,9 +1287,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (store.popover === "slash") { if (store.popover === "slash") {
slashOnKeyDown(event) slashOnKeyDown(event)
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
scrollSlashActiveIntoView()
}
} }
event.preventDefault() event.preventDefault()
return return
@@ -1408,8 +1406,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setSlashActive={setSlashActive} setSlashActive={setSlashActive}
onSlashSelect={handleSlashSelect} onSlashSelect={handleSlashSelect}
commandKeybind={command.keybind} commandKeybind={command.keybind}
commandKeybindParts={command.keybindParts}
newLayoutDesigns={props.controls.newLayoutDesigns}
t={(key) => language.t(key as Parameters<typeof language.t>[0])} t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/> />
<Switch> <Switch>
@@ -1,8 +1,6 @@
import { Component, For, Match, Show, Switch } from "solid-js" import { Component, For, Match, Show, Switch } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/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" import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
export type AtOption = export type AtOption =
@@ -32,8 +30,6 @@ type PromptPopoverProps = {
setSlashActive: (id: string) => void setSlashActive: (id: string) => void
onSlashSelect: (item: SlashCommand) => void onSlashSelect: (item: SlashCommand) => void
commandKeybind: (id: string) => string | undefined commandKeybind: (id: string) => string | undefined
commandKeybindParts: (id: string) => string[]
newLayoutDesigns: boolean
t: (key: string) => string t: (key: string) => string
} }
@@ -45,29 +41,15 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (props.popover === "slash") props.setSlashPopoverRef(el) 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 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" overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
classList={{ bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
"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()} onMouseDown={(e) => e.preventDefault()}
> >
<Switch> <Switch>
<Match when={props.popover === "at"}> <Match when={props.popover === "at"}>
<Show <Show
when={props.atFlat.length > 0} when={props.atFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyResults")}
</div>
}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => { {(item) => {
@@ -76,29 +58,13 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
if (item.type === "agent") { if (item.type === "agent") {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span <span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
class="whitespace-nowrap"
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-v2-text-text-base": props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
@{item.name}
</span>
</button> </button>
) )
} }
@@ -109,44 +75,16 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
return ( return (
<button <button
class="w-full flex items-center gap-x-2 px-2 py-0.5" class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
"rounded-[4px]": props.newLayoutDesigns,
"rounded-md": !props.newLayoutDesigns,
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.atActive === key,
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.atActive === key,
}}
onClick={() => props.onAtSelect(item)} onClick={() => props.onAtSelect(item)}
onPointerMove={() => props.setAtActive(key)} onMouseEnter={() => props.setAtActive(key)}
> >
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div <div class="flex items-center text-14-regular min-w-0">
class="flex items-center min-w-0" <span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
classList={{
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
props.newLayoutDesigns,
"text-14-regular": !props.newLayoutDesigns,
}}
>
<span
class="whitespace-nowrap truncate min-w-0"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{directory}
</span>
<Show when={!isDirectory}> <Show when={!isDirectory}>
<span <span class="text-text-strong whitespace-nowrap">{filename}</span>
class="whitespace-nowrap"
classList={{
"text-v2-text-text-base": props.newLayoutDesigns,
"text-text-strong": !props.newLayoutDesigns,
}}
>
{filename}
</span>
</Show> </Show>
</div> </div>
</button> </button>
@@ -158,98 +96,41 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
<Match when={props.popover === "slash"}> <Match when={props.popover === "slash"}>
<Show <Show
when={props.slashFlat.length > 0} when={props.slashFlat.length > 0}
fallback={ fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
<div
class="px-2 py-1"
classList={{
"text-v2-text-text-muted": props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{props.t("prompt.popover.emptyCommands")}
</div>
}
> >
<For each={props.slashFlat}> <For each={props.slashFlat}>
{(cmd) => { {(cmd) => (
const keybind = () => props.commandKeybind(cmd.id) <button
const keybindParts = () => props.commandKeybindParts(cmd.id) data-slash-id={cmd.id}
return ( classList={{
<button "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
data-slash-id={cmd.id} "bg-surface-raised-base-hover": props.slashActive === cmd.id,
classList={{ }}
"w-full flex items-center justify-between gap-4 px-2 py-1": true, onClick={() => props.onSlashSelect(cmd)}
"rounded-[4px] scroll-my-2": props.newLayoutDesigns, onMouseEnter={() => props.setSlashActive(cmd.id)}
"rounded-md": !props.newLayoutDesigns, >
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id, <div class="flex items-center gap-2 min-w-0">
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id, <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
}} <Show when={cmd.description}>
onClick={() => props.onSlashSelect(cmd)} <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
onPointerMove={() => props.setSlashActive(cmd.id)} </Show>
> </div>
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-2 shrink-0">
<span <Show when={cmd.type === "custom" && cmd.source !== "command"}>
class="whitespace-nowrap" <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
classList={{ {cmd.source === "skill"
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": ? props.t("prompt.slash.badge.skill")
props.newLayoutDesigns, : cmd.source === "mcp"
"text-v2-text-text-base": props.newLayoutDesigns, ? props.t("prompt.slash.badge.mcp")
"text-14-regular": !props.newLayoutDesigns, : props.t("prompt.slash.badge.custom")}
"text-text-strong": !props.newLayoutDesigns,
}}
>
/{cmd.trigger}
</span> </span>
<Show when={cmd.description}> </Show>
<span <Show when={props.commandKeybind(cmd.id)}>
class="truncate" <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
classList={{ </Show>
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]": </div>
props.newLayoutDesigns, </button>
"text-v2-text-text-muted": props.newLayoutDesigns, )}
"text-14-regular": !props.newLayoutDesigns,
"text-text-weak": !props.newLayoutDesigns,
}}
>
{cmd.description}
</span>
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show
when={props.newLayoutDesigns}
fallback={
<span class="text-11-regular px-1.5 py-0.5 rounded bg-surface-base text-text-subtle">
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</span>
}
>
<Tag>
{cmd.source === "skill"
? props.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? props.t("prompt.slash.badge.mcp")
: props.t("prompt.slash.badge.custom")}
</Tag>
</Show>
</Show>
<Show when={props.newLayoutDesigns ? keybindParts().length > 0 : keybind()}>
<Show
when={props.newLayoutDesigns}
fallback={<span class="text-12-regular text-text-subtle">{keybind()}</span>}
>
<KeybindV2 keys={keybindParts()} variant="neutral" />
</Show>
</Show>
</div>
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>
@@ -1,9 +1,7 @@
import { Match, Show, Switch, createMemo } from "solid-js" import { Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ProgressCircleV2 } from "@opencode-ai/ui/v2/progress-circle-v2"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
@@ -17,7 +15,6 @@ import { createSessionTabs } from "@/pages/session/helpers"
interface SessionContextUsageProps { interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
buttonAppearance?: "default" | "v2"
placement?: TooltipProps["placement"] placement?: TooltipProps["placement"]
} }
@@ -42,7 +39,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const { params, tabs, view } = useSessionLayout() const { params, tabs, view } = useSessionLayout()
const variant = createMemo(() => props.variant ?? "button") const variant = createMemo(() => props.variant ?? "button")
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
const tabState = createSessionTabs({ const tabState = createSessionTabs({
tabs, tabs,
pathFromTab: file.pathFromTab, pathFromTab: file.pathFromTab,
@@ -84,11 +80,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} /> <ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
</div> </div>
) )
const circleV2 = () => (
<div class="flex items-center justify-center">
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
</div>
)
const tooltipValue = () => ( const tooltipValue = () => (
<div> <div>
@@ -122,16 +113,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}> <Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
<Switch> <Switch>
<Match when={variant() === "indicator"}>{circle()}</Match> <Match when={variant() === "indicator"}>{circle()}</Match>
<Match when={buttonAppearance() === "v2"}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
icon={circleV2()}
onClick={openContext}
aria-label={language.t("context.usage.view")}
/>
</Match>
<Match when={true}> <Match when={true}>
<Button <Button
type="button" type="button"
-101
View File
@@ -108,45 +108,6 @@
} }
} }
.home-session-group-header::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 12px;
background: var(--v2-background-bg-base);
}
.home-session-group-header::after {
content: "";
position: absolute;
top: 100%;
left: 0;
width: 100%;
height: 16px;
pointer-events: none;
background: linear-gradient(
180deg,
var(--v2-background-bg-base) 0%,
color-mix(in srgb, var(--v2-background-bg-base) 92.0456%, transparent) 7.93%,
color-mix(in srgb, var(--v2-background-bg-base) 84.9947%, transparent) 14.14%,
color-mix(in srgb, var(--v2-background-bg-base) 78.6813%, transparent) 19%,
color-mix(in srgb, var(--v2-background-bg-base) 72.9394%, transparent) 22.85%,
color-mix(in srgb, var(--v2-background-bg-base) 67.6028%, transparent) 26.05%,
color-mix(in srgb, var(--v2-background-bg-base) 62.5055%, transparent) 28.95%,
color-mix(in srgb, var(--v2-background-bg-base) 57.4815%, transparent) 31.91%,
color-mix(in srgb, var(--v2-background-bg-base) 52.3647%, transparent) 35.27%,
color-mix(in srgb, var(--v2-background-bg-base) 46.989%, transparent) 39.4%,
color-mix(in srgb, var(--v2-background-bg-base) 41.1884%, transparent) 44.65%,
color-mix(in srgb, var(--v2-background-bg-base) 34.7969%, transparent) 51.36%,
color-mix(in srgb, var(--v2-background-bg-base) 27.6484%, transparent) 59.9%,
color-mix(in srgb, var(--v2-background-bg-base) 19.5767%, transparent) 70.62%,
color-mix(in srgb, var(--v2-background-bg-base) 10.416%, transparent) 83.87%,
transparent 100%
);
}
[data-slot="titlebar-update-loader"] { [data-slot="titlebar-update-loader"] {
display: block; display: block;
flex-shrink: 0; flex-shrink: 0;
@@ -171,66 +132,4 @@
transform: rotate(360deg); 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);
}
}
} }
+89 -235
View File
@@ -1,6 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client" import type { Session } from "@opencode-ai/sdk/v2/client"
import { import {
type ComponentProps,
createEffect, createEffect,
createMemo, createMemo,
createResource, createResource,
@@ -69,9 +68,6 @@ import { archiveHomeSession } from "./home-session-archive"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
const HOME_SESSION_LIMIT = 64 const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16
const SHOW_HOME_SESSION_ARCHIVE = false const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -137,107 +133,6 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}` return `${pathKey(record.session.directory)}:${record.session.id}`
} }
function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let viewport: HTMLDivElement | undefined
let content: HTMLDivElement | undefined
let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({
titleOpacity: {} as Partial<Record<HomeSessionGroup["id"], number>>,
})
createEffect(() => {
const items = groups()
const ids = new Set(items.map((group) => group.id))
headerRefs.forEach((_, id) => {
if (!ids.has(id)) headerRefs.delete(id)
})
headerOffsets.forEach((_, id) => {
if (!ids.has(id)) headerOffsets.delete(id)
})
if (items.length === 0) {
content = undefined
bindResizeObserver()
}
queuePositionUpdate()
})
onCleanup(() => {
if (positionFrame !== undefined) cancelAnimationFrame(positionFrame)
resizeObserver?.disconnect()
})
function setViewport(el: HTMLDivElement) {
viewport = el
bindResizeObserver()
queuePositionUpdate()
}
function setContentRef(el: HTMLDivElement) {
content = el
bindResizeObserver()
queuePositionUpdate()
}
function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) {
headerRefs.set(id, el)
queuePositionUpdate()
}
function queuePositionUpdate() {
if (typeof requestAnimationFrame === "undefined") {
updatePositionCache()
return
}
if (positionFrame !== undefined) return
positionFrame = requestAnimationFrame(() => {
positionFrame = undefined
updatePositionCache()
})
}
function updatePositionCache() {
if (!viewport) return
groups().forEach((group) => {
const el = headerRefs.get(group.id)
if (!el) return
headerOffsets.set(group.id, el.offsetTop)
})
update(viewport.scrollTop)
}
function update(scrollTop: number) {
const items = groups()
items.forEach((group, index) => {
const nextOffset = items
.slice(index + 1)
.map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000)
})
}
function titleOpacity(id: HomeSessionGroup["id"]) {
return state.titleOpacity[id] ?? 1
}
function bindResizeObserver() {
resizeObserver?.disconnect()
if (typeof ResizeObserver === "undefined") return
resizeObserver = new ResizeObserver(() => queuePositionUpdate())
if (viewport) resizeObserver.observe(viewport)
if (content) resizeObserver.observe(content)
}
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
}
export function NewHome() { export function NewHome() {
const sync = useServerSync() const sync = useServerSync()
const layout = useLayout() const layout = useLayout()
@@ -328,7 +223,6 @@ export function NewHome() {
}) })
const searchOpen = createMemo(() => state.searchFocused && search().length > 0) const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language)) const groups = createMemo(() => groupSessions(records(), language))
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
const prefetched = new Set<string>() const prefetched = new Set<string>()
createEffect(() => { createEffect(() => {
@@ -541,7 +435,7 @@ export function NewHome() {
/> />
<section <section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative" class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12"
aria-label={language.t("sidebar.project.recentSessions")} aria-label={language.t("sidebar.project.recentSessions")}
> >
<HomeSessionSearch <HomeSessionSearch
@@ -562,25 +456,7 @@ export function NewHome() {
onClose={closeSearch} onClose={closeSearch}
onSelect={selectSearchSession} onSelect={selectSearchSession}
/> />
<ScrollView <ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
class="mt-3 -mr-3 min-h-0 flex-1 relative"
viewportRef={sessionHeaderOpacity.setViewport}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
>
<Show when={groups().length > 0 && newSessionProject()}>
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
<ButtonV2
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show <Show
when={!sessionLoad.isLoading} when={!sessionLoad.isLoading}
fallback={ fallback={
@@ -593,19 +469,15 @@ export function NewHome() {
when={groups().length > 0} when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
> >
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16"> <div class="flex flex-col gap-6 pt-3 pr-3 pb-16">
<For each={groups()}> <For each={groups()}>
{(group, index) => ( {(group, index) => (
<> <div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader <HomeSessionGroupHeader
title={group.title} title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)} onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/> />
<div <div class="flex min-w-0 flex-col gap-px">
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}> <For each={group.sessions}>
{(record) => ( {(record) => (
<HomeSessionRow <HomeSessionRow
@@ -619,7 +491,7 @@ export function NewHome() {
)} )}
</For> </For>
</div> </div>
</> </div>
)} )}
</For> </For>
</div> </div>
@@ -668,10 +540,10 @@ function HomeProjectColumn(props: {
return ( return (
<aside <aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]" class="mt-6 flex min-w-0 flex-col gap-4 lg:mt-14 lg:pt-[52px]"
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5"> <div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div> <div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<Show when={global.servers.list().length === 1}> <Show when={global.servers.list().length === 1}>
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}> <TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
@@ -688,51 +560,42 @@ function HomeProjectColumn(props: {
</TooltipV2> </TooltipV2>
</Show> </Show>
</div> </div>
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink"> <Show
<Show when={global.servers.list().length > 1}
when={global.servers.list().length > 1} fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
fallback={ >
<div class="pr-3"> <For each={global.servers.list()}>
<HomeProjectList {...props} server={global.servers.list()[0]!} /> {(item) => {
</div> const key = ServerConnection.key(item)
} const healthy = () => !!global.servers.health[key]?.healthy
> const serverCtx = global.ensureServerCtx(item)
<div class="flex min-w-0 flex-col gap-1 pr-3"> const collapsed = () => !!state().collapsed[key]
<For each={global.servers.list()}> return (
{(item) => { <div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
const key = ServerConnection.key(item) <HomeServerRow
const healthy = () => !!global.servers.health[key]?.healthy server={item}
const serverCtx = global.ensureServerCtx(item) selected={props.selected.server === key && !props.selected.directory}
const projects = () => serverCtx.projects.list() healthy={healthy()}
const hasProjects = () => projects().length > 0 collapsed={collapsed()}
const collapsed = () => !!state().collapsed[key] health={global.servers.health[key]}
return ( controller={controller}
<div class="flex min-w-0 flex-col gap-1"> focusServer={props.focusServer}
<HomeServerRow chooseProject={props.chooseProject}
server={item} openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
selected={props.selected.server === key && !props.selected.directory} toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
collapsed={collapsed()} language={props.language}
health={global.servers.health[key]} />
controller={controller} <Show when={healthy() && !collapsed()}>
focusServer={props.focusServer} <div class="mx-3 h-px bg-v2-border-border-base" />
chooseProject={props.chooseProject} <HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)} </Show>
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])} </div>
language={props.language} )
/> }}
<Show when={healthy() && hasProjects() && !collapsed()}> </For>
<div class="mx-3 h-px bg-v2-border-border-base" /> </Show>
<HomeProjectList {...props} server={item} projects={projects()} />
</Show>
</div>
)
}}
</For>
</div>
</Show>
</ScrollView>
<HomeUtilityNav <HomeUtilityNav
class="mb-8 mt-4 hidden shrink-0 lg:flex" class="mt-4 hidden lg:flex"
openSettings={props.openSettings} openSettings={props.openSettings}
openHelp={props.openHelp} openHelp={props.openHelp}
language={props.language} language={props.language}
@@ -772,6 +635,7 @@ function HomeUtilityNav(props: {
function HomeServerRow(props: { function HomeServerRow(props: {
server: ServerConnection.Any server: ServerConnection.Any
selected: boolean selected: boolean
healthy: boolean
collapsed: boolean collapsed: boolean
health: ServerHealth | undefined health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController> controller: ReturnType<typeof useServerManagementController>
@@ -781,46 +645,39 @@ function HomeServerRow(props: {
toggleCollapsed: () => void toggleCollapsed: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}) { }) {
const global = useGlobal()
const [state, setState] = createStore({ menuOpen: false }) const [state, setState] = createStore({ menuOpen: false })
const healthy = () => !!props.health?.healthy
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
return ( return (
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]"> <div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button <button
type="button" type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`} class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected ? "" : undefined} data-selected={props.selected ? "" : undefined}
disabled={!healthy()} disabled={!props.healthy}
onClick={() => props.focusServer(props.server)} onClick={() => props.focusServer(props.server)}
> >
<span <Show when={props.healthy}>
data-action="home-server-collapse" <span
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted" data-action="home-server-collapse"
classList={{ class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(), aria-label={
"cursor-default opacity-40": !canToggle(), props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
}} }
aria-label={ aria-expanded={!props.collapsed}
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse") onClick={(event) => {
} event.preventDefault()
aria-disabled={!canToggle()} event.stopPropagation()
aria-expanded={canToggle() ? !props.collapsed : undefined} props.toggleCollapsed()
onClick={(event) => { }}
event.preventDefault() onPointerDown={(event) => event.preventDefault()}
event.stopPropagation() >
if (!canToggle()) return <IconV2
props.toggleCollapsed() name="chevron-down"
}} size="small"
onPointerDown={(event) => event.preventDefault()} class="transition-transform duration-150 ease-in-out"
> style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
<IconV2 />
name="chevron-down" </span>
size="small" </Show>
class="transition-transform duration-150 ease-in-out"
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
/>
</span>
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5"> <div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
<ServerHealthIndicator health={props.health} /> <ServerHealthIndicator health={props.health} />
</div> </div>
@@ -997,7 +854,6 @@ function HomeSessionLeading(props: {
session: Session session: Session
server: ServerConnection.Key server: ServerConnection.Key
activeServer: boolean activeServer: boolean
revealProjectOnHover: boolean
}) { }) {
const tabs = useTabs() const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
@@ -1015,7 +871,6 @@ function HomeSessionLeading(props: {
directory={props.session.directory} directory={props.session.directory}
sessionId={props.session.id} sessionId={props.session.id}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={props.revealProjectOnHover}
/> />
</div> </div>
) )
@@ -1106,7 +961,7 @@ function HomeSessionSearch(props: {
return ( return (
<div class="w-full"> <div class="w-full">
<div ref={root} data-component="home-session-search" class="relative z-30 w-full"> <div ref={root} data-component="home-session-search" class="relative z-10 w-full">
<Show when={props.open}> <Show when={props.open}>
<div <div
data-component="home-session-search-panel" data-component="home-session-search-panel"
@@ -1255,7 +1110,6 @@ function HomeSessionSearchResultRow(props: {
classList={{ classList={{
[HOME_SEARCH_RESULT_ROW]: true, [HOME_SEARCH_RESULT_ROW]: true,
"bg-v2-overlay-simple-overlay-hover": props.selected, "bg-v2-overlay-simple-overlay-hover": props.selected,
group: !!showProjectName(),
}} }}
onMouseEnter={() => props.onHighlight()} onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)} onClick={() => props.onSelect(props.record.session)}
@@ -1265,7 +1119,6 @@ function HomeSessionSearchResultRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<div class="flex min-w-0 flex-1 items-center gap-1.5"> <div class="flex min-w-0 flex-1 items-center gap-1.5">
<span <span
@@ -1281,20 +1134,25 @@ function HomeSessionSearchResultRow(props: {
) )
} }
function HomeSessionGroupHeader(props: { function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
title: string const language = useLanguage()
titleOpacity: number
ref: ComponentProps<"div">["ref"]
elevated?: boolean
}) {
return ( return (
<div <div class="flex h-7 min-w-0 items-center justify-between pl-3">
ref={props.ref} <div class={HOME_SECTION_LABEL}>{props.title}</div>
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} <Show when={props.onNewSession}>
> {(onNewSession) => (
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> <ButtonV2
{props.title} data-action="home-new-session"
</div> variant="ghost-muted"
size="normal"
icon="edit"
class="h-7 px-2 [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div> </div>
) )
} }
@@ -1312,10 +1170,7 @@ function HomeSessionRow(props: {
const showProjectName = () => props.showProjectName && props.record.projectName const showProjectName = () => props.showProjectName && props.record.projectName
return ( return (
<div <div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
classList={{ group: !!showProjectName() }}
>
<button <button
type="button" type="button"
data-component="home-session-row" data-component="home-session-row"
@@ -1327,7 +1182,6 @@ function HomeSessionRow(props: {
session={props.record.session} session={props.record.session}
server={props.server} server={props.server}
activeServer={props.activeServer} activeServer={props.activeServer}
revealProjectOnHover={!!showProjectName()}
/> />
<span <span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`} class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${showProjectName() ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
@@ -11,28 +11,32 @@ export function SessionTabAvatar(props: {
directory: string directory: string
sessionId: string sessionId: string
activeServer: boolean activeServer: boolean
revealProjectOnHover?: boolean
}) { }) {
const directory = () => props.directory const directory = () => props.directory
const sessionId = () => props.sessionId const sessionId = () => props.sessionId
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
)
return ( return (
<Show when={state.loading()} fallback={projectAvatar()}> <Show
<span class="relative block size-4 shrink-0"> when={state.loading()}
<SessionProgressIndicatorV2 fallback={
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`} <ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/> />
<Show when={props.revealProjectOnHover !== false}> }
<span class="invisible absolute inset-0 group-hover:visible">{projectAvatar()}</span> >
</Show> <span class="relative block size-4 shrink-0">
<SessionProgressIndicatorV2 class="absolute inset-0 group-hover:invisible" />
<span class="invisible absolute inset-0 group-hover:visible">
<ProjectAvatar
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
variant={getProjectAvatarVariant(props.project?.icon?.color)}
unread={state.unread()}
/>
</span>
</span> </span>
</Show> </Show>
) )
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
</Show> </Show>
<div <div
classList={{ classList={{
"relative z-[70]": true, "relative z-30": true,
}} }}
style={{ style={{
"margin-top": `${-controller.lift()}px`, "margin-top": `${-controller.lift()}px`,
@@ -31,14 +31,9 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry" import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
@@ -676,34 +671,6 @@ export function MessageTimeline(props: {
if (!shareEnabled()) return if (!shareEnabled()) return
unshareMutation.mutate(id) unshareMutation.mutate(id)
} }
const copyShareUrl = () => {
const url = shareUrl()
if (!url) return
void navigator.clipboard
.writeText(url)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: url,
}),
)
.catch((err: unknown) =>
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
}),
)
}
const selectShareUrlText: JSX.EventHandler<HTMLDivElement, MouseEvent> = (event) => {
const selection = window.getSelection()
if (!selection) return
const range = document.createRange()
range.selectNodeContents(event.currentTarget)
selection.removeAllRanges()
selection.addRange(range)
}
createEffect( createEffect(
on( on(
@@ -889,26 +856,6 @@ export function MessageTimeline(props: {
dialog.close() dialog.close()
} }
if (settings.general.newLayoutDesigns())
return (
<DialogV2 fit>
<DialogHeader hideClose>
<DialogTitleGroup
title={language.t("session.delete.title")}
description={language.t("session.delete.confirm", { name: name() })}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
</DialogV2>
)
return ( return (
<Dialog title={language.t("session.delete.title")} fit> <Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
@@ -1013,7 +960,6 @@ export function MessageTimeline(props: {
message={message()} message={message()}
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)} showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
turnDurationMs={turnDurationMs(row().userMessageID)} turnDurationMs={turnDurationMs(row().userMessageID)}
useV2Actions={settings.general.newLayoutDesigns()}
defaultOpen={defaultOpen()} defaultOpen={defaultOpen()}
toolOpen={toolOpen[part().id] ?? defaultOpen()} toolOpen={toolOpen[part().id] ?? defaultOpen()}
onToolOpenChange={(open) => setToolOpen(part().id, open)} onToolOpenChange={(open) => setToolOpen(part().id, open)}
@@ -1121,7 +1067,6 @@ export function MessageTimeline(props: {
message={message()} message={message()}
parts={getMsgParts(userMessageRow().userMessageID)} parts={getMsgParts(userMessageRow().userMessageID)}
actions={props.actions} actions={props.actions}
useV2Actions={settings.general.newLayoutDesigns()}
/> />
</div> </div>
</div> </div>
@@ -1359,16 +1304,11 @@ export function MessageTimeline(props: {
"pr-3": true, "pr-3": true,
"pl-4": settings.general.newLayoutDesigns(), "pl-4": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !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(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}} }}
> >
<div class="h-12 w-full flex items-center justify-between gap-2"> <div class="h-12 w-full flex items-center justify-between gap-2">
<div <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
classList={{
"flex items-center gap-1 min-w-0 flex-1": true,
"pr-3": !settings.general.newLayoutDesigns(),
}}
>
<div class="flex items-center min-w-0 grow-1"> <div class="flex items-center min-w-0 grow-1">
<Show when={parentID()}> <Show when={parentID()}>
<button <button
@@ -1407,17 +1347,8 @@ export function MessageTimeline(props: {
data-slot="session-title-child" data-slot="session-title-child"
value={title.draft} value={title.draft}
disabled={titleMutation.isPending} disabled={titleMutation.isPending}
classList={{ class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
"text-14-medium text-text-strong grow-1 min-w-0 pl-1 -ml-1": true, style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
"h-6 leading-4 rounded-[3px] 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()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)} onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
event.stopPropagation() event.stopPropagation()
@@ -1439,170 +1370,88 @@ export function MessageTimeline(props: {
</div> </div>
<Show when={sessionID()} keyed> <Show when={sessionID()} keyed>
{(id) => ( {(id) => (
<div <div class="shrink-0 flex items-center gap-3">
classList={{ <SessionContextUsage placement="bottom" />
"shrink-0 flex items-center": true,
"gap-2": settings.general.newLayoutDesigns(),
"gap-3": !settings.general.newLayoutDesigns(),
}}
>
<SessionContextUsage
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={!parentID()}> <Show when={!parentID()}>
<Show <DropdownMenu
when={settings.general.newLayoutDesigns()} gutter={4}
fallback={ placement="bottom-end"
<DropdownMenu open={title.menuOpen}
gutter={4} onOpenChange={(open) => {
placement="bottom-end" setTitle("menuOpen", open)
open={title.menuOpen} if (open) return
onOpenChange={(open) => { }}
setTitle("menuOpen", open) >
if (open) return <DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<DropdownMenu.Trigger <DropdownMenu.Item
as={IconButton} onSelect={() => {
icon="dot-grid" setTitle("pendingRename", true)
variant="ghost" setTitle("menuOpen", false)
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
classList={{
"bg-surface-base-active": share.open || title.pendingShare,
}}
aria-label={language.t("common.moreOptions")}
aria-expanded={title.menuOpen || share.open || title.pendingShare}
ref={(el: HTMLButtonElement) => {
more = el
}}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingShare: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("session.share.action.share")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
}
>
<MenuV2
gutter={6}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => {
setTitle("menuOpen", open)
if (open) return
}}
>
<MenuV2.Trigger
as={IconButtonV2}
icon={<IconV2 name="outline-dots" />}
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
}}
/>
<MenuV2.Portal>
<MenuV2.Content
style={{ width: "120px", "min-width": "120px" }}
onCloseAutoFocus={(event) => {
if (title.pendingRename) {
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
return
}
if (title.pendingShare) {
event.preventDefault()
requestAnimationFrame(() => {
setShare({ open: true, dismiss: null })
setTitle("pendingShare", false)
})
}
}} }}
> >
<MenuV2.Item <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => { onSelect={() => {
setTitle("pendingRename", true) setTitle({ pendingShare: true, menuOpen: false })
setTitle("menuOpen", false)
}} }}
> >
{language.t("common.rename")} <DropdownMenu.ItemLabel>
</MenuV2.Item> {language.t("session.share.action.share")}
<Show when={shareEnabled()}> </DropdownMenu.ItemLabel>
<MenuV2.Item </DropdownMenu.Item>
onSelect={() => { </Show>
setTitle({ pendingShare: true, menuOpen: false }) <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
}} <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
> </DropdownMenu.Item>
{language.t("session.share.action.share")}... <DropdownMenu.Separator />
</MenuV2.Item> <DropdownMenu.Item
</Show> onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
<MenuV2.Item onSelect={() => void archiveSession(id)}> >
{language.t("common.archive")} <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</MenuV2.Item> </DropdownMenu.Item>
<MenuV2.Separator /> </DropdownMenu.Content>
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}> </DropdownMenu.Portal>
{language.t("common.delete")}... </DropdownMenu>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
<KobaltePopover <KobaltePopover
open={share.open} open={share.open}
anchorRef={() => more} anchorRef={() => more}
placement="bottom-end" placement="bottom-end"
gutter={settings.general.newLayoutDesigns() ? 6 : 4} gutter={4}
modal={false} modal={false}
onOpenChange={(open) => { onOpenChange={(open) => {
if (open) setShare("dismiss", null) if (open) setShare("dismiss", null)
@@ -1612,10 +1461,6 @@ export function MessageTimeline(props: {
<KobaltePopover.Portal> <KobaltePopover.Portal>
<KobaltePopover.Content <KobaltePopover.Content
data-component="popover-content" data-component="popover-content"
classList={{
"flex w-80 max-w-none flex-col items-start gap-3 rounded-[10px] border-0 bg-v2-background-bg-layer-01 p-3 shadow-[var(--v2-elevation-floating)]":
settings.general.newLayoutDesigns(),
}}
style={{ "min-width": "320px" }} style={{ "min-width": "320px" }}
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
setShare({ dismiss: "escape", open: false }) setShare({ dismiss: "escape", open: false })
@@ -1633,90 +1478,24 @@ export function MessageTimeline(props: {
setShare("dismiss", null) setShare("dismiss", null)
}} }}
> >
<Show <div class="flex flex-col p-3">
when={settings.general.newLayoutDesigns()} <div class="flex flex-col gap-1">
fallback={ <div class="text-13-medium text-text-strong">
<div class="flex flex-col p-3">
<div class="flex flex-col gap-1">
<div class="text-13-medium text-text-strong">
{language.t("session.share.popover.title")}
</div>
<div class="text-12-regular text-text-weak">
{shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")}
</div>
</div>
<div class="mt-3 flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<Button
size="large"
variant="primary"
class="w-full"
onClick={shareSession}
disabled={shareMutation.isPending}
>
{shareMutation.isPending
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
}
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
tabIndex={-1}
class="w-full"
/>
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={unshareMutation.isPending}
>
{unshareMutation.isPending
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div>
</div>
</Show>
</div>
</div>
}
>
<div class="flex w-full flex-col gap-1.5 px-0.5 pt-0.5">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]">
{language.t("session.share.popover.title")} {language.t("session.share.popover.title")}
</div> </div>
<div class="select-none text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-variation-settings:'slnt'_0]"> <div class="text-12-regular text-text-weak">
{shareUrl() {shareUrl()
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")} : language.t("session.share.popover.description.unshared")}
</div> </div>
</div> </div>
<div class="flex w-full flex-col gap-2"> <div class="mt-3 flex flex-col gap-2">
<Show <Show
when={shareUrl()} when={shareUrl()}
fallback={ fallback={
<ButtonV2 <Button
variant="contrast" size="large"
variant="primary"
class="w-full" class="w-full"
onClick={shareSession} onClick={shareSession}
disabled={shareMutation.isPending} disabled={shareMutation.isPending}
@@ -1724,57 +1503,48 @@ export function MessageTimeline(props: {
{shareMutation.isPending {shareMutation.isPending
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</ButtonV2> </Button>
} }
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div <TextField
class="flex h-8 w-full items-center gap-1.5 rounded-[6px] py-1 pl-2.5 pr-1.5 shadow-[var(--v2-elevation-button-neutral)]" value={shareUrl() ?? ""}
style={{ readOnly
background: copyable
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)", copyKind="link"
}} tabIndex={-1}
> class="w-full"
<div />
class="min-w-0 flex-1 truncate select-text cursor-text text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-variation-settings:'slnt'_0]" <div class="grid grid-cols-2 gap-2">
onClick={selectShareUrlText} <Button
> size="large"
{shareUrl()} variant="secondary"
</div> class={
<IconButtonV2 settings.general.newLayoutDesigns()
type="button" ? "w-full shadow-none border-[0.5px] border-border-weak-base"
size="small" : "w-full shadow-none border border-border-weak-base"
variant="ghost-muted" }
icon={<IconV2 name="outline-copy" />}
aria-label={language.t("session.share.copy.copyLink")}
onClick={copyShareUrl}
/>
<IconButtonV2
type="button"
size="small"
variant="ghost-muted"
icon={<IconV2 name="outline-square-arrow" />}
aria-label={language.t("session.share.action.view")}
onClick={viewShare}
disabled={unshareMutation.isPending}
/>
</div>
<div class="flex w-full">
<ButtonV2
variant="outline"
class="w-full"
onClick={unshareSession} onClick={unshareSession}
disabled={unshareMutation.isPending} disabled={unshareMutation.isPending}
> >
{unshareMutation.isPending {unshareMutation.isPending
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</ButtonV2> </Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={unshareMutation.isPending}
>
{language.t("session.share.action.view")}
</Button>
</div> </div>
</div> </div>
</Show> </Show>
</div> </div>
</Show> </div>
</KobaltePopover.Content> </KobaltePopover.Content>
</KobaltePopover.Portal> </KobaltePopover.Portal>
</KobaltePopover> </KobaltePopover>
@@ -137,7 +137,7 @@ export async function handler(
const providerBudgetTracker = createProviderBudgetTracker( const providerBudgetTracker = createProviderBudgetTracker(
modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })), modelInfo.providers.map((provider) => ({ ...zenData.providers[provider.id], ...provider })),
) )
const providerBudget = await providerBudgetTracker?.check() const providerBudgetUsage = await providerBudgetTracker?.check()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => { const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider( const providerInfo = selectProvider(
@@ -151,7 +151,7 @@ export async function handler(
stickyProvider, stickyProvider,
modelTpmLimits, modelTpmLimits,
modelTpsLimits, modelTpsLimits,
providerBudget, providerBudgetUsage,
) )
validateModelSettings(billingSource, authInfo) validateModelSettings(billingSource, authInfo)
updateProviderKey(authInfo, providerInfo) updateProviderKey(authInfo, providerInfo)
@@ -216,9 +216,6 @@ export async function handler(
return headers return headers
})(), })(),
body: reqBody, 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.")) { if (providerInfo.id.startsWith("console.")) {
@@ -287,7 +284,7 @@ export async function handler(
const costInfo = calculateCost(modelInfo, usageInfo) const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo) await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo) await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
await providerBudgetTracker?.track(providerInfo.id, providerInfo.budgetPriority, costInfo.totalCostInCent) await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
json.cost = calculateOccurredCost(billingSource, costInfo) json.cost = calculateOccurredCost(billingSource, costInfo)
@@ -314,10 +311,9 @@ export async function handler(
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format) const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
const usageParser = providerInfo.createUsageParser() const usageParser = providerInfo.createUsageParser()
const binaryDecoder = providerInfo.createBinaryStreamDecoder() const binaryDecoder = providerInfo.createBinaryStreamDecoder()
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
const stream = new ReadableStream({ const stream = new ReadableStream({
start(c) { start(c) {
reader = res.body?.getReader() const reader = res.body?.getReader()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const encoder = new TextEncoder() const encoder = new TextEncoder()
@@ -349,11 +345,7 @@ export async function handler(
timestampLastByte, timestampLastByte,
usageInfo, usageInfo,
) )
await providerBudgetTracker?.track( await providerBudgetTracker?.track(providerInfo.id, costInfo.totalCostInCent)
providerInfo.id,
providerInfo.budgetPriority,
costInfo.totalCostInCent,
)
await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo)
await reload(billingSource, authInfo, costInfo) await reload(billingSource, authInfo, costInfo)
const cost = calculateOccurredCost(billingSource, costInfo) const cost = calculateOccurredCost(billingSource, costInfo)
@@ -403,11 +395,6 @@ export async function handler(
return pump() 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, { return new Response(stream, {
status: resStatus, status: resStatus,
@@ -415,15 +402,6 @@ export async function handler(
headers: resHeaders, headers: resHeaders,
}) })
} catch (error: any) { } 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({ logger.metric({
"error.type": error.constructor.name, "error.type": error.constructor.name,
"error.message": error.message, "error.message": error.message,
@@ -534,12 +512,7 @@ export async function handler(
stickyProviderId: string | undefined, stickyProviderId: string | undefined,
modelTpmLimits: Record<string, number> | undefined, modelTpmLimits: Record<string, number> | undefined,
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined, modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
providerBudget: providerBudgetUsage: Record<string, number> | undefined,
| {
qualify: (providerId: string, priority: number) => boolean
prefer: (providerId: string, priority: number) => boolean
}
| undefined,
) { ) {
const modelProvider = (() => { const modelProvider = (() => {
// Byok is top priority b/c if user set their own API key, we should use it // Byok is top priority b/c if user set their own API key, we should use it
@@ -563,9 +536,10 @@ export async function handler(
.filter((provider) => provider.weight !== 0) .filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id)) .filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => { .filter((provider) => {
if (provider.budgetPriority === undefined) return true if (provider.budgetMode !== "fill") return true
if (!providerBudget) return true const budget = zenData.providers[provider.id]?.budget
return providerBudget.qualify(provider.id, provider.budgetPriority) if (budget === undefined) return false
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
}) })
.filter((provider) => { .filter((provider) => {
if (!provider.tpmLimit) return true if (!provider.tpmLimit) return true
@@ -602,19 +576,15 @@ export async function handler(
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId) const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
if (!stickProvider) return provider if (!stickProvider) return provider
const preferBudgetProvider = // stick provider exists + selected provider is API type => use sticky provider
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority) if (!provider.tpsGoal) return stickProvider
const preferTpsProvider = (() => { // stick provier exists + selected provider is GPU type + GPU not idle => use selected provider
if (!provider.tpsGoal) return false const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? { qualify: 0,
qualify: 0, unqualify: 0,
unqualify: 0, }
} if (tps.qualify <= tps.unqualify * 3) return stickProvider
return tps.qualify > tps.unqualify * 3
})()
if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
return provider return provider
} }
@@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
) )
const now = Date.now() const now = Date.now()
const currInterval = toInterval(new Date(now)) const currInterval = toInterval(new Date(now))
const prevInterval = toInterval(new Date(now - 60_000)) const prevInterval = toInterval(new Date(now - 60 * 1000))
return { return {
check: async () => { check: async () => {
@@ -2,148 +2,50 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
import { buildRateLimitKey, getRedis } from "./redis" import { buildRateLimitKey, getRedis } from "./redis"
import { logger } from "./logger" import { logger } from "./logger"
// Per-provider, per-minute budget with priorities. The budget belongs to a
// provider and is shared across every model that routes to it. Each model's
// provider entry carries a `budgetPriority`: priority 1 ("always") routes
// unconditionally, while higher priorities ("fill") only route while the provider's
// current-minute spend through that priority is still under budget.
//
// Spend is tracked per (provider, priority, minute) so a fill priority can yield its
// leftover headroom to the next priority down. The previous minute is also read so
// higher priorities can reserve the next minute's budget first.
export function createProviderBudgetTracker( export function createProviderBudgetTracker(
providers: { providers: {
id: string id: string
budget?: number budget?: number
budgetContribution?: number budgetContribution?: number
budgetPriority?: number budgetMode?: "always" | "fill"
}[], }[],
) { ) {
const tracked = providers.filter( const tracked = providers.filter(
(provider) => (provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
provider.budget !== undefined &&
provider.budgetContribution !== undefined &&
provider.budgetPriority !== undefined,
) )
if (tracked.length === 0) return undefined if (tracked.length === 0) return undefined
const intervalAt = (date: Date) => const interval = new Date()
date .toISOString()
.toISOString() .replace(/[^0-9]/g, "")
.replace(/[^0-9]/g, "") .substring(0, 12)
.substring(0, 12)
const now = new Date()
const currInterval = intervalAt(now)
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
const redis = getRedis() const redis = getRedis()
const key = (providerId: string, priority: number, withInterval: string) => const keys = Object.fromEntries(
buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval) tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
)
const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => { let budgetUsage: Record<string, number> = {}
acc[provider.id] = provider.budget!
return acc
}, {})
const maxPriorityByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
acc[provider.id] = Math.max(acc[provider.id] ?? 0, provider.budgetPriority!)
return acc
}, {})
// Effective budget in micro-cents per provider/priority, computed in check()
// from the configured budget minus previous-minute usage from higher priorities.
let effectiveBudget: Record<string, Record<number, number>> = {}
// Cumulative current-minute spend through each priority, per provider.
let spentThroughPriority: Record<string, Record<number, number>> = {}
let previousSpentThroughPriority: Record<string, Record<number, number>> = {}
return { return {
// Returns whether a provider at a given priority still has budget headroom.
// Priority 1 always qualifies; higher priorities qualify only while everything through
// the current priority hasn't already filled the previous-minute adjusted
// budget.
check: async () => { check: async () => {
const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) => const ids = tracked.map((provider) => provider.id)
Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [ if (ids.length === 0) return {}
{ providerId, priority, interval: currInterval, prev: false }, const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
{ providerId, priority, interval: prevInterval, prev: true }, budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
]), return budgetUsage
)
const values = await redis.mget<(string | number | null)[]>(
reads.map((r) => key(r.providerId, r.priority, r.interval)),
)
const current: Record<string, Record<number, number>> = {}
const previous: Record<string, Record<number, number>> = {}
reads.forEach((r, index) => {
const amount = Number(values[index] ?? 0)
if (r.prev) {
previous[r.providerId] ??= {}
previous[r.providerId][r.priority] = amount
return
}
current[r.providerId] ??= {}
current[r.providerId][r.priority] = amount
})
effectiveBudget = {}
spentThroughPriority = {}
previousSpentThroughPriority = {}
Object.entries(maxPriorityByProvider).forEach(([providerId, maxPriority]) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return
const budget = centsToMicroCents(providerBudget * 100)
let currentRunning = 0
let previousRunning = 0
effectiveBudget[providerId] = {}
spentThroughPriority[providerId] = {}
previousSpentThroughPriority[providerId] = {}
Array.from({ length: maxPriority }, (_, index) => index + 1).forEach((priority) => {
currentRunning += current[providerId]?.[priority] ?? 0
effectiveBudget[providerId][priority] = Math.max(0, budget - previousRunning)
previousRunning += previous[providerId]?.[priority] ?? 0
spentThroughPriority[providerId][priority] = currentRunning
previousSpentThroughPriority[providerId][priority] = previousRunning
})
})
return {
// Priority 1 is unconditional. Higher priorities gate on the spend through
// the current priority against the effective budget.
qualify: (providerId: string, priority: number) => {
if (priority <= 1) return true
const budget = effectiveBudget[providerId]?.[priority]
if (budget === undefined) return false
const spentThroughCurrentPriority = spentThroughPriority[providerId]?.[priority] ?? 0
return spentThroughCurrentPriority < budget
},
prefer: (providerId: string, priority: number) => {
const providerBudget = budgetByProvider[providerId]
if (providerBudget === undefined) return false
const budget = centsToMicroCents(providerBudget * 100)
const previousUsage = previousSpentThroughPriority[providerId]?.[priority]
if (previousUsage === undefined) return false
return previousUsage < budget * 0.8
},
}
}, },
track: async (provider: string, priority: number | undefined, costInCent: number) => { track: async (provider: string, costInCent: number) => {
if (priority === undefined) return const config = tracked.find((item) => item.id === provider)
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
if (!config) return if (!config) return
if (config.budgetContribution === undefined) return if (config.budgetContribution === undefined) return
const cost = centsToMicroCents(costInCent * config.budgetContribution) const cost = centsToMicroCents(costInCent * config.budgetContribution)
if (cost <= 0) return if (cost <= 0) return
const redisKey = key(provider, priority, currInterval)
const pipeline = redis.pipeline() const pipeline = redis.pipeline()
pipeline.incrby(redisKey, cost) pipeline.incrby(keys[provider], cost)
// Keep two minutes so the previous interval is readable for budget adjustment. pipeline.expire(keys[provider], 120)
pipeline.expire(redisKey, 120)
await pipeline.exec() await pipeline.exec()
logger.metric({ logger.metric({
"provider.budget_usage": cost, "provider.budget_usage": budgetUsage[provider] + cost,
"provider.budget_priority": priority, "model.budget_usage": cost,
}) })
}, },
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export namespace ZenData {
priority: z.number().optional(), priority: z.number().optional(),
tpmLimit: z.number().optional(), tpmLimit: z.number().optional(),
tpsGoal: z.number().optional(), tpsGoal: z.number().optional(),
budgetPriority: z.number().optional(), budgetMode: z.enum(["always", "fill"]).optional(),
budgetContribution: z.number().optional(), budgetContribution: z.number().optional(),
weight: z.number().optional(), weight: z.number().optional(),
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
@@ -1,7 +1,6 @@
export * as MoveSession from "./move-session" export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect" import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import { EventV2 } from "../event" import { EventV2 } from "../event"
import { Git } from "../git" import { Git } from "../git"
import { Location } from "../location" import { Location } from "../location"
@@ -147,9 +146,3 @@ export const defaultLayer = layer.pipe(
Layer.provide(ProjectV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer),
Layer.provide(SessionStore.defaultLayer), Layer.provide(SessionStore.defaultLayer),
) )
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
})
+1 -44
View File
@@ -272,50 +272,7 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
} }
function replacementMapFrom(replacements?: Replacements) { function replacementMapFrom(replacements?: Replacements) {
return ( return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
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<string, AnyNode>()) ?? new Map<string, AnyNode>()
)
}
function rewriteReplacementDependencies(root: AnyNode, replacements: ReadonlyMap<string, AnyNode>) {
if (replacements.size === 0) return root
const cache = new Map<AnyNode, AnyNode>()
const visiting = new Set<AnyNode>()
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<unknown, unknown, any>, source: AnyNode): boolean { export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
-3
View File
@@ -1,7 +1,6 @@
export * as Observability from "./observability" export * as Observability from "./observability"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "./effect/layer-node"
import { Effect, Layer, Logger, References } from "effect" import { Effect, Layer, Logger, References } from "effect"
import { FetchHttpClient } from "effect/unstable/http" import { FetchHttpClient } from "effect/unstable/http"
import { OtlpSerialization } from "effect/unstable/observability" import { OtlpSerialization } from "effect/unstable/observability"
@@ -20,5 +19,3 @@ export const layer = Layer.unwrap(
return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer))
}), }),
) )
export const node = LayerNode.make({ name: "observability", layer, deps: [] })
-8
View File
@@ -4,13 +4,11 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch" import { Patch } from "../patch"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -196,12 +194,6 @@ 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 { function patchFile(change: Prepared): typeof FileDiff.Info.Type {
const counts = diffLines(change.before, change.after).reduce( const counts = diffLines(change.before, change.after).reduce(
(result, item) => ({ (result, item) => ({
-8
View File
@@ -5,13 +5,11 @@ import { ToolFailure } from "@opencode-ai/llm"
import { Duration, Effect, Layer, Schema } from "effect" import { Duration, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { Config } from "../config" import { Config } from "../config"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { AppProcess } from "../process" import { AppProcess } from "../process"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -195,9 +193,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/bash",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
})
+30 -13
View File
@@ -15,6 +15,20 @@ import { TodoWriteTool } from "./todowrite"
import { WebFetchTool } from "./webfetch" import { WebFetchTool } from "./webfetch"
import { WebSearchTool } from "./websearch" import { WebSearchTool } from "./websearch"
import { WriteTool } from "./write" 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. * Composes only the shipped Location-scoped built-in tool transforms.
@@ -46,19 +60,22 @@ export const locationLayer = Layer.mergeAll(
export const node = makeLocationNode({ export const node = makeLocationNode({
name: "built-in-tools", name: "built-in-tools",
layer: Layer.empty, layer: locationLayer,
deps: [ deps: [
ApplyPatchTool.node, ToolRegistry.toolsNode,
BashTool.node, FSUtil.node,
EditTool.node, AppProcess.node,
GlobTool.node, Config.node,
GrepTool.node, Location.node,
QuestionTool.node, LocationMutation.node,
ReadTool.node, FileMutation.node,
SkillTool.node, PermissionV2.node,
TodoWriteTool.node, Ripgrep.node,
WebFetchTool.node, Image.node,
WebSearchTool.node, QuestionV2.node,
WriteTool.node, SkillV2.node,
SessionTodo.node,
ReadToolFileSystem.node,
httpClient,
], ],
}) })
-8
View File
@@ -10,12 +10,10 @@ import { ToolFailure } from "@opencode-ai/llm"
import { FileDiff } from "@opencode-ai/schema/file-diff" import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch, diffLines } from "diff" import { createTwoFilesPatch, diffLines } from "diff"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -215,9 +213,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/edit",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
})
-8
View File
@@ -3,13 +3,11 @@ export * as GlobTool from "./glob"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import path from "path" import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { Location } from "../location" import { Location } from "../location"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema" import { RelativePath } from "../schema"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -97,9 +95,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/glob",
layer,
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
})
-8
View File
@@ -3,14 +3,12 @@ export * as GrepTool from "./grep"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import path from "path" import path from "path"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { Location } from "../location" import { Location } from "../location"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { Ripgrep } from "../ripgrep" import { Ripgrep } from "../ripgrep"
import { RelativePath } from "../schema" import { RelativePath } from "../schema"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -129,9 +127,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/grep",
layer,
deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
})
-8
View File
@@ -2,10 +2,8 @@ export * as QuestionTool from "./question"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question" import { QuestionV2 } from "../question"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -86,9 +84,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/question",
layer,
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
})
-8
View File
@@ -2,14 +2,12 @@ export * as ReadTool from "./read"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileSystem } from "../filesystem" import { FileSystem } from "../filesystem"
import { Image } from "../image" import { Image } from "../image"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { AbsolutePath } from "../schema" import { AbsolutePath } from "../schema"
import { ReadToolFileSystem } from "./read-filesystem" import { ReadToolFileSystem } from "./read-filesystem"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -109,9 +107,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/read",
layer,
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
})
-8
View File
@@ -3,11 +3,9 @@ export * as SkillTool from "./skill"
import path from "path" import path from "path"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FSUtil } from "../fs-util" import { FSUtil } from "../fs-util"
import { SkillV2 } from "../skill" import { SkillV2 } from "../skill"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -101,9 +99,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/skill",
layer,
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
})
-8
View File
@@ -2,10 +2,8 @@ export * as TodoWriteTool from "./todowrite"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { SessionTodo } from "../session/todo" import { SessionTodo } from "../session/todo"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -54,9 +52,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/todowrite",
layer,
deps: [ToolRegistry.node, PermissionV2.node, SessionTodo.node],
})
-9
View File
@@ -5,11 +5,8 @@ import { Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2" import { Parser } from "htmlparser2"
import TurndownService from "turndown" import TurndownService from "turndown"
import { makeLocationNode } from "../effect/app-node"
import { LayerNodePlatform } from "../effect/app-node-platform"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { collectBoundedResponseBody } from "./http-body" import { collectBoundedResponseBody } from "./http-body"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -180,12 +177,6 @@ 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) { export function extractTextFromHTML(html: string) {
let text = "" let text = ""
let skipDepth = 0 let skipDepth = 0
-11
View File
@@ -3,8 +3,6 @@ export * as WebSearchTool from "./websearch"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Context, Duration, Effect, Layer, Schema } from "effect" import { Context, Duration, Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http" 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 { truthy } from "../flag/flag"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
import { PositiveInt } from "../schema" import { PositiveInt } from "../schema"
@@ -13,7 +11,6 @@ import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
import { collectBoundedResponseBody } from "./http-body" import { collectBoundedResponseBody } from "./http-body"
import { checksum } from "../util/encode" import { checksum } from "../util/encode"
import { ToolRegistry } from "./registry"
export const name = "websearch" export const name = "websearch"
export const NO_RESULTS = "No search results found. Please try a different query." export const NO_RESULTS = "No search results found. Please try a different query."
@@ -83,8 +80,6 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
}), }),
) )
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
export function selectProvider( export function selectProvider(
sessionID: string, sessionID: string,
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false }, flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
@@ -252,9 +247,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/websearch",
layer,
deps: [ToolRegistry.node, PermissionV2.node, LayerNodePlatform.httpClient, configNode],
})
-8
View File
@@ -8,11 +8,9 @@ export * as WriteTool from "./write"
import { ToolFailure } from "@opencode-ai/llm" import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "../effect/app-node"
import { FileMutation } from "../file-mutation" import { FileMutation } from "../file-mutation"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { ToolRegistry } from "./registry"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Tools } from "./tools" import { Tools } from "./tools"
@@ -93,9 +91,3 @@ export const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
}), }),
) )
export const node = makeLocationNode({
name: "tool/write",
layer,
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
})
+1 -2
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Exit, Scope } from "effect" import { Effect, Exit, Scope } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" 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 { Location } from "@opencode-ai/core/location"
import { AgentPlugin } from "@opencode-ai/core/plugin/agent" import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -9,7 +8,7 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { agentHost, host } from "./plugin/host" import { agentHost, host } from "./plugin/host"
const it = testEffect(AppNodeBuilder.build(AgentV2.node)) const it = testEffect(AgentV2.locationLayer)
describe("AgentV2", () => { describe("AgentV2", () => {
it.effect("starts without agents", () => it.effect("starts without agents", () =>
+3 -6
View File
@@ -1,11 +1,8 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { BackgroundJob } from "@opencode-ai/core/background-job" 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 { Deferred, Effect, Exit, Scope } from "effect"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const jobsLayer = LayerNode.compile(BackgroundJob.node)
describe("BackgroundJob", () => { describe("BackgroundJob", () => {
it.live("tracks process-local work through explicit observation", () => it.live("tracks process-local work through explicit observation", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -28,7 +25,7 @@ describe("BackgroundJob", () => {
timedOut: false, timedOut: false,
info: { status: "completed", output: "done" }, info: { status: "completed", output: "done" },
}) })
}).pipe(Effect.provide(jobsLayer)), }).pipe(Effect.provide(BackgroundJob.layer)),
) )
it.live("publishes jobs before starting immediately settling work", () => it.live("publishes jobs before starting immediately settling work", () =>
@@ -58,7 +55,7 @@ describe("BackgroundJob", () => {
}) })
}) })
}) })
}).pipe(Effect.provide(jobsLayer)), }).pipe(Effect.provide(BackgroundJob.layer)),
) )
it.live("increments pending work before starting immediately settling extensions", () => it.live("increments pending work before starting immediately settling extensions", () =>
@@ -83,7 +80,7 @@ describe("BackgroundJob", () => {
}) })
}), }),
) )
}).pipe(Effect.provide(jobsLayer)), }).pipe(Effect.provide(BackgroundJob.layer)),
) )
it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => it.live("interrupts live work without promising settlement after the owning process-local scope closes", () =>
+1 -2
View File
@@ -1,12 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { CommandV2 } from "@opencode-ai/core/command" 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 { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(CommandV2.node)) const it = testEffect(CommandV2.locationLayer)
describe("CommandV2", () => { describe("CommandV2", () => {
it.effect("applies command transforms and preserves later overrides", () => it.effect("applies command transforms and preserves later overrides", () =>
+7 -4
View File
@@ -5,6 +5,7 @@ import { Effect, Layer, Schema } from "effect"
import { FastCheck } from "effect/testing" import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigProvider } from "@opencode-ai/core/config/provider" 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate" import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
@@ -27,6 +28,11 @@ function testLayer(
projectDirectory = directory, projectDirectory = directory,
vcs?: Project.Vcs, 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( const locationLayer = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of( Location.Service.of(
@@ -36,10 +42,7 @@ function testLayer(
), ),
), ),
) )
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [ return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]])
[Location.node, locationLayer],
[Global.node, Global.layerWith({ config: globalDirectory })],
])
} }
const provider = { const provider = {
@@ -15,8 +15,6 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" 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 simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" 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 { EventV2 } from "@opencode-ai/core/event"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -270,6 +268,7 @@ describe("DatabaseMigration", () => {
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration]) yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
const database = Layer.succeed(Database.Service, { db }) const database = Layer.succeed(Database.Service, { db })
const events = EventV2.layer.pipe(Layer.provide(database))
yield* EventV2.Service.use((service) => yield* EventV2.Service.use((service) =>
service.publish(SessionV1.Event.Updated, { service.publish(SessionV1.Event.Updated, {
sessionID: SessionSchema.ID.make("session"), sessionID: SessionSchema.ID.make("session"),
@@ -285,7 +284,7 @@ describe("DatabaseMigration", () => {
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build(LayerNode.group([EventV2.node, SessionProjector.node]), [[Database.node, database]]), Layer.merge(events, SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))),
), ),
) )
@@ -69,10 +69,10 @@ void invalidNodeReplacement
// @ts-expect-error Replacement cannot introduce a new error // @ts-expect-error Replacement cannot introduce a new error
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]]) LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
const invalidNodeErrorReplacement = () => 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, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]]) // @ts-expect-error Node replacement cannot introduce a new error
void invalidNodeErrorReplacement LayerNode.compile(a, [[a, nodeReplacementWithError]])
class TagA extends Context.Service<TagA, {}>()("test/TagA") {} class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
class TagB extends Context.Service<TagB, {}>()("test/TagB") {} class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
@@ -143,21 +143,6 @@ describe("layer node", () => {
expect(acquisitions).toBe(0) 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 () => { test("hoists and compiles tagged graphs", async () => {
const tags = LayerNode.tags({ location: ["global"], global: [] }) const tags = LayerNode.tags({ location: ["global"], global: [] })
const global = tags.make("global") const global = tags.make("global")
+5 -8
View File
@@ -6,8 +6,6 @@ import { Session } from "@opencode-ai/schema/session"
import { SessionEvent } from "@opencode-ai/schema/session-event" import { SessionEvent } from "@opencode-ai/schema/session-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1" import { SessionV1 } from "@opencode-ai/schema/session-v1"
import { Database } from "@opencode-ai/core/database/database" 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 { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -78,10 +76,9 @@ const durableData = (sessionID: Session.ID, text: string) => ({
messageID: SessionV1.MessageID.ascending(`msg_${text}`), messageID: SessionV1.MessageID.ascending(`msg_${text}`),
}) })
const it = testEffect( const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]), const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
) const itWithoutLocation = testEffect(eventLayer)
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
describe("EventV2", () => { describe("EventV2", () => {
it.effect("publishes events with the current location", () => it.effect("publishes events with the current location", () =>
@@ -465,7 +462,7 @@ describe("EventV2", () => {
pause pause
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead))) ? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
: Effect.void, : Effect.void,
}).pipe(Layer.provide(LayerNode.compile(Database.node))) }).pipe(Layer.provide(Database.defaultLayer))
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const events = yield* EventV2.Service const events = yield* EventV2.Service
@@ -480,7 +477,7 @@ describe("EventV2", () => {
expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([ expect(Array.from(yield* Fiber.join(fiber)).map((event) => [event.durable?.seq, event.data])).toEqual([
[0, durableData(aggregateID, "during handoff")], [0, durableData(aggregateID, "during handoff")],
]) ])
}).pipe(Effect.provide(Layer.merge(LayerNode.compile(Database.node), eventLayer))) }).pipe(Effect.provide(Layer.mergeAll(Database.defaultLayer, eventLayer)))
}), }),
) )
+5 -10
View File
@@ -2,8 +2,6 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect" 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 { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -13,17 +11,14 @@ import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect" import { it } from "./lib/effect"
function provide(directory: string, filesystemLayer = LayerNode.compile(FSUtil.node)) { function provide(directory: string, filesystem = FSUtil.defaultLayer) {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
) )
return Effect.provide( const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [ const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
[Location.node, activeLocation], return Effect.provide(Layer.mergeAll(resolution, mutation))
[FSUtil.node, filesystemLayer],
]),
)
} }
function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) { function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
@@ -364,5 +359,5 @@ function instrumentWrites(run: <E>(write: Effect.Effect<void, E>, target: string
run(filesystem.writeFileString(target, content, options), target), run(filesystem.writeFileString(target, content, options), target),
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
} }
+13 -9
View File
@@ -4,11 +4,10 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect" import { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Config } from "@opencode-ai/core/config" 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 { EventV2 } from "@opencode-ai/core/event"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Git } from "@opencode-ai/core/git"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { location } from "../fixture/location" import { location } from "../fixture/location"
@@ -19,7 +18,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
const it = testEffect(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node]))) const it = testEffect(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))
const configLayer = Layer.succeed( const configLayer = Layer.succeed(
Config.Service, Config.Service,
@@ -41,10 +40,12 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })), Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
) )
return Effect.provide( return Effect.provide(
AppNodeBuilder.build(Watcher.node, [ Watcher.layer.pipe(
[Config.node, configLayer], Layer.provide(configLayer),
[Location.node, locationLayer], Layer.provide(Git.defaultLayer),
]).pipe(Layer.provide(flagsLayer)), Layer.provide(locationLayer),
Layer.provide(flagsLayer),
),
) )
} }
@@ -195,7 +196,7 @@ describeWatcher("Watcher", () => {
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe( yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
Effect.provideService(EventV2.Service, events), Effect.provideService(EventV2.Service, events),
) )
}).pipe(Effect.provide(AppNodeBuilder.build(LayerNode.group([FSUtil.node, EventV2.node])))), }).pipe(Effect.provide(Layer.mergeAll(FSUtil.defaultLayer, EventV2.defaultLayer))),
) )
it.live("ignores .git/index changes", () => it.live("ignores .git/index changes", () =>
@@ -227,7 +228,10 @@ describeWatcher("Watcher", () => {
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet()) yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
expect( expect(
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)), yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toMatchObject({ file: head }) ).toEqual({
file: head,
event: "change",
})
}), }),
{ git: true }, { git: true },
), ),
@@ -1,7 +1,7 @@
import fs from "fs/promises" import fs from "fs/promises"
import os from "os" import os from "os"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
log: os.tmpdir(), log: os.tmpdir(),
}) })
const testLayer = AppNodeBuilder.build(EffectFlock.node, [[Global.node, testGlobal]]) const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(FSUtil.defaultLayer))
async function job() { async function job() {
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid)) if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
+61 -87
View File
@@ -2,8 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" 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 { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { InstructionContext } from "@opencode-ai/core/instruction-context" import { InstructionContext } from "@opencode-ai/core/instruction-context"
@@ -17,17 +15,6 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty) const it = testEffect(Layer.empty)
const instructionLayer = (input: {
config: string
locationServiceLayer: Layer.Layer<Location.Service>
filesystemLayer?: Layer.Layer<FSUtil.Service>
}) =>
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", () => { describe("InstructionContext", () => {
it.live("loads global and upward project AGENTS.md files as one aggregate context", () => it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
Effect.acquireRelease( Effect.acquireRelease(
@@ -54,19 +41,19 @@ describe("InstructionContext", () => {
const load = SystemContextRegistry.Service.pipe( const load = SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), 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( Effect.provide(
instructionLayer({ Layer.succeed(
config: global, Location.Service,
locationServiceLayer: Layer.succeed( Location.Service.of(
Location.Service, location(
Location.Service.of( { directory: AbsolutePath.make(directory) },
location( { projectDirectory: AbsolutePath.make(project) },
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(project) },
),
), ),
), ),
}), ),
), ),
) )
@@ -120,14 +107,14 @@ describe("InstructionContext", () => {
yield* Effect.promise(() => fs.writeFile(file, "")) yield* Effect.promise(() => fs.writeFile(file, ""))
const context = yield* SystemContextRegistry.Service.pipe( const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), 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( Effect.provide(
instructionLayer({ Layer.succeed(
config: path.join(tmp.path, "global"), Location.Service,
locationServiceLayer: Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
Location.Service, ),
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
),
}),
), ),
) )
@@ -146,18 +133,14 @@ describe("InstructionContext", () => {
FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }), FSUtil.Service.of({ ...fs, up: () => Effect.fail(new FSUtil.FileSystemError({ method: "up" })) }),
), ),
), ),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const context = yield* SystemContextRegistry.Service.pipe( const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), 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( Effect.provide(
instructionLayer({ Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
config: "/global",
filesystemLayer: failingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
), ),
) )
@@ -186,18 +169,14 @@ describe("InstructionContext", () => {
}), }),
), ),
), ),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const context = yield* SystemContextRegistry.Service.pipe( const context = yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), 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( Effect.provide(
instructionLayer({ Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
config: "/global",
filesystemLayer: racingFS,
locationServiceLayer: Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
),
}),
), ),
) )
@@ -229,21 +208,20 @@ describe("InstructionContext", () => {
}), }),
), ),
), ),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
yield* SystemContextRegistry.Service.pipe( yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), 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( Effect.provide(
instructionLayer({ Layer.succeed(
config: "/global", Location.Service,
filesystemLayer: observingFS, Location.Service.of(
locationServiceLayer: Layer.succeed( location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
),
), ),
}), ),
), ),
) )
@@ -263,20 +241,18 @@ describe("InstructionContext", () => {
yield* SystemContextRegistry.Service.pipe( yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide( Effect.provide(
instructionLayer({ Layer.effect(
config: "/global", FSUtil.Service,
filesystemLayer: Layer.effect( FSUtil.Service.pipe(
FSUtil.Service, Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
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.ensuring(
Effect.sync(() => { Effect.sync(() => {
@@ -295,25 +271,23 @@ describe("InstructionContext", () => {
let scanned = false let scanned = false
yield* SystemContextRegistry.Service.pipe( yield* SystemContextRegistry.Service.pipe(
Effect.flatMap((service) => service.load()), Effect.flatMap((service) => service.load()),
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
Effect.provide( Effect.provide(
instructionLayer({ Layer.effect(
config: "/global", FSUtil.Service,
filesystemLayer: Layer.effect( FSUtil.Service.pipe(
FSUtil.Service, Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
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") },
),
),
), ),
}), ).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") }),
),
),
), ),
) )
+20 -5
View File
@@ -1,14 +1,12 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { DateTime, Effect, Equal, Hash, Schema } from "effect" import { DateTime, Effect, Equal, Hash, Layer, Schema } from "effect"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { define } from "@opencode-ai/plugin/v2/effect" import { define } from "@opencode-ai/plugin/v2/effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
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 { Location } from "@opencode-ai/core/location"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
@@ -32,8 +30,25 @@ import { Reference } from "../src/reference"
import { ToolRegistry } from "../src/tool/registry" import { ToolRegistry } from "../src/tool/registry"
import { ApplicationTools } from "../src/tool/application-tools" import { ApplicationTools } from "../src/tool/application-tools"
const applicationTools = ApplicationTools.layer
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, Database.node, EventV2.node, LocationServiceMap.node])), 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,
),
),
),
),
) )
describe("LocationServiceMap", () => { describe("LocationServiceMap", () => {
+1 -2
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -22,7 +21,7 @@ const projectLayer = Layer.succeed(
commit: () => Effect.void, commit: () => Effect.void,
}), }),
) )
const it = testEffect(AppNodeBuilder.build(Location.boundNode(ref), [[Project.node, projectLayer]])) const it = testEffect(Location.layer(ref).pipe(Layer.provide(projectLayer)))
describe("Location", () => { describe("Location", () => {
it.effect("resolves the current project and vcs information", () => it.effect("resolves the current project and vcs information", () =>
+1 -1
View File
@@ -88,7 +88,7 @@ const makeMockClient = (state: Ref.Ref<MockState>) =>
) )
const buildLayer = (state: Ref.Ref<MockState>) => const buildLayer = (state: Ref.Ref<MockState>) =>
// Layer.fresh is required because the ModelsDev implementation is a module-level Layer constant, // Layer.fresh is required: ModelsDev.layer is a module-level Layer constant,
// and Effect.provide uses a process-global MemoMap by default — without fresh, // and Effect.provide uses a process-global MemoMap by default — without fresh,
// every test would reuse the cachedInvalidateWithTTL state from the first run. // every test would reuse the cachedInvalidateWithTTL state from the first run.
Layer.fresh( Layer.fresh(
+36 -13
View File
@@ -3,34 +3,57 @@ import { $ } from "bun"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session" import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database" import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { FSUtil } from "@opencode-ai/core/fs-util"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Git } from "@opencode-ai/core/git"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectDirectories } from "@opencode-ai/core/project/directories" import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" 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 { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql" import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" 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( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([ layer,
MoveSession.node, Database.defaultLayer,
Database.node, EventV2.defaultLayer,
EventV2.node, ProjectDirectories.defaultLayer,
ProjectDirectories.node, project,
Project.node, SessionProjector.defaultLayer,
SessionProjector.node, SessionStore.defaultLayer,
SessionStore.node, sessions,
]),
), ),
) )
+10 -3
View File
@@ -1,10 +1,12 @@
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Option } from "effect" import { NodeFileSystem } from "@effect/platform-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Effect, Layer, Option } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
const win = process.platform === "win32" const win = process.platform === "win32"
@@ -19,7 +21,12 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
) )
const npmLayer = (cache: string) => const npmLayer = (cache: string) =>
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]]) 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),
)
describe("Npm.sanitize", () => { describe("Npm.sanitize", () => {
test("keeps normal scoped package specs unchanged", () => { test("keeps normal scoped package specs unchanged", () => {
+19 -14
View File
@@ -2,8 +2,6 @@ import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect" import { Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -13,7 +11,9 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionTable } from "@opencode-ai/core/session/sql" 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 { SessionStore } from "@opencode-ai/core/session/store"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { location } from "./fixture/location" import { location } from "./fixture/location"
@@ -23,19 +23,24 @@ const current = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make("/project") })), Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
) )
const it = testEffect( const sessions = SessionV2.layer.pipe(
AppNodeBuilder.build( Layer.provide(locationServiceMapLayer),
LayerNode.group([ Layer.provide(EventV2.defaultLayer),
Database.node, Layer.provide(Database.defaultLayer),
EventV2.node, Layer.provide(SessionStore.defaultLayer),
SessionStore.node, Layer.provide(Project.defaultLayer),
PermissionSaved.node, Layer.provide(SessionExecution.noopLayer),
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 = []) { function setup(rules: PermissionV2.Ruleset = []) {
return Effect.gen(function* () { return Effect.gen(function* () {
+6 -5
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { CommandV2 } from "@opencode-ai/core/command" 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 { Location } from "@opencode-ai/core/location"
import { CommandPlugin } from "@opencode-ai/core/plugin/command" import { CommandPlugin } from "@opencode-ai/core/plugin/command"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -11,11 +10,13 @@ import { host } from "./host"
const directory = AbsolutePath.make("/repo/packages/app") const directory = AbsolutePath.make("/repo/packages/app")
const project = AbsolutePath.make("/repo") const project = AbsolutePath.make("/repo")
const locationLayer = Layer.succeed( const it = testEffect(
Location.Service, CommandV2.locationLayer.pipe(
Location.Service.of(location({ directory }, { projectDirectory: project })), Layer.provide(
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
),
),
) )
const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]))
describe("CommandPlugin.Plugin", () => { describe("CommandPlugin.Plugin", () => {
it.effect("registers built-in init and review commands", () => it.effect("registers built-in init and review commands", () =>
+27 -42
View File
@@ -1,52 +1,37 @@
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 { 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 { EventV2 } from "@opencode-ai/core/event"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Integration } from "@opencode-ai/core/integration" import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location"
import { Npm } from "@opencode-ai/core/npm" import { Npm } from "@opencode-ai/core/npm"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { Reference } from "@opencode-ai/core/reference" import { RepositoryCache } from "@opencode-ai/core/repository-cache"
import { SkillV2 } from "@opencode-ai/core/skill" import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { tempLocationLayer } from "../fixture/location" import { tempLocationLayer } from "../fixture/location"
const npmLayer = Layer.succeed( export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
Npm.Service, Layer.provideMerge(
Npm.Service.of({ Layer.mergeAll(
add: () => Effect.succeed({ directory: "", entrypoint: undefined }), Credential.defaultLayer,
install: () => Effect.void, EventV2.defaultLayer,
which: () => Effect.succeed(undefined), FetchHttpClient.layer,
}), FSUtil.defaultLayer,
) Global.defaultLayer,
Layer.succeed(
export const PluginTestLayer = AppNodeBuilder.build( Npm.Service,
LayerNode.group([ Npm.Service.of({
FileSystem.node, add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
FSUtil.node, install: () => Effect.void,
Location.node, which: () => Effect.succeed(undefined),
Npm.node, }),
Credential.node, ),
EventV2.node, RepositoryCache.defaultLayer,
LayerNodePlatform.httpClient, SkillDiscovery.defaultLayer,
PluginV2.node, Ripgrep.defaultLayer,
AgentV2.node, tempLocationLayer,
AISDK.node, ),
Catalog.node, ),
CommandV2.node,
Integration.node,
Reference.node,
SkillV2.node,
]),
[
[Location.node, tempLocationLayer],
[Npm.node, npmLayer],
],
) )
@@ -6,7 +6,6 @@ import os from "os"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { AISDK } from "@opencode-ai/core/aisdk" 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 { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginHost } from "@opencode-ai/core/plugin/host"
@@ -18,7 +17,7 @@ import { PluginTestLayer } from "./fixture"
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const fixtureProviderPath = fileURLToPath(fixtureProvider) const fixtureProviderPath = fileURLToPath(fixtureProvider)
const it = testEffect(PluginTestLayer) const it = testEffect(PluginTestLayer)
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node))) const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
function npmEntrypoint(entrypoint?: string) { function npmEntrypoint(entrypoint?: string) {
return Npm.Service.of({ return Npm.Service.of({
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog" import { Catalog } from "@opencode-ai/core/catalog"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin" import { PluginV2 } from "@opencode-ai/core/plugin"
@@ -17,12 +16,8 @@ const it = testEffect(PluginTestLayer)
const addPlugin = Effect.fn(function* () { const addPlugin = Effect.fn(function* () {
const plugin = yield* PluginV2.Service const plugin = yield* PluginV2.Service
const host = yield* PluginHost.make(plugin) const host = yield* PluginHost.make(plugin)
const events = yield* EventV2.Service
const integration = yield* Integration.Service const integration = yield* Integration.Service
yield* OpencodePlugin.effect(host).pipe( yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
Effect.provideService(EventV2.Service, events),
Effect.provideService(Integration.Service, integration),
)
}) })
function required<T>(value: T | undefined): T { function required<T>(value: T | undefined): T {
+4 -6
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Policy } from "@opencode-ai/core/policy" import { Policy } from "@opencode-ai/core/policy"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -8,12 +7,11 @@ import { location } from "./fixture/location"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(Policy.node, [ Policy.locationLayer.pipe(
[ Layer.provide(
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
], ),
]), ),
) )
describe("Policy", () => { describe("Policy", () => {
+11 -12
View File
@@ -1,8 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect" import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
import { Config } from "@opencode-ai/core/config" 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 { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Pty } from "@opencode-ai/core/pty" import { Pty } from "@opencode-ai/core/pty"
@@ -19,10 +17,11 @@ const locationLayer = Layer.succeed(
) )
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const it = testEffect( const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ Pty.layer.pipe(
[Config.node, configLayer], Layer.provide(configLayer),
[Location.node, locationLayer], Layer.provideMerge(EventV2.defaultLayer),
]), Layer.provideMerge(locationLayer),
),
) )
const ptyTest = process.platform === "win32" ? it.live.skip : it.live const ptyTest = process.platform === "win32" ? it.live.skip : it.live
@@ -206,9 +205,8 @@ describe("pty", () => {
const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash")
const configuredIt = testEffect( const configuredIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [ Pty.layer.pipe(
[ Layer.provide(
Config.node,
Layer.mock(Config.Service)({ Layer.mock(Config.Service)({
entries: () => entries: () =>
Effect.succeed( Effect.succeed(
@@ -217,9 +215,10 @@ const configuredIt = testEffect(
: [], : [],
), ),
}), }),
], ),
[Location.node, locationLayer], Layer.provideMerge(EventV2.defaultLayer),
]), Layer.provideMerge(locationLayer),
),
) )
const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live
+2 -5
View File
@@ -1,15 +1,12 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyID } from "@opencode-ai/core/pty/schema"
import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { PtyTicket } from "@opencode-ai/core/pty/ticket"
import { WorkspaceV2 } from "@opencode-ai/core/workspace" import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
const it = testEffect(LayerNode.compile(PtyTicket.node)) const it = testEffect(PtyTicket.layer)
const itExpiring = testEffect( const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
)
describe("PTY websocket tickets", () => { describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () => it.live("consumes tickets once", () =>
+28 -31
View File
@@ -1,15 +1,11 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Reference } from "@opencode-ai/core/reference" import { Reference } from "@opencode-ai/core/reference"
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance" import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
import { SystemContext } from "@opencode-ai/core/system-context/index" import { SystemContext } from "@opencode-ai/core/system-context/index"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
describe("ReferenceGuidance", () => { describe("ReferenceGuidance", () => {
it.effect("lists available references in the system context", () => it.effect("lists available references in the system context", () =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -21,24 +17,23 @@ describe("ReferenceGuidance", () => {
expect(generation.baseline).toContain("<path>/docs</path>") expect(generation.baseline).toContain("<path>/docs</path>")
expect(generation.baseline).toContain("<description>Use for product documentation</description>") expect(generation.baseline).toContain("<description>Use for product documentation</description>")
}).pipe( }).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide( Effect.provide(
guidanceLayer( Layer.mock(Reference.Service, {
Layer.mock(Reference.Service, { list: () =>
list: () => Effect.succeed([
Effect.succeed([ new Reference.Info({
new Reference.Info({ name: "docs",
name: "docs", path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
source: Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make("/docs"), path: AbsolutePath.make("/docs"),
description: "Use for product documentation", description: "Use for product documentation",
source: Reference.LocalSource.make({
type: "local",
path: AbsolutePath.make("/docs"),
description: "Use for product documentation",
}),
}), }),
]), }),
}), ]),
), }),
), ),
), ),
) )
@@ -48,7 +43,10 @@ describe("ReferenceGuidance", () => {
const guidance = yield* ReferenceGuidance.Service const guidance = yield* ReferenceGuidance.Service
const generation = yield* SystemContext.initialize(yield* guidance.load()) const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("") expect(generation.baseline).toBe("")
}).pipe(Effect.provide(guidanceLayer(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })))), }).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide(Layer.mock(Reference.Service, { list: () => Effect.succeed([]) })),
),
) )
it.effect("omits references without descriptions", () => it.effect("omits references without descriptions", () =>
@@ -57,19 +55,18 @@ describe("ReferenceGuidance", () => {
const generation = yield* SystemContext.initialize(yield* guidance.load()) const generation = yield* SystemContext.initialize(yield* guidance.load())
expect(generation.baseline).toBe("") expect(generation.baseline).toBe("")
}).pipe( }).pipe(
Effect.provide(ReferenceGuidance.layer),
Effect.provide( Effect.provide(
guidanceLayer( Layer.mock(Reference.Service, {
Layer.mock(Reference.Service, { list: () =>
list: () => Effect.succeed([
Effect.succeed([ new Reference.Info({
new Reference.Info({ name: "docs",
name: "docs", path: AbsolutePath.make("/docs"),
path: AbsolutePath.make("/docs"), source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }), }),
}), ]),
]), }),
}),
),
), ),
), ),
) )
+21 -13
View File
@@ -4,8 +4,6 @@ import { Effect, Layer, Stream } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -15,6 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { SessionV1 } from "@opencode-ai/core/v1/session" import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Prompt } from "@opencode-ai/core/session/prompt" import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -35,13 +34,23 @@ const projects = Layer.succeed(
commit: () => Effect.void, 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( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), Database.defaultLayer,
[ EventV2.defaultLayer,
[ProjectV2.node, projects], projects,
[SessionExecution.node, SessionExecution.noopLayer], SessionProjector.defaultLayer,
], SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
), ),
) )
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
@@ -239,10 +248,9 @@ describe("SessionV2.create", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
) )
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite")) const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
const targetLayer = AppNodeBuilder.build( const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]), const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
[[Database.node, targetDatabase]], const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
)
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
@@ -290,7 +298,7 @@ describe("SessionV2.create", () => {
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)], [1, EventV2.versionedType(SessionEvent.PromptAdmitted.type, 1)],
[2, EventV2.versionedType(SessionEvent.Prompted.type, 1)], [2, EventV2.versionedType(SessionEvent.Prompted.type, 1)],
]) ])
}).pipe(Effect.provide(Layer.fresh(targetLayer))) }).pipe(Effect.provide(Layer.fresh(Layer.mergeAll(targetDatabase, targetEvents, targetProjector, targetStore))))
}), }),
) )
+17 -8
View File
@@ -1,10 +1,9 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -23,13 +22,23 @@ const projects = Layer.succeed(
commit: () => Effect.void, 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( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), Database.defaultLayer,
[ EventV2.defaultLayer,
[ProjectV2.node, projects], projects,
[SessionExecution.node, SessionExecution.noopLayer], SessionProjector.defaultLayer,
], SessionStore.defaultLayer,
SessionExecution.noopLayer,
sessions,
), ),
) )
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
+16 -5
View File
@@ -2,8 +2,6 @@ import { describe, expect } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -11,6 +9,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Prompt } from "@opencode-ai/core/session/prompt" import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -42,10 +41,22 @@ 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( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]), Database.defaultLayer,
[[SessionExecution.node, execution]], EventV2.defaultLayer,
SessionProjector.defaultLayer,
SessionStore.defaultLayer,
execution,
sessions,
), ),
) )
const sessionID = SessionV2.ID.make("ses_prompt_test") const sessionID = SessionV2.ID.make("ses_prompt_test")
@@ -3,9 +3,6 @@ import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -15,6 +12,7 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { Prompt } from "@opencode-ai/core/session/prompt" import { Prompt } from "@opencode-ai/core/session/prompt"
import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -24,7 +22,6 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store" import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -60,6 +57,8 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const agents = AgentV2.layer
const model = OpenAIChat.route const model = OpenAIChat.route
.with({ .with({
endpoint: { baseURL: "https://api.openai.com/v1" }, endpoint: { baseURL: "https://api.openai.com/v1" },
@@ -68,22 +67,26 @@ const model = OpenAIChat.route
}) })
.model({ id: "gpt-4o-mini" }) .model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model)) const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = AppNodeBuilder.build(SystemContextRegistry.node) const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) }) const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const referenceGuidance = Layer.mock(ReferenceGuidance.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 config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ const runner = SessionRunnerLLM.defaultLayer.pipe(
[Snapshot.node, Snapshot.noopLayer], Layer.provide(Snapshot.noopLayer),
[LayerNodePlatform.llmClient, client], Layer.provide(Database.defaultLayer),
[SessionRunnerModel.node, models], Layer.provide(SessionStore.defaultLayer),
[SystemContextRegistry.node, systemContext], Layer.provide(EventV2.defaultLayer),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], Layer.provide(client),
[SkillGuidance.node, skillGuidance], Layer.provide(registry),
[ReferenceGuidance.node, referenceGuidance], Layer.provide(models),
[Config.node, config], Layer.provide(systemContext),
[PermissionV2.node, permission], Layer.provide(location),
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], Layer.provide(agents),
]) Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const execution = Layer.effect( const execution = Layer.effect(
SessionExecution.Service, SessionExecution.Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -98,38 +101,34 @@ const execution = Layer.effect(
interrupt: coordinator.interrupt, interrupt: coordinator.interrupt,
}) })
}), }),
).pipe(Layer.provide(runnerLayer)) ).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),
)
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([ Database.defaultLayer,
Database.node, EventV2.defaultLayer,
EventV2.node, SessionProjector.defaultLayer,
SessionProjector.node, SessionStore.defaultLayer,
SessionStore.node, executor,
AgentV2.node, client,
ToolRegistry.node, permission,
SessionRunnerModel.node, agents,
SystemContextRegistry.node, registry,
SkillGuidance.node, models,
ReferenceGuidance.node, systemContext,
Config.node, location,
Snapshot.node, skillGuidance,
SessionRunnerLLM.node, config,
SessionV2.node, runner,
]), execution,
[ sessions,
[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") const sessionID = SessionV2.ID.make("ses_runner_recorded")
@@ -1,8 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Tool } from "@opencode-ai/core/tool/tool" import { Tool } from "@opencode-ai/core/tool/tool"
import { AgentV2 } from "@opencode-ai/core/agent" 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 { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionMessage } from "@opencode-ai/core/session/message"
@@ -29,13 +27,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
) )
}, },
}) })
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
const it = testEffect(registryLayer) const it = testEffect(registry)
const integrated = testEffect( const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [
[ToolOutputStore.node, outputStore],
]),
)
const identity = { const identity = {
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"), assistantMessageID: SessionMessage.ID.make("msg_registry"),
+57 -53
View File
@@ -11,10 +11,6 @@ import {
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat" import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql" import { EventTable } from "@opencode-ai/core/event/sql"
@@ -23,6 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
import { QuestionV2 } from "@opencode-ai/core/question" import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error" import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -36,6 +33,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm" import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { AgentV2 } from "@opencode-ai/core/agent" import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
@@ -59,6 +57,7 @@ import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream }
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
const requests: LLMRequest[] = [] const requests: LLMRequest[] = []
let response: LLMEvent[] = [] let response: LLMEvent[] = []
let responses: LLMEvent[][] | undefined let responses: LLMEvent[][] | undefined
@@ -121,6 +120,13 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), 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( const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) => ToolRegistry.Service.use((registry) =>
registry.register({ registry.register({
@@ -150,8 +156,7 @@ 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 modelResolveHook = Effect.void
let currentModel = model let currentModel = model
const models = SessionRunnerModel.layerWith((session) => const models = SessionRunnerModel.layerWith((session) =>
@@ -191,7 +196,8 @@ const systemContext = Layer.effectDiscard(
}), }),
), ),
), ),
).pipe(Layer.provideMerge(AppNodeBuilder.build(SystemContextRegistry.node))) ).pipe(Layer.provideMerge(SystemContextRegistry.layer))
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { const skillGuidance = Layer.mock(SkillGuidance.Service, {
load: (agent) => load: (agent) =>
Effect.succeed( Effect.succeed(
@@ -225,17 +231,21 @@ const config = Layer.succeed(
]), ]),
}), }),
) )
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [ const runner = SessionRunnerLLM.layer.pipe(
[Snapshot.node, Snapshot.noopLayer], Layer.provide(Snapshot.noopLayer),
[LayerNodePlatform.llmClient, client], Layer.provide(Database.defaultLayer),
[SessionRunnerModel.node, models], Layer.provide(SessionStore.defaultLayer),
[SystemContextRegistry.node, systemContext], Layer.provide(EventV2.defaultLayer),
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], Layer.provide(client),
[SkillGuidance.node, skillGuidance], Layer.provide(registry),
[ReferenceGuidance.node, referenceGuidance], Layer.provide(models),
[PermissionV2.node, permission], Layer.provide(systemContext),
[Config.node, config], Layer.provide(location),
]) Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(referenceGuidance),
Layer.provide(config),
)
const execution = Layer.effect( const execution = Layer.effect(
SessionExecution.Service, SessionExecution.Service,
Effect.gen(function* () { Effect.gen(function* () {
@@ -250,42 +260,36 @@ const execution = Layer.effect(
interrupt: coordinator.interrupt, interrupt: coordinator.interrupt,
}) })
}), }),
).pipe(Layer.provide(runnerLayer)) ).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),
)
const it = testEffect( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(
LayerNode.group([ Database.defaultLayer,
Database.node, EventV2.defaultLayer,
EventV2.node, questions,
QuestionV2.node, SessionProjector.defaultLayer,
SessionProjector.node, SessionStore.defaultLayer,
SessionStore.node, client,
ApplicationTools.node, permission,
AgentV2.node, applications,
ToolRegistry.node, agents,
ToolRegistry.toolsNode, registry,
echoNode, echo,
SessionRunnerModel.node, models,
SystemContextRegistry.node, systemContext,
SkillGuidance.node, location,
ReferenceGuidance.node, skillGuidance,
Config.node, config,
Snapshot.node, runner,
SessionRunnerLLM.node, execution,
SessionExecution.node, sessions,
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") const sessionID = SessionV2.ID.make("ses_runner_test")
+1 -4
View File
@@ -2,7 +2,6 @@ import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent" 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 { AbsolutePath } from "@opencode-ai/core/schema"
import { SkillV2 } from "@opencode-ai/core/skill" import { SkillV2 } from "@opencode-ai/core/skill"
import { SystemContext } from "@opencode-ai/core/system-context" import { SystemContext } from "@opencode-ai/core/system-context"
@@ -29,9 +28,7 @@ const denied = SkillV2.Info.make({
}) })
const layer = (list: () => SkillV2.Info[]) => const layer = (list: () => SkillV2.Info[]) =>
AppNodeBuilder.build(SkillGuidance.node, [ SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
[SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
])
describe("SkillGuidance", () => { describe("SkillGuidance", () => {
it.effect("renders described agent skills and reconciles the complete available list", () => { it.effect("renders described agent skills and reconciles the complete available list", () => {
+17 -6
View File
@@ -3,9 +3,12 @@ import { describe, expect } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Config } from "@opencode-ai/core/config"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Git } from "@opencode-ai/core/git"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { Hash } from "@opencode-ai/core/util/hash" import { Hash } from "@opencode-ai/core/util/hash"
@@ -115,7 +118,9 @@ describe("Snapshot", () => {
return (yield* Location.Service).project.id return (yield* Location.Service).project.id
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(project) }))), Location.layer(Location.Ref.make({ directory: AbsolutePath.make(project) })).pipe(
Layer.provide(Project.defaultLayer),
),
), ),
) )
expect( expect(
@@ -167,10 +172,16 @@ describe("Snapshot", () => {
}) })
function snapshotLayer(data: string, directory: string) { function snapshotLayer(data: string, directory: string) {
return AppNodeBuilder.build(Snapshot.node, [ const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))], Layer.provide(Project.defaultLayer),
[Global.node, Global.layerWith({ data, config: path.join(data, "config") })], )
]) 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") })),
)
} }
function read(file: string) { function read(file: string) {
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, Effect, Exit, Schema, Scope } from "effect" 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 { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry" import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -18,7 +17,7 @@ const entry = (key: string, text: string, sourceKey = key) => ({
), ),
}) })
const it = testEffect(AppNodeBuilder.build(SystemContextRegistry.node)) const it = testEffect(SystemContextRegistry.layer)
describe("SystemContextRegistry", () => { describe("SystemContextRegistry", () => {
it.effect("loads empty system context when there are no entries", () => it.effect("loads empty system context when there are no entries", () =>
+12 -23
View File
@@ -2,8 +2,6 @@ import fs from "fs/promises"
import path from "path" import path from "path"
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect" 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 { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -12,7 +10,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -84,34 +81,26 @@ const filesystem = Layer.effect(
}, },
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => { const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), 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 Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service) return yield* body(yield* ToolRegistry.Service)
}).pipe( }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, patch)))
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") => ({ const call = (patchText: string, id = "call-apply-patch") => ({
+13 -18
View File
@@ -6,8 +6,6 @@ import { Effect, Layer } from "effect"
import { ChildProcess } from "effect/unstable/process" import { ChildProcess } from "effect/unstable/process"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Config } from "@opencode-ai/core/config" 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 { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
@@ -16,7 +14,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { BashTool } from "@opencode-ai/core/tool/bash" import { BashTool } from "@opencode-ai/core/tool/bash"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -102,26 +99,24 @@ const withTool = <A, E, R>(
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
processLayer: Layer.Layer<AppProcess.Service> = appProcess, processLayer: Layer.Layer<AppProcess.Service> = appProcess,
) => { ) => {
const filesystem = FSUtil.defaultLayer
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), 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 Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service) return yield* body(yield* ToolRegistry.Service)
}).pipe( }).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
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") => ({ const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
@@ -236,7 +231,7 @@ describe("BashTool", () => {
return withTool( return withTool(
tmp.path, tmp.path,
(registry) => settleTool(registry, call({ command: "printf core-bash" })), (registry) => settleTool(registry, call({ command: "printf core-bash" })),
LayerNode.compile(AppProcess.node), AppProcess.defaultLayer,
).pipe( ).pipe(
Effect.andThen((settled) => Effect.andThen((settled) =>
Effect.sync(() => { Effect.sync(() => {
+12 -23
View File
@@ -3,8 +3,6 @@ import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect" 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 { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -13,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -74,34 +71,26 @@ const filesystem = Layer.effect(
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))), Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeFileString(target, content, options))),
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => { const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), 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 Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service) return yield* body(yield* ToolRegistry.Service)
}).pipe( }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
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") => ({ const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
+5 -7
View File
@@ -31,6 +31,9 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
])
const question = Layer.succeed( const question = Layer.succeed(
QuestionV2.Service, QuestionV2.Service,
QuestionV2.Service.of({ QuestionV2.Service.of({
@@ -43,13 +46,8 @@ const question = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const it = testEffect( const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [ const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
[PermissionV2.node, permission],
[QuestionV2.node, question],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
describe("QuestionTool", () => { describe("QuestionTool", () => {
it.effect("omits a denied built-in question and terminally settles a stale call", () => it.effect("omits a denied built-in question and terminally settles a stale call", () =>
+29 -22
View File
@@ -3,8 +3,6 @@ import path from "path"
import { Effect, Exit, Layer, PlatformError } from "effect" import { Effect, Exit, Layer, PlatformError } from "effect"
import { Config } from "@opencode-ai/core/config" import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments" 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 { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
@@ -16,7 +14,6 @@ import { Global } from "@opencode-ai/core/global"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { ReadTool } from "@opencode-ai/core/tool/read"
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem" import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -72,8 +69,9 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), 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 config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) const image = Image.layer.pipe(Layer.provide(config))
const testFileSystem = Layer.effect( const testFileSystem = Layer.effect(
FSUtil.Service, FSUtil.Service,
FSUtil.Service.use((fs) => FSUtil.Service.use((fs) =>
@@ -94,10 +92,11 @@ const testFileSystem = Layer.effect(
}), }),
), ),
), ),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const locationLayer = Layer.succeed( const infrastructure = Layer.mergeAll(
Location.Service, testFileSystem,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
Global.layerWith({ data: Global.Path.data }),
) )
const mutation = Layer.succeed( const mutation = Layer.succeed(
LocationMutation.Service, LocationMutation.Service,
@@ -129,20 +128,28 @@ const unavailableImage = Layer.succeed(
Image.Service, Image.Service,
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
) )
const readLayer = (imageLayer: Layer.Layer<Image.Service>) => const read = ReadTool.layer.pipe(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [ Layer.provide(registry),
[ReadToolFileSystem.node, reader], Layer.provide(reader),
[PermissionV2.node, permission], Layer.provide(permission),
[Config.node, config], Layer.provide(config),
[Image.node, imageLayer], Layer.provide(image),
[LocationMutation.node, mutation], Layer.provide(mutation),
[FSUtil.node, testFileSystem], Layer.provide(infrastructure),
[Location.node, locationLayer], )
[Global.node, Global.layerWith({ data: Global.Path.data })], const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], const unavailableRead = ReadTool.layer.pipe(
]) Layer.provide(registry),
const it = testEffect(readLayer(imageLayer)) Layer.provide(reader),
const itWithoutResizer = testEffect(readLayer(unavailableImage)) 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 sessionID = SessionV2.ID.make("ses_read_tool_test") const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => { describe("ReadTool", () => {
+11 -8
View File
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
@@ -65,14 +66,16 @@ describe("SkillTool", () => {
list: () => Effect.succeed(current), list: () => Effect.succeed(current),
}), }),
) )
const skillToolLayer = AppNodeBuilder.build( const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]), [ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
[ ])
[PermissionV2.node, permission], const tool = SkillTool.layer.pipe(
[SkillV2.node, skills], Layer.provide(registry),
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], Layer.provide(permission),
], Layer.provide(LayerNode.compile(FSUtil.node)),
Layer.provide(skills),
) )
const layer = Layer.mergeAll(permission, skills, registry, tool)
return yield* Effect.gen(function* () { return yield* Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
@@ -141,7 +144,7 @@ describe("SkillTool", () => {
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } }, call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
}), }),
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) }) ).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
}).pipe(Effect.provide(skillToolLayer)) }).pipe(Effect.provide(layer))
}), }),
), ),
), ),
+7 -17
View File
@@ -1,8 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { Database } from "@opencode-ai/core/database/database" 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 { EventV2 } from "@opencode-ai/core/event"
import { PermissionV2 } from "@opencode-ai/core/permission" import { PermissionV2 } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
@@ -13,7 +11,6 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionTodo } from "@opencode-ai/core/session/todo" import { SessionTodo } from "@opencode-ai/core/session/todo"
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite" import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -35,21 +32,14 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), 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( const it = testEffect(
AppNodeBuilder.build( Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool),
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* () { const setup = Effect.gen(function* () {
+10 -13
View File
@@ -1,15 +1,11 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Duration, Effect, Fiber, Layer, Schema } from "effect" import { Duration, Effect, Fiber, Layer, Schema } from "effect"
import * as TestClock from "effect/testing/TestClock" import * as TestClock from "effect/testing/TestClock"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, 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 { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch" import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -39,14 +35,15 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const toolLayer = (replacements: LayerNode.Replacements = []) => const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [ const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
[PermissionV2.node, permission], const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig], const fetchWebfetch = WebFetchTool.layer.pipe(
...replacements, Layer.provide(registry),
]) Layer.provide(permission),
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]])) Layer.provide(FetchHttpClient.layer),
const live = testEffect(toolLayer()) )
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
const reset = () => { const reset = () => {
requests.length = 0 requests.length = 0
+7 -14
View File
@@ -1,14 +1,10 @@
import { beforeEach, describe, expect, test } from "bun:test" import { beforeEach, describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" 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 { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { WebSearchTool } from "@opencode-ai/core/tool/websearch" import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool" import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
@@ -103,6 +99,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"), list: () => Effect.die("unused"),
}), }),
) )
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const websearchConfig = Layer.succeed( const websearchConfig = Layer.succeed(
WebSearchTool.ConfigService, WebSearchTool.ConfigService,
WebSearchTool.ConfigService.of({ WebSearchTool.ConfigService.of({
@@ -123,17 +120,13 @@ const websearchConfig = Layer.succeed(
}, },
}), }),
) )
const it = testEffect( const websearch = WebSearchTool.layer.pipe(
AppNodeBuilder.build( Layer.provide(registry),
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]), Layer.provide(permission),
[ Layer.provide(http),
[PermissionV2.node, permission], Layer.provide(websearchConfig),
[LayerNodePlatform.httpClient, http],
[WebSearchTool.configNode, websearchConfig],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
],
),
) )
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
describe("WebSearchTool registration", () => { describe("WebSearchTool registration", () => {
it.effect("registers websearch, asserts query permission, and calls Exa", () => it.effect("registers websearch, asserts query permission, and calls Exa", () =>
+11 -23
View File
@@ -4,8 +4,6 @@ import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Layer } from "effect" import { Effect, Layer } from "effect"
import { FileMutation } from "@opencode-ai/core/file-mutation" 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 { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation" import { LocationMutation } from "@opencode-ai/core/location-mutation"
@@ -13,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session" import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" 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 { WriteTool } from "@opencode-ai/core/tool/write"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
@@ -58,34 +55,25 @@ const filesystem = Layer.effect(
Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))), Effect.sync(() => writes.push(target)).pipe(Effect.andThen(fs.writeWithDirs(target, content, mode))),
}) })
}), }),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))) ).pipe(Layer.provide(FSUtil.defaultLayer))
const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => { const withTool = <A, E, R>(directory: string, body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>) => {
const activeLocation = Layer.succeed( const activeLocation = Layer.succeed(
Location.Service, Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), 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 Effect.gen(function* () {
return yield* body(yield* ToolRegistry.Service) return yield* body(yield* ToolRegistry.Service)
}).pipe( }).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
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") => ({ const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
@@ -9,7 +9,6 @@ import {
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReason, type FinishReason,
type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderMetadata, type ProviderMetadata,
@@ -22,7 +21,6 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
import { isContextOverflow } from "../provider-error" import { isContextOverflow } from "../provider-error"
import * as Cache from "./utils/cache" import * as Cache from "./utils/cache"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "anthropic-messages" const ADAPTER = "anthropic-messages"
@@ -258,10 +256,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
return typeof anthropic.signature === "string" ? anthropic.signature : undefined return typeof anthropic.signature === "string" ? anthropic.signature : undefined
} }
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
input_schema: inputSchema, input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache), cache_control: cacheControl(breakpoints, tool.cache),
}) })
@@ -506,8 +504,6 @@ const lowerThinking = Effect.fn("AnthropicMessages.lowerThinking")(function* (re
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const generation = request.generation 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 → // Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers // 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. // over-mark we keep their tool hints and shed the message-tail ones first.
@@ -515,13 +511,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const tools = const tools =
request.tools.length === 0 || request.toolChoice?.type === "none" request.tools.length === 0 || request.toolChoice?.type === "none"
? undefined ? undefined
: request.tools.map((tool) => : request.tools.map((tool) => lowerTool(breakpoints, tool))
lowerTool(
breakpoints,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
),
)
const system = const system =
request.system.length === 0 request.system.length === 0
? undefined ? undefined
@@ -543,7 +533,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools, tools,
tool_choice: toolChoice, tool_choice: toolChoice,
stream: true as const, stream: true as const,
max_tokens: generation?.maxTokens ?? outputLimit, max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
top_k: generation?.topK, top_k: generation?.topK,
+5 -12
View File
@@ -7,9 +7,7 @@ import {
Usage, Usage,
type CacheHint, type CacheHint,
type FinishReason, type FinishReason,
type JsonSchema,
type LLMRequest, type LLMRequest,
type ModelToolSchemaCompatibility,
type ProviderMetadata, type ProviderMetadata,
type ReasoningPart, type ReasoningPart,
type ToolCallPart, type ToolCallPart,
@@ -23,7 +21,6 @@ import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache" import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media" import { BedrockMedia } from "./utils/bedrock-media"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "bedrock-converse" const ADAPTER = "bedrock-converse"
@@ -208,22 +205,18 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({ const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
toolSpec: { toolSpec: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
inputSchema: { json: inputSchema }, inputSchema: { json: tool.inputSchema },
}, },
}) })
const lowerTools = ( const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
compatibility: ModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
const result: BedrockTool[] = [] const result: BedrockTool[] = []
for (const tool of tools) { for (const tool of tools) {
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))) result.push(lowerToolSpec(tool))
const cachePoint = BedrockCache.block(breakpoints, tool.cache) const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint) if (cachePoint) result.push(cachePoint)
} }
@@ -393,7 +386,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
const breakpoints = BedrockCache.breakpoints() const breakpoints = BedrockCache.breakpoints()
const toolConfig = const toolConfig =
request.tools.length > 0 && request.toolChoice?.type !== "none" request.tools.length > 0 && request.toolChoice?.type !== "none"
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice } ? { tools: lowerTools(breakpoints, request.tools), toolChoice }
: undefined : undefined
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system) const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
const messages = yield* lowerMessages(request, breakpoints) const messages = yield* lowerMessages(request, breakpoints)
+3 -14
View File
@@ -8,7 +8,6 @@ import {
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ProviderMetadata, type ProviderMetadata,
@@ -20,7 +19,6 @@ import {
import { JsonObject, optionalArray, ProviderShared } from "./shared" import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { GeminiToolSchema } from "./utils/gemini-tool-schema" import { GeminiToolSchema } from "./utils/gemini-tool-schema"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
const ADAPTER = "gemini" const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES) const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
@@ -168,10 +166,10 @@ interface ParserState {
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({ const lowerTool = (tool: ToolDefinition) => ({
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: GeminiToolSchema.convert(inputSchema), parameters: GeminiToolSchema.convert(tool.inputSchema),
}) })
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) => const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -302,7 +300,6 @@ const thinkingConfig = (request: LLMRequest) => {
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const generationConfig = { const generationConfig = {
maxOutputTokens: generation?.maxTokens, maxOutputTokens: generation?.maxTokens,
temperature: generation?.temperature, temperature: generation?.temperature,
@@ -316,15 +313,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
contents: yield* lowerMessages(request), contents: yield* lowerMessages(request),
systemInstruction: systemInstruction:
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
tools: toolsEnabled tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined,
? [
{
functionDeclarations: request.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
},
]
: undefined,
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
generationConfig: Object.values(generationConfig).some((value) => value !== undefined) generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
? generationConfig ? generationConfig
+3 -11
View File
@@ -8,7 +8,6 @@ import {
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type JsonSchema,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
type ReasoningPart, type ReasoningPart,
@@ -20,7 +19,6 @@ import {
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
import { OpenAIOptions } from "./utils/openai-options" import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-chat" const ADAPTER = "openai-chat"
@@ -176,12 +174,12 @@ const invalid = ProviderShared.invalidRequest
// Lowering is the only place that knows how common LLM messages map onto the // 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 // OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`. // fields into `LLMRequest`.
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({ const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
type: "function", type: "function",
function: { function: {
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema), parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
}, },
}) })
@@ -345,16 +343,10 @@ const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMR
// `fromRequest` returns the provider body only. Endpoint, auth, framing, // `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`. // validation, and HTTP execution are composed by `Route.make`.
const generation = request.generation const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return { return {
model: request.model.id, model: request.model.id,
messages: yield* lowerMessages(request), messages: yield* lowerMessages(request),
tools: tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
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, tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const, stream: true as const,
stream_options: { include_usage: true }, stream_options: { include_usage: true },
+3 -11
View File
@@ -8,7 +8,6 @@ import {
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
type JsonSchema,
type LLMRequest, type LLMRequest,
type ProviderMetadata, type ProviderMetadata,
type ReasoningPart, type ReasoningPart,
@@ -22,7 +21,6 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
import { isContextOverflow } from "../provider-error" import { isContextOverflow } from "../provider-error"
import { OpenAIOptions } from "./utils/openai-options" import { OpenAIOptions } from "./utils/openai-options"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
import { ToolSchemaProjection } from "./utils/tool-schema"
import { ToolStream } from "./utils/tool-stream" import { ToolStream } from "./utils/tool-stream"
const ADAPTER = "openai-responses" const ADAPTER = "openai-responses"
@@ -256,11 +254,11 @@ const invalid = ProviderShared.invalidRequest
// ============================================================================= // =============================================================================
// Request Lowering // Request Lowering
// ============================================================================= // =============================================================================
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({ const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
type: "function", type: "function",
name: tool.name, name: tool.name,
description: tool.description, description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema), parameters: ProviderShared.openAiToolInputSchema(tool.inputSchema),
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas. // TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
strict: false, strict: false,
}) })
@@ -478,16 +476,10 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) { const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const generation = request.generation const generation = request.generation
const options = yield* lowerOptions(request) const options = yield* lowerOptions(request)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return { return {
model: request.model.id, model: request.model.id,
input: yield* lowerMessages(request), input: yield* lowerMessages(request),
tools: tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
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, tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const, stream: true as const,
max_output_tokens: generation?.maxTokens, max_output_tokens: generation?.maxTokens,
+34 -1
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer" import { Buffer } from "node:buffer"
import { Effect, Schema, Stream } from "effect" import { Effect, JsonSchema, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse" import * as Sse from "effect/unstable/encoding/Sse"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
@@ -24,6 +24,39 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema)) export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema)) export const optionalNull = <const S extends Schema.Top>(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 * Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here * multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -1,4 +1,4 @@
import { isRecord } from "../../utils/record" import { ProviderShared } from "../shared"
// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a // 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 // handful of common JSON Schema shapes. Keep this projection isolated so the
@@ -20,6 +20,8 @@ const SCHEMA_INTENT_KEYS = [
"else", "else",
] ]
const isRecord = ProviderShared.isRecord
const hasCombiner = (schema: unknown) => const hasCombiner = (schema: unknown) =>
isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf)) isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf))
@@ -1,86 +0,0 @@
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<unknown>) => {
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
+2 -1
View File
@@ -1,6 +1,7 @@
import type { RouteDefaultsInput } from "./route/client"
import type { Model, ModelID, ProviderID } from "./schema" import type { Model, ModelID, ProviderID } from "./schema"
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility"> export type ModelOptions = RouteDefaultsInput
/** /**
* Advanced structural provider definition helper. Built-in providers should * Advanced structural provider definition helper. Built-in providers should
+17 -19
View File
@@ -164,20 +164,13 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const resolveRequestOptions = (request: LLMRequest) => { const resolveRequestOptions = (request: LLMRequest) =>
const routeDefaults = request.model.route.defaults LLMRequest.update(request, {
const modelDefaults = request.model.defaults generation:
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation) mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
return LLMRequest.update(request, { providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
generation: generation ?? new GenerationOptions({}), http: mergeHttpOptions(request.model.route.defaults.http, request.http),
providerOptions: mergeProviderOptions(
routeDefaults.providerOptions,
modelDefaults?.providerOptions,
request.providerOptions,
),
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
}) })
}
export interface MakeInput<Body, Frame, Event, State> { export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in diagnostics and prepared request metadata. */ /** Route id used in diagnostics and prepared request metadata. */
@@ -381,12 +374,17 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
const generateWith = (stream: Interface["stream"]) => const generateWith = (stream: Interface["stream"]) =>
Effect.fn("LLM.generate")(function* (request: LLMRequest) { Effect.fn("LLM.generate")(function* (request: LLMRequest) {
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) return new LLMResponse(
const response = LLMResponse.complete(state) yield* stream(request).pipe(
if (response) return response Stream.runFold(
return yield* ProviderShared.eventError( () => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }),
`${request.model.provider}/${request.model.route.id}`, (acc, event) => {
"Provider stream ended without a terminal finish event", acc.events.push(event)
if ("usage" in event && event.usage !== undefined) acc.usage = event.usage
return acc
},
),
),
) )
}) })
-47
View File
@@ -28,56 +28,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString() 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<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () { Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } 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)) { if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {} const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) } return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
+1 -247
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
import { ModelSchema } from "./options" import { ModelSchema } from "./options"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages" import { ToolOutput, ToolResultValue } from "./messages"
import { ProviderFailureClassification } from "./errors" import { ProviderFailureClassification } from "./errors"
/** /**
@@ -335,234 +335,9 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
undefined, 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<LLMEvent>
readonly message: Message
readonly usage?: Usage
readonly finishReason?: FinishReason
readonly textParts: Readonly<Record<string, ContentAssembly>>
readonly reasoningParts: Readonly<Record<string, ContentAssembly>>
readonly toolInputs: Readonly<Record<string, ToolInputAssembly>>
}
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<ContentPart>): 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<LLMResponse>("LLM.Response")({ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
message: Message,
events: Schema.Array(LLMEvent), events: Schema.Array(LLMEvent),
usage: Schema.optional(Usage), usage: Schema.optional(Usage),
finishReason: FinishReason,
}) { }) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */ /** Concatenated assistant text assembled from streamed `text-delta` events. */
get text() { get text() {
@@ -581,29 +356,8 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
} }
export namespace LLMResponse { export namespace LLMResponse {
export type State = ResponseState
export type Output = LLMResponse | { readonly events: ReadonlyArray<LLMEvent>; readonly usage?: Usage } export type Output = LLMResponse | { readonly events: ReadonlyArray<LLMEvent>; 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<LLMEvent>) => complete(events.reduce(reduce, empty()))
/** Concatenate assistant text from a response or collected event list. */ /** Concatenate assistant text from a response or collected event list. */
export const text = (response: Output) => responseText(response.events) export const text = (response: Output) => responseText(response.events)
+1 -56
View File
@@ -134,62 +134,15 @@ export namespace ModelLimits {
input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) input instanceof ModelLimits ? input : new ModelLimits(input ?? {})
} }
export class ModelDefaults extends Schema.Class<ModelDefaults>("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<typeof ModelToolSchemaCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
}) {}
export namespace ModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
}
export class Model { export class Model {
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
constructor(input: Model.ConstructorInput) { constructor(input: Model.ConstructorInput) {
this.id = input.id this.id = input.id
this.provider = input.provider this.provider = input.provider
this.route = input.route this.route = input.route
this.defaults = input.defaults
this.compatibility = input.compatibility
} }
static make(input: Model.Input) { static make(input: Model.Input) {
@@ -197,8 +150,6 @@ export class Model {
id: ModelID.make(input.id), id: ModelID.make(input.id),
provider: ProviderID.make(input.provider), provider: ProviderID.make(input.provider),
route: input.route, route: input.route,
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
}) })
} }
@@ -207,8 +158,6 @@ export class Model {
id: model.id, id: model.id,
provider: model.provider, provider: model.provider,
route: model.route, route: model.route,
defaults: model.defaults,
compatibility: model.compatibility,
} }
} }
@@ -226,15 +175,11 @@ export namespace Model {
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults
readonly compatibility?: ModelCompatibility
} }
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & { export type Input = Omit<ConstructorInput, "id" | "provider"> & {
readonly id: string | ModelID readonly id: string | ModelID
readonly provider: string | ProviderID readonly provider: string | ProviderID
readonly defaults?: ModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input
} }
} }
+2 -9
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse } from "../src" import { LLM } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { Model } from "../src/schema" import { Model } from "../src/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@@ -112,16 +112,9 @@ describe("llm route", () => {
const llm = yield* LLMClient.Service const llm = yield* LLMClient.Service
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect)) const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
const response = yield* llm.generate(request) const response = yield* llm.generate(request)
const reduced = LLMResponse.fromEvents(events)
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"]) expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
expect(reduced).toBeDefined() expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"])
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"}' }])
}), }),
) )
@@ -21,7 +21,7 @@
"headers": { "headers": {
"content-type": "text/event-stream" "content-type": "text/event-stream"
}, },
"body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" "body": ""
} }
}, },
{ {
@@ -39,7 +39,7 @@
"headers": { "headers": {
"content-type": "text/event-stream" "content-type": "text/event-stream"
}, },
"body": "data: {\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hi.\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"cachedContentTokenCount\":1100,\"promptTokenCount\":1200,\"candidatesTokenCount\":2,\"totalTokenCount\":1202}}\n\n" "body": ""
} }
} }
] ]
+2 -34
View File
@@ -90,47 +90,15 @@ describe("llm constructors", () => {
provider: "fake", provider: "fake",
route: chatRoute, route: chatRoute,
}) })
const updated = Model.update(base, { const updated = Model.update(base, { route: responsesRoute })
route: responsesRoute,
defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" },
})
const updatedInput = Model.input(updated)
expect(updated).toBeInstanceOf(Model) expect(updated).toBeInstanceOf(Model)
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(String(Model.input(updated).provider)).toBe("fake")
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) 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", () => { test("builds tool choices from names and tools", () => {
const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }) const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })
-178
View File
@@ -1,178 +0,0 @@
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<OpenAIChat.OpenAIChatBody>(
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)
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")
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("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({
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<AnthropicMessages.AnthropicMessagesBody>(
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
)
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
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)
}),
)
})
@@ -395,10 +395,6 @@ describe("Anthropic Messages route", () => {
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({ expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { anthropic: { signature: "sig_1" } }, 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({ expect(response.events.at(-1)).toMatchObject({
type: "finish", type: "finish",
reason: "stop", reason: "stop",
+8 -11
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, Usage } from "../../src" import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
import * as Azure from "../../src/providers/azure" import * as Azure from "../../src/providers/azure"
import * as OpenAI from "../../src/providers/openai" import * as OpenAI from "../../src/providers/openai"
import * as OpenAIChat from "../../src/protocols/openai-chat" import * as OpenAIChat from "../../src/protocols/openai-chat"
@@ -597,22 +597,19 @@ describe("OpenAI Chat route", () => {
}), }),
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }), deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
) )
const input = LLM.updateRequest(request, { const response = yield* LLMClient.generate(
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], 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))), ).pipe(Effect.provide(fixedResponse(body)))
)
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(events).toEqual([ expect(response.events).toEqual([
{ type: "step-start", index: 0 }, { type: "step-start", index: 0 },
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined }, { 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: '{"query"' },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
]) ])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) expect(response.toolCalls).toEqual([])
expect(error.message).toContain("Provider stream ended without a terminal finish event")
}), }),
) )
@@ -778,11 +778,6 @@ describe("OpenAI Responses route", () => {
{ type: "step-finish", index: 0, reason: "stop" }, { type: "step-finish", index: 0, reason: "stop" },
{ type: "finish", 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" },
])
}), }),
) )
-98
View File
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test"
import { LLMEvent, LLMResponse } from "../src"
const reduce = (events: ReadonlyArray<LLMEvent>) => events.reduce(LLMResponse.reduce, LLMResponse.empty())
const finishEvents = (events: ReadonlyArray<LLMEvent>) => events.filter(LLMEvent.is.finish)
describe("LLMResponse reducer", () => {
test("assembles interleaved reasoning and text with end metadata", () => {
const events = [
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 } }),
]
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",
text: "I should compare...",
providerMetadata: { anthropic: { signature: "sig" } },
},
{ type: "text", text: "Answer" },
])
})
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("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" }),
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" } },
])
})
})
@@ -1,117 +0,0 @@
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<OpenAIChat.OpenAIChatBody>(
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,
})
}),
)
})
+3 -1
View File
@@ -186,7 +186,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ac
export const use = serviceUse(Service) export const use = serviceUse(Service)
const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const repo = yield* AccountRepo.Service const repo = yield* AccountRepo.Service
@@ -456,6 +456,8 @@ const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpCl
}), }),
) )
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
export const node = LayerNode.make({ service: Service, layer: layer, deps: [AccountRepo.node, httpClient] }) export const node = LayerNode.make({ service: Service, layer: layer, deps: [AccountRepo.node, httpClient] })
export * as Account from "./account" export * as Account from "./account"
+3 -1
View File
@@ -39,7 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ac
export const use = serviceUse(Service) export const use = serviceUse(Service)
const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
@@ -166,6 +166,8 @@ 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 const node = LayerNode.make({ service: Service, layer: layer, deps: [Database.node] })
export * as AccountRepo from "./repo" export * as AccountRepo from "./repo"
+8 -10
View File
@@ -1,9 +1,7 @@
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Command } from "@/command" import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref" import { InstanceRef } from "@/effect/instance-ref"
import { InstanceBootstrap } from "@/project/bootstrap"
import { InstanceStore } from "@/project/instance-store" import { InstanceStore } from "@/project/instance-store"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { ProviderV2 } from "@opencode-ai/core/provider" import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model" import { ModelV2 } from "@opencode-ai/core/model"
import { Provider } from "@/provider/provider" import { Provider } from "@/provider/provider"
@@ -141,7 +139,7 @@ export const loaderLayer = Layer.effect(
}), }),
) )
const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const loader = yield* Loader const loader = yield* Loader
@@ -201,12 +199,12 @@ const layer = Layer.effect(
}), }),
) )
export const loaderNode = LayerNode.make({ export const defaultLayer = layer.pipe(
service: Loader, Layer.provide(loaderLayer),
layer: loaderLayer, Layer.provide(Provider.defaultLayer),
deps: [Provider.node, Agent.node, Command.node, InstanceStore.node], Layer.provide(Agent.defaultLayer),
}) Layer.provide(Command.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] }) )
export * as Directory from "./directory" export * as Directory from "./directory"

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