mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c2df36405 | |||
| d3e2382f53 | |||
| 421e33e531 | |||
| c2d619741b | |||
| 0bff5bf389 | |||
| ca359adafa | |||
| b8f1ff424a | |||
| d31d9d2287 | |||
| dc295c9bd3 | |||
| a2fc3057e7 | |||
| 0629920fc5 | |||
| b027484365 | |||
| e8b6ff6be3 | |||
| 9a90e14c90 | |||
| 8e84d9663b | |||
| 4ce8c1dd4d | |||
| 15bc8ab920 | |||
| 0ebbdd71be |
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: [] })
|
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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],
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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,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", () =>
|
||||||
|
|||||||
@@ -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,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", () =>
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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") }),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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", () =>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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", () =>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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", () =>
|
||||||
|
|||||||
@@ -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") }),
|
}),
|
||||||
}),
|
]),
|
||||||
]),
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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") })
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () =>
|
||||||
|
|||||||
@@ -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") => ({
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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") => ({
|
||||||
|
|||||||
@@ -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", () =>
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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))
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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* () {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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", () =>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"}' }])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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" } })
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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" },
|
|
||||||
])
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user