mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -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 twoHours = 2 * 60 * 60 * 1000;
|
||||
const orgMemberAssociations = new Set(['OWNER', 'MEMBER']);
|
||||
|
||||
for (const item of items) {
|
||||
const isPR = !!item.pull_request;
|
||||
const kind = isPR ? 'PR' : 'issue';
|
||||
|
||||
if (orgMemberAssociations.has(item.author_association)) {
|
||||
core.info(`Skipping ${kind} #${item.number}; author association is ${item.author_association}`);
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: item.number,
|
||||
name: 'needs:compliance',
|
||||
});
|
||||
} catch (e) {}
|
||||
continue;
|
||||
}
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
Issue author association: ${{ github.event.issue.author_association }}
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
@@ -50,8 +49,6 @@ jobs:
|
||||
|
||||
Check whether the issue follows our contributing guidelines and issue templates.
|
||||
|
||||
If the issue author association is OWNER or MEMBER, skip this compliance check. Do not add the needs:compliance label for organization-owned issues.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
1. Bug Report - requires a Description field with real content
|
||||
@@ -86,7 +83,7 @@ jobs:
|
||||
|
||||
Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows:
|
||||
|
||||
If the issue is NOT compliant 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 -->
|
||||
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: |
|
||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||
Issue author association: ${{ github.event.issue.author_association }}
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
If the issue author association is OWNER or MEMBER, remove the needs:compliance label if present, delete the previous compliance comment if present, and do not post a new comment.
|
||||
|
||||
Re-check whether the issue now follows our contributing guidelines and issue templates.
|
||||
|
||||
This project has three issue templates that every issue MUST use one of:
|
||||
|
||||
@@ -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()
|
||||
if (!activeId || !slashPopoverRef) return
|
||||
|
||||
@@ -799,7 +800,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
|
||||
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
|
||||
})
|
||||
}
|
||||
})
|
||||
const selectPopoverActive = () => {
|
||||
if (store.popover === "at") {
|
||||
const items = atFlat()
|
||||
@@ -1286,9 +1287,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
if (store.popover === "slash") {
|
||||
slashOnKeyDown(event)
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown" || ctrlNav) {
|
||||
scrollSlashActiveIntoView()
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
return
|
||||
@@ -1408,8 +1406,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
setSlashActive={setSlashActive}
|
||||
onSlashSelect={handleSlashSelect}
|
||||
commandKeybind={command.keybind}
|
||||
commandKeybindParts={command.keybindParts}
|
||||
newLayoutDesigns={props.controls.newLayoutDesigns}
|
||||
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
|
||||
/>
|
||||
<Switch>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Component, For, Match, Show, Switch } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
|
||||
export type AtOption =
|
||||
@@ -32,8 +30,6 @@ type PromptPopoverProps = {
|
||||
setSlashActive: (id: string) => void
|
||||
onSlashSelect: (item: SlashCommand) => void
|
||||
commandKeybind: (id: string) => string | undefined
|
||||
commandKeybindParts: (id: string) => string[]
|
||||
newLayoutDesigns: boolean
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -45,29 +41,15 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
if (props.popover === "slash") props.setSlashPopoverRef(el)
|
||||
}}
|
||||
class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10
|
||||
overflow-auto no-scrollbar flex flex-col p-2"
|
||||
classList={{
|
||||
"z-[70] rounded-[10px] bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": props.newLayoutDesigns,
|
||||
"rounded-[12px] bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]":
|
||||
!props.newLayoutDesigns,
|
||||
}}
|
||||
overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px]
|
||||
bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={props.popover === "at"}>
|
||||
<Show
|
||||
when={props.atFlat.length > 0}
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
|
||||
>
|
||||
<For each={props.atFlat.slice(0, 10)}>
|
||||
{(item) => {
|
||||
@@ -76,29 +58,13 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
if (item.type === "agent") {
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"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,
|
||||
}}
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
>
|
||||
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
|
||||
<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>
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -109,44 +75,16 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
|
||||
return (
|
||||
<button
|
||||
class="w-full flex items-center gap-x-2 px-2 py-0.5"
|
||||
classList={{
|
||||
"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,
|
||||
}}
|
||||
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
|
||||
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
|
||||
onClick={() => props.onAtSelect(item)}
|
||||
onPointerMove={() => props.setAtActive(key)}
|
||||
onMouseEnter={() => props.setAtActive(key)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div
|
||||
class="flex items-center min-w-0"
|
||||
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>
|
||||
<div class="flex items-center text-14-regular min-w-0">
|
||||
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
|
||||
<Show when={!isDirectory}>
|
||||
<span
|
||||
class="whitespace-nowrap"
|
||||
classList={{
|
||||
"text-v2-text-text-base": props.newLayoutDesigns,
|
||||
"text-text-strong": !props.newLayoutDesigns,
|
||||
}}
|
||||
>
|
||||
{filename}
|
||||
</span>
|
||||
<span class="text-text-strong whitespace-nowrap">{filename}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
@@ -158,98 +96,41 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
|
||||
<Match when={props.popover === "slash"}>
|
||||
<Show
|
||||
when={props.slashFlat.length > 0}
|
||||
fallback={
|
||||
<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>
|
||||
}
|
||||
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
|
||||
>
|
||||
<For each={props.slashFlat}>
|
||||
{(cmd) => {
|
||||
const keybind = () => props.commandKeybind(cmd.id)
|
||||
const keybindParts = () => props.commandKeybindParts(cmd.id)
|
||||
return (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 px-2 py-1": true,
|
||||
"rounded-[4px] scroll-my-2": props.newLayoutDesigns,
|
||||
"rounded-md": !props.newLayoutDesigns,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
"bg-surface-raised-base-hover": !props.newLayoutDesigns && props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onPointerMove={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
/{cmd.trigger}
|
||||
{(cmd) => (
|
||||
<button
|
||||
data-slash-id={cmd.id}
|
||||
classList={{
|
||||
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
|
||||
"bg-surface-raised-base-hover": props.slashActive === cmd.id,
|
||||
}}
|
||||
onClick={() => props.onSlashSelect(cmd)}
|
||||
onMouseEnter={() => props.setSlashActive(cmd.id)}
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
|
||||
<Show when={cmd.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
|
||||
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
|
||||
{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>
|
||||
<Show when={cmd.description}>
|
||||
<span
|
||||
class="truncate"
|
||||
classList={{
|
||||
"text-[13px] leading-[calc(var(--font-size-base)*1.8)] tracking-[-0.04px] [font-weight:440]":
|
||||
props.newLayoutDesigns,
|
||||
"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>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<Show when={props.commandKeybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Match>
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Match, Show, Switch, createMemo } from "solid-js"
|
||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||
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 { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
@@ -17,7 +15,6 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
||||
|
||||
interface SessionContextUsageProps {
|
||||
variant?: "button" | "indicator"
|
||||
buttonAppearance?: "default" | "v2"
|
||||
placement?: TooltipProps["placement"]
|
||||
}
|
||||
|
||||
@@ -42,7 +39,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
const buttonAppearance = createMemo(() => props.buttonAppearance ?? "default")
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
@@ -84,11 +80,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
const circleV2 = () => (
|
||||
<div class="flex items-center justify-center">
|
||||
<ProgressCircleV2 percentage={context()?.usage ?? 0} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const tooltipValue = () => (
|
||||
<div>
|
||||
@@ -122,16 +113,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
<Tooltip value={tooltipValue()} placement={props.placement ?? "top"}>
|
||||
<Switch>
|
||||
<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}>
|
||||
<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"] {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
@@ -171,66 +132,4 @@
|
||||
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 ComponentProps,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
@@ -69,9 +68,6 @@ import { archiveHomeSession } from "./home-session-archive"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
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 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"
|
||||
@@ -137,107 +133,6 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
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() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -328,7 +223,6 @@ export function NewHome() {
|
||||
})
|
||||
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups)
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
createEffect(() => {
|
||||
@@ -541,7 +435,7 @@ export function NewHome() {
|
||||
/>
|
||||
|
||||
<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")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
@@ -562,25 +456,7 @@ export function NewHome() {
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<ScrollView
|
||||
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>
|
||||
<ScrollView class="mt-3 -mr-3 min-h-0 flex-1">
|
||||
<Show
|
||||
when={!sessionLoad.isLoading}
|
||||
fallback={
|
||||
@@ -593,19 +469,15 @@ export function NewHome() {
|
||||
when={groups().length > 0}
|
||||
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()}>
|
||||
{(group, index) => (
|
||||
<>
|
||||
<div class="flex min-w-0 flex-col gap-4">
|
||||
<HomeSessionGroupHeader
|
||||
title={group.title}
|
||||
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
|
||||
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
|
||||
elevated={index() === 0}
|
||||
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
|
||||
/>
|
||||
<div
|
||||
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-px">
|
||||
<For each={group.sessions}>
|
||||
{(record) => (
|
||||
<HomeSessionRow
|
||||
@@ -619,7 +491,7 @@ export function NewHome() {
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
@@ -668,10 +540,10 @@ function HomeProjectColumn(props: {
|
||||
|
||||
return (
|
||||
<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")}
|
||||
>
|
||||
<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>
|
||||
<Show when={global.servers.list().length === 1}>
|
||||
<TooltipV2 placement="bottom" value={props.language.t("home.project.add")}>
|
||||
@@ -688,51 +560,42 @@ function HomeProjectColumn(props: {
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
<ScrollView data-slot="home-projects-scroll" class="min-h-0 min-w-0 shrink">
|
||||
<Show
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={
|
||||
<div class="pr-3">
|
||||
<HomeProjectList {...props} server={global.servers.list()[0]!} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex min-w-0 flex-col gap-1 pr-3">
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const projects = () => serverCtx.projects.list()
|
||||
const hasProjects = () => projects().length > 0
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
return (
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && hasProjects() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={projects()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
<Show
|
||||
when={global.servers.list().length > 1}
|
||||
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
|
||||
>
|
||||
<For each={global.servers.list()}>
|
||||
{(item) => {
|
||||
const key = ServerConnection.key(item)
|
||||
const healthy = () => !!global.servers.health[key]?.healthy
|
||||
const serverCtx = global.ensureServerCtx(item)
|
||||
const collapsed = () => !!state().collapsed[key]
|
||||
return (
|
||||
<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">
|
||||
<HomeServerRow
|
||||
server={item}
|
||||
selected={props.selected.server === key && !props.selected.directory}
|
||||
healthy={healthy()}
|
||||
collapsed={collapsed()}
|
||||
health={global.servers.health[key]}
|
||||
controller={controller}
|
||||
focusServer={props.focusServer}
|
||||
chooseProject={props.chooseProject}
|
||||
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
|
||||
toggleCollapsed={() => setState("collapsed", key, !state().collapsed[key])}
|
||||
language={props.language}
|
||||
/>
|
||||
<Show when={healthy() && !collapsed()}>
|
||||
<div class="mx-3 h-px bg-v2-border-border-base" />
|
||||
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
<HomeUtilityNav
|
||||
class="mb-8 mt-4 hidden shrink-0 lg:flex"
|
||||
class="mt-4 hidden lg:flex"
|
||||
openSettings={props.openSettings}
|
||||
openHelp={props.openHelp}
|
||||
language={props.language}
|
||||
@@ -772,6 +635,7 @@ function HomeUtilityNav(props: {
|
||||
function HomeServerRow(props: {
|
||||
server: ServerConnection.Any
|
||||
selected: boolean
|
||||
healthy: boolean
|
||||
collapsed: boolean
|
||||
health: ServerHealth | undefined
|
||||
controller: ReturnType<typeof useServerManagementController>
|
||||
@@ -781,46 +645,39 @@ function HomeServerRow(props: {
|
||||
toggleCollapsed: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ menuOpen: false })
|
||||
const healthy = () => !!props.health?.healthy
|
||||
const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0
|
||||
return (
|
||||
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
|
||||
data-selected={props.selected ? "" : undefined}
|
||||
disabled={!healthy()}
|
||||
disabled={!props.healthy}
|
||||
onClick={() => props.focusServer(props.server)}
|
||||
>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
class="inline-flex -ml-0.5 -mr-1.5 size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted"
|
||||
classList={{
|
||||
"hover:bg-v2-overlay-simple-overlay-hover": canToggle(),
|
||||
"cursor-default opacity-40": !canToggle(),
|
||||
}}
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-disabled={!canToggle()}
|
||||
aria-expanded={canToggle() ? !props.collapsed : undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!canToggle()) return
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
<Show when={props.healthy}>
|
||||
<span
|
||||
data-action="home-server-collapse"
|
||||
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"
|
||||
aria-label={
|
||||
props.collapsed ? props.language.t("home.server.expand") : props.language.t("home.server.collapse")
|
||||
}
|
||||
aria-expanded={!props.collapsed}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.toggleCollapsed()
|
||||
}}
|
||||
onPointerDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<IconV2
|
||||
name="chevron-down"
|
||||
size="small"
|
||||
class="transition-transform duration-150 ease-in-out"
|
||||
style={{ transform: `rotate(${props.collapsed ? -90 : 0}deg)` }}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
<div class="flex size-4 shrink-0 items-center justify-center -mr-0.5">
|
||||
<ServerHealthIndicator health={props.health} />
|
||||
</div>
|
||||
@@ -997,7 +854,6 @@ function HomeSessionLeading(props: {
|
||||
session: Session
|
||||
server: ServerConnection.Key
|
||||
activeServer: boolean
|
||||
revealProjectOnHover: boolean
|
||||
}) {
|
||||
const tabs = useTabs()
|
||||
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
|
||||
@@ -1015,7 +871,6 @@ function HomeSessionLeading(props: {
|
||||
directory={props.session.directory}
|
||||
sessionId={props.session.id}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={props.revealProjectOnHover}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -1106,7 +961,7 @@ function HomeSessionSearch(props: {
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<div
|
||||
data-component="home-session-search-panel"
|
||||
@@ -1255,7 +1110,6 @@ function HomeSessionSearchResultRow(props: {
|
||||
classList={{
|
||||
[HOME_SEARCH_RESULT_ROW]: true,
|
||||
"bg-v2-overlay-simple-overlay-hover": props.selected,
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onClick={() => props.onSelect(props.record.session)}
|
||||
@@ -1265,7 +1119,6 @@ function HomeSessionSearchResultRow(props: {
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<span
|
||||
@@ -1281,20 +1134,25 @@ function HomeSessionSearchResultRow(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function HomeSessionGroupHeader(props: {
|
||||
title: string
|
||||
titleOpacity: number
|
||||
ref: ComponentProps<"div">["ref"]
|
||||
elevated?: boolean
|
||||
}) {
|
||||
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div
|
||||
ref={props.ref}
|
||||
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"}`}
|
||||
>
|
||||
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
|
||||
{props.title}
|
||||
</div>
|
||||
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
|
||||
<div class={HOME_SECTION_LABEL}>{props.title}</div>
|
||||
<Show when={props.onNewSession}>
|
||||
{(onNewSession) => (
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1312,10 +1170,7 @@ function HomeSessionRow(props: {
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
|
||||
return (
|
||||
<div
|
||||
class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]"
|
||||
classList={{ group: !!showProjectName() }}
|
||||
>
|
||||
<div class="group/session relative flex h-10 min-w-0 items-center rounded-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
@@ -1327,7 +1182,6 @@ function HomeSessionRow(props: {
|
||||
session={props.record.session}
|
||||
server={props.server}
|
||||
activeServer={props.activeServer}
|
||||
revealProjectOnHover={!!showProjectName()}
|
||||
/>
|
||||
<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]"}`}
|
||||
|
||||
@@ -11,28 +11,32 @@ export function SessionTabAvatar(props: {
|
||||
directory: string
|
||||
sessionId: string
|
||||
activeServer: boolean
|
||||
revealProjectOnHover?: boolean
|
||||
}) {
|
||||
const directory = () => props.directory
|
||||
const sessionId = () => props.sessionId
|
||||
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 (
|
||||
<Show when={state.loading()} fallback={projectAvatar()}>
|
||||
<span class="relative block size-4 shrink-0">
|
||||
<SessionProgressIndicatorV2
|
||||
class={`absolute inset-0 ${props.revealProjectOnHover === false ? "" : "group-hover:invisible"}`}
|
||||
<Show
|
||||
when={state.loading()}
|
||||
fallback={
|
||||
<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>
|
||||
</Show>
|
||||
)
|
||||
|
||||
@@ -125,7 +125,7 @@ export function SessionComposerRegion(props: {
|
||||
</Show>
|
||||
<div
|
||||
classList={{
|
||||
"relative z-[70]": true,
|
||||
"relative z-30": true,
|
||||
}}
|
||||
style={{
|
||||
"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 { Icon } from "@opencode-ai/ui/icon"
|
||||
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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
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 { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
@@ -676,34 +671,6 @@ export function MessageTimeline(props: {
|
||||
if (!shareEnabled()) return
|
||||
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(
|
||||
on(
|
||||
@@ -889,26 +856,6 @@ export function MessageTimeline(props: {
|
||||
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 (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
@@ -1013,7 +960,6 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
|
||||
turnDurationMs={turnDurationMs(row().userMessageID)}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
defaultOpen={defaultOpen()}
|
||||
toolOpen={toolOpen[part().id] ?? defaultOpen()}
|
||||
onToolOpenChange={(open) => setToolOpen(part().id, open)}
|
||||
@@ -1121,7 +1067,6 @@ export function MessageTimeline(props: {
|
||||
message={message()}
|
||||
parts={getMsgParts(userMessageRow().userMessageID)}
|
||||
actions={props.actions}
|
||||
useV2Actions={settings.general.newLayoutDesigns()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1359,16 +1304,11 @@ export function MessageTimeline(props: {
|
||||
"pr-3": true,
|
||||
"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
|
||||
classList={{
|
||||
"flex items-center gap-1 min-w-0 flex-1": true,
|
||||
"pr-3": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
@@ -1407,17 +1347,8 @@ export function MessageTimeline(props: {
|
||||
data-slot="session-title-child"
|
||||
value={title.draft}
|
||||
disabled={titleMutation.isPending}
|
||||
classList={{
|
||||
"text-14-medium text-text-strong grow-1 min-w-0 pl-1 -ml-1": true,
|
||||
"h-6 leading-4 rounded-[3px] focus:shadow-none focus:outline focus:outline-1 focus:outline-offset-[-1px] focus:outline-v2-border-border-focus":
|
||||
settings.general.newLayoutDesigns(),
|
||||
"rounded-[6px]": !settings.general.newLayoutDesigns(),
|
||||
}}
|
||||
style={{
|
||||
"--inline-input-shadow": settings.general.newLayoutDesigns()
|
||||
? "none"
|
||||
: "var(--shadow-xs-border-select)",
|
||||
}}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
@@ -1439,170 +1370,88 @@ export function MessageTimeline(props: {
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
{(id) => (
|
||||
<div
|
||||
classList={{
|
||||
"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"}
|
||||
/>
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<Show when={!parentID()}>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => {
|
||||
setTitle("menuOpen", open)
|
||||
if (open) return
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
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
|
||||
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.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)
|
||||
})
|
||||
}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<MenuV2.Item
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
{language.t("common.rename")}
|
||||
</MenuV2.Item>
|
||||
<Show when={shareEnabled()}>
|
||||
<MenuV2.Item
|
||||
onSelect={() => {
|
||||
setTitle({ pendingShare: true, menuOpen: false })
|
||||
}}
|
||||
>
|
||||
{language.t("session.share.action.share")}...
|
||||
</MenuV2.Item>
|
||||
</Show>
|
||||
<MenuV2.Item onSelect={() => void archiveSession(id)}>
|
||||
{language.t("common.archive")}
|
||||
</MenuV2.Item>
|
||||
<MenuV2.Separator />
|
||||
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
|
||||
{language.t("common.delete")}...
|
||||
</MenuV2.Item>
|
||||
</MenuV2.Content>
|
||||
</MenuV2.Portal>
|
||||
</MenuV2>
|
||||
</Show>
|
||||
<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>
|
||||
|
||||
<KobaltePopover
|
||||
open={share.open}
|
||||
anchorRef={() => more}
|
||||
placement="bottom-end"
|
||||
gutter={settings.general.newLayoutDesigns() ? 6 : 4}
|
||||
gutter={4}
|
||||
modal={false}
|
||||
onOpenChange={(open) => {
|
||||
if (open) setShare("dismiss", null)
|
||||
@@ -1612,10 +1461,6 @@ export function MessageTimeline(props: {
|
||||
<KobaltePopover.Portal>
|
||||
<KobaltePopover.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" }}
|
||||
onEscapeKeyDown={(event) => {
|
||||
setShare({ dismiss: "escape", open: false })
|
||||
@@ -1633,90 +1478,24 @@ export function MessageTimeline(props: {
|
||||
setShare("dismiss", null)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={settings.general.newLayoutDesigns()}
|
||||
fallback={
|
||||
<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]">
|
||||
<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="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()
|
||||
? language.t("session.share.popover.description.shared")
|
||||
: language.t("session.share.popover.description.unshared")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="mt-3 flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<ButtonV2
|
||||
variant="contrast"
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={shareSession}
|
||||
disabled={shareMutation.isPending}
|
||||
@@ -1724,57 +1503,48 @@ export function MessageTimeline(props: {
|
||||
{shareMutation.isPending
|
||||
? language.t("session.share.action.publishing")
|
||||
: language.t("session.share.action.publish")}
|
||||
</ButtonV2>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
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)]"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-button-neutral)",
|
||||
}}
|
||||
>
|
||||
<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]"
|
||||
onClick={selectShareUrlText}
|
||||
>
|
||||
{shareUrl()}
|
||||
</div>
|
||||
<IconButtonV2
|
||||
type="button"
|
||||
size="small"
|
||||
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"
|
||||
<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={
|
||||
settings.general.newLayoutDesigns()
|
||||
? "w-full shadow-none border-[0.5px] border-border-weak-base"
|
||||
: "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")}
|
||||
</ButtonV2>
|
||||
</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>
|
||||
</Show>
|
||||
</div>
|
||||
</KobaltePopover.Content>
|
||||
</KobaltePopover.Portal>
|
||||
</KobaltePopover>
|
||||
|
||||
@@ -137,7 +137,7 @@ export async function handler(
|
||||
const providerBudgetTracker = createProviderBudgetTracker(
|
||||
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 providerInfo = selectProvider(
|
||||
@@ -151,7 +151,7 @@ export async function handler(
|
||||
stickyProvider,
|
||||
modelTpmLimits,
|
||||
modelTpsLimits,
|
||||
providerBudget,
|
||||
providerBudgetUsage,
|
||||
)
|
||||
validateModelSettings(billingSource, authInfo)
|
||||
updateProviderKey(authInfo, providerInfo)
|
||||
@@ -216,9 +216,6 @@ export async function handler(
|
||||
return headers
|
||||
})(),
|
||||
body: reqBody,
|
||||
// Propagate caller disconnects to the upstream provider request so
|
||||
// abandoned Console requests do not leave orphaned inference work open.
|
||||
signal: input.request.signal,
|
||||
})
|
||||
|
||||
if (providerInfo.id.startsWith("console.")) {
|
||||
@@ -287,7 +284,7 @@ export async function handler(
|
||||
const costInfo = calculateCost(modelInfo, usageInfo)
|
||||
await trialLimiter?.track(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 reload(billingSource, authInfo, costInfo)
|
||||
json.cost = calculateOccurredCost(billingSource, costInfo)
|
||||
@@ -314,10 +311,9 @@ export async function handler(
|
||||
const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
|
||||
const usageParser = providerInfo.createUsageParser()
|
||||
const binaryDecoder = providerInfo.createBinaryStreamDecoder()
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||
const stream = new ReadableStream({
|
||||
start(c) {
|
||||
reader = res.body?.getReader()
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
@@ -349,11 +345,7 @@ export async function handler(
|
||||
timestampLastByte,
|
||||
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 reload(billingSource, authInfo, costInfo)
|
||||
const cost = calculateOccurredCost(billingSource, costInfo)
|
||||
@@ -403,11 +395,6 @@ export async function handler(
|
||||
|
||||
return pump()
|
||||
},
|
||||
cancel() {
|
||||
// When the downstream caller stops reading, release the upstream
|
||||
// response body instead of keeping the provider/inference stream alive.
|
||||
return reader?.cancel()
|
||||
},
|
||||
})
|
||||
return new Response(stream, {
|
||||
status: resStatus,
|
||||
@@ -415,15 +402,6 @@ export async function handler(
|
||||
headers: resHeaders,
|
||||
})
|
||||
} catch (error: any) {
|
||||
// The caller disconnected before we finished. Because the outbound provider
|
||||
// request shares input.request.signal, an aborted caller surfaces here as an
|
||||
// AbortError. There is no client left to receive a body, so skip the error
|
||||
// metric and 500 and return a quiet client-closed response.
|
||||
if (input.request.signal.aborted || error?.name === "AbortError") {
|
||||
logger.debug("REQUEST ABORTED BY CALLER")
|
||||
return new Response(null, { status: 499 })
|
||||
}
|
||||
|
||||
logger.metric({
|
||||
"error.type": error.constructor.name,
|
||||
"error.message": error.message,
|
||||
@@ -534,12 +512,7 @@ export async function handler(
|
||||
stickyProviderId: string | undefined,
|
||||
modelTpmLimits: Record<string, number> | undefined,
|
||||
modelTpsLimits: Record<string, { qualify: number; unqualify: number }> | undefined,
|
||||
providerBudget:
|
||||
| {
|
||||
qualify: (providerId: string, priority: number) => boolean
|
||||
prefer: (providerId: string, priority: number) => boolean
|
||||
}
|
||||
| undefined,
|
||||
providerBudgetUsage: Record<string, number> | undefined,
|
||||
) {
|
||||
const modelProvider = (() => {
|
||||
// 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) => !retry.excludeProviders.includes(provider.id))
|
||||
.filter((provider) => {
|
||||
if (provider.budgetPriority === undefined) return true
|
||||
if (!providerBudget) return true
|
||||
return providerBudget.qualify(provider.id, provider.budgetPriority)
|
||||
if (provider.budgetMode !== "fill") return true
|
||||
const budget = zenData.providers[provider.id]?.budget
|
||||
if (budget === undefined) return false
|
||||
return (providerBudgetUsage?.[provider.id] ?? 0) < centsToMicroCents(budget * 100)
|
||||
})
|
||||
.filter((provider) => {
|
||||
if (!provider.tpmLimit) return true
|
||||
@@ -602,19 +576,15 @@ export async function handler(
|
||||
const stickProvider = allProviders.find((provider) => provider.id === stickyProviderId)
|
||||
if (!stickProvider) return provider
|
||||
|
||||
const preferBudgetProvider =
|
||||
provider.budgetPriority !== undefined && providerBudget?.prefer(provider.id, provider.budgetPriority)
|
||||
// stick provider exists + selected provider is API type => use sticky provider
|
||||
if (!provider.tpsGoal) return stickProvider
|
||||
|
||||
const preferTpsProvider = (() => {
|
||||
if (!provider.tpsGoal) return false
|
||||
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
|
||||
qualify: 0,
|
||||
unqualify: 0,
|
||||
}
|
||||
return tps.qualify > tps.unqualify * 3
|
||||
})()
|
||||
|
||||
if (!preferBudgetProvider && !preferTpsProvider) return stickProvider
|
||||
// stick provier exists + selected provider is GPU type + GPU not idle => use selected provider
|
||||
const tps = modelTpsLimits?.[`${provider.id}/${provider.model}/${provider.tpsGoal}`] ?? {
|
||||
qualify: 0,
|
||||
unqualify: 0,
|
||||
}
|
||||
if (tps.qualify <= tps.unqualify * 3) return stickProvider
|
||||
|
||||
return provider
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createModelTpsLimiter(providers: { id: string; model: string; tp
|
||||
)
|
||||
const now = 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 {
|
||||
check: async () => {
|
||||
|
||||
@@ -2,148 +2,50 @@ import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
|
||||
import { buildRateLimitKey, getRedis } from "./redis"
|
||||
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(
|
||||
providers: {
|
||||
id: string
|
||||
budget?: number
|
||||
budgetContribution?: number
|
||||
budgetPriority?: number
|
||||
budgetMode?: "always" | "fill"
|
||||
}[],
|
||||
) {
|
||||
const tracked = providers.filter(
|
||||
(provider) =>
|
||||
provider.budget !== undefined &&
|
||||
provider.budgetContribution !== undefined &&
|
||||
provider.budgetPriority !== undefined,
|
||||
(provider) => provider.budget !== undefined && provider.budgetContribution !== undefined,
|
||||
)
|
||||
if (tracked.length === 0) return undefined
|
||||
|
||||
const intervalAt = (date: Date) =>
|
||||
date
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const now = new Date()
|
||||
const currInterval = intervalAt(now)
|
||||
const prevInterval = intervalAt(new Date(now.getTime() - 60_000))
|
||||
|
||||
const interval = new Date()
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12)
|
||||
const redis = getRedis()
|
||||
const key = (providerId: string, priority: number, withInterval: string) =>
|
||||
buildRateLimitKey("provider-budget", `${providerId}:${priority}`, withInterval)
|
||||
|
||||
const budgetByProvider = tracked.reduce<Record<string, number>>((acc, provider) => {
|
||||
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>> = {}
|
||||
const keys = Object.fromEntries(
|
||||
tracked.map((provider) => [provider.id, buildRateLimitKey("provider-budget", provider.id, interval)]),
|
||||
)
|
||||
let budgetUsage: Record<string, number> = {}
|
||||
|
||||
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 () => {
|
||||
const reads = Object.entries(maxPriorityByProvider).flatMap(([providerId, maxPriority]) =>
|
||||
Array.from({ length: maxPriority }, (_, index) => index + 1).flatMap((priority) => [
|
||||
{ providerId, priority, interval: currInterval, prev: false },
|
||||
{ providerId, priority, interval: prevInterval, prev: true },
|
||||
]),
|
||||
)
|
||||
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
|
||||
},
|
||||
}
|
||||
const ids = tracked.map((provider) => provider.id)
|
||||
if (ids.length === 0) return {}
|
||||
const values = await redis.mget<(string | number | null)[]>(ids.map((id) => keys[id]))
|
||||
budgetUsage = Object.fromEntries(ids.map((id, index) => [id, Number(values[index] ?? 0)]))
|
||||
return budgetUsage
|
||||
},
|
||||
track: async (provider: string, priority: number | undefined, costInCent: number) => {
|
||||
if (priority === undefined) return
|
||||
const config = tracked.find((item) => item.id === provider && item.budgetPriority === priority)
|
||||
track: async (provider: string, costInCent: number) => {
|
||||
const config = tracked.find((item) => item.id === provider)
|
||||
if (!config) return
|
||||
if (config.budgetContribution === undefined) return
|
||||
const cost = centsToMicroCents(costInCent * config.budgetContribution)
|
||||
if (cost <= 0) return
|
||||
const redisKey = key(provider, priority, currInterval)
|
||||
const pipeline = redis.pipeline()
|
||||
pipeline.incrby(redisKey, cost)
|
||||
// Keep two minutes so the previous interval is readable for budget adjustment.
|
||||
pipeline.expire(redisKey, 120)
|
||||
pipeline.incrby(keys[provider], cost)
|
||||
pipeline.expire(keys[provider], 120)
|
||||
await pipeline.exec()
|
||||
logger.metric({
|
||||
"provider.budget_usage": cost,
|
||||
"provider.budget_priority": priority,
|
||||
"provider.budget_usage": budgetUsage[provider] + cost,
|
||||
"model.budget_usage": cost,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export namespace ZenData {
|
||||
priority: z.number().optional(),
|
||||
tpmLimit: z.number().optional(),
|
||||
tpsGoal: z.number().optional(),
|
||||
budgetPriority: z.number().optional(),
|
||||
budgetMode: z.enum(["always", "fill"]).optional(),
|
||||
budgetContribution: z.number().optional(),
|
||||
weight: z.number().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as MoveSession from "./move-session"
|
||||
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import { makeGlobalNode } from "../effect/app-node"
|
||||
import { EventV2 } from "../event"
|
||||
import { Git } from "../git"
|
||||
import { Location } from "../location"
|
||||
@@ -147,9 +146,3 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(ProjectV2.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Git.node, EventV2.node, ProjectV2.node, SessionStore.node],
|
||||
})
|
||||
|
||||
@@ -272,50 +272,7 @@ export function compile<A, E, const Items extends Replacements = readonly []>(
|
||||
}
|
||||
|
||||
function replacementMapFrom(replacements?: Replacements) {
|
||||
return (
|
||||
replacements?.reduce((map, [source, replacement]) => {
|
||||
const normalized = rewriteReplacementDependencies(replacementNode(source, replacement), map)
|
||||
const current = new Map([[source.name, normalized]])
|
||||
for (const [name, node] of map) map.set(name, rewriteReplacementDependencies(node, current))
|
||||
map.set(source.name, normalized)
|
||||
return map
|
||||
}, new Map<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)
|
||||
return new Map(replacements?.map(([source, replacement]) => [source.name, replacementNode(source, replacement)]))
|
||||
}
|
||||
|
||||
export function hasUnbound(root: Node<unknown, unknown, any>, source: AnyNode): boolean {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Observability from "./observability"
|
||||
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Effect, Layer, Logger, References } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { OtlpSerialization } from "effect/unstable/observability"
|
||||
@@ -20,5 +19,3 @@ export const layer = Layer.unwrap(
|
||||
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 { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { Patch } from "../patch"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -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 {
|
||||
const counts = diffLines(change.before, change.after).reduce(
|
||||
(result, item) => ({
|
||||
|
||||
@@ -5,13 +5,11 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Duration, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../config"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { AppProcess } from "../process"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { PositiveInt } from "../schema"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -195,9 +193,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/bash",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FSUtil.node, AppProcess.node, Config.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -15,6 +15,20 @@ import { TodoWriteTool } from "./todowrite"
|
||||
import { WebFetchTool } from "./webfetch"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { WriteTool } from "./write"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { AppProcess } from "../process"
|
||||
import { Config } from "../config"
|
||||
import { Location } from "../location"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { Image } from "../image"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { httpClient } from "../effect/app-node-platform"
|
||||
|
||||
/**
|
||||
* Composes only the shipped Location-scoped built-in tool transforms.
|
||||
@@ -46,19 +60,22 @@ export const locationLayer = Layer.mergeAll(
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "built-in-tools",
|
||||
layer: Layer.empty,
|
||||
layer: locationLayer,
|
||||
deps: [
|
||||
ApplyPatchTool.node,
|
||||
BashTool.node,
|
||||
EditTool.node,
|
||||
GlobTool.node,
|
||||
GrepTool.node,
|
||||
QuestionTool.node,
|
||||
ReadTool.node,
|
||||
SkillTool.node,
|
||||
TodoWriteTool.node,
|
||||
WebFetchTool.node,
|
||||
WebSearchTool.node,
|
||||
WriteTool.node,
|
||||
ToolRegistry.toolsNode,
|
||||
FSUtil.node,
|
||||
AppProcess.node,
|
||||
Config.node,
|
||||
Location.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
PermissionV2.node,
|
||||
Ripgrep.node,
|
||||
Image.node,
|
||||
QuestionV2.node,
|
||||
SkillV2.node,
|
||||
SessionTodo.node,
|
||||
ReadToolFileSystem.node,
|
||||
httpClient,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,12 +10,10 @@ import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -215,9 +213,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/edit",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, FSUtil.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -3,13 +3,11 @@ export * as GlobTool from "./glob"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Location } from "../location"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -97,9 +95,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/glob",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -3,14 +3,12 @@ export * as GrepTool from "./grep"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Location } from "../location"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { Ripgrep } from "../ripgrep"
|
||||
import { RelativePath } from "../schema"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -129,9 +127,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/grep",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, FSUtil.node, Ripgrep.node, Location.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -2,10 +2,8 @@ export * as QuestionTool from "./question"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -86,9 +84,3 @@ export const layer = Layer.effectDiscard(
|
||||
.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 { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Image } from "../image"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { ReadToolFileSystem } from "./read-filesystem"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -109,9 +107,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/read",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, ReadToolFileSystem.node, LocationMutation.node, Image.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -3,11 +3,9 @@ export * as SkillTool from "./skill"
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { SkillV2 } from "../skill"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -101,9 +99,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/skill",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, FSUtil.node, SkillV2.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -2,10 +2,8 @@ export * as TodoWriteTool from "./todowrite"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { SessionTodo } from "../session/todo"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -54,9 +52,3 @@ export const layer = Layer.effectDiscard(
|
||||
.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 { Parser } from "htmlparser2"
|
||||
import TurndownService from "turndown"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNodePlatform } from "../effect/app-node-platform"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -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) {
|
||||
let text = ""
|
||||
let skipDepth = 0
|
||||
|
||||
@@ -3,8 +3,6 @@ export * as WebSearchTool from "./websearch"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Context, Duration, Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { LayerNodePlatform } from "../effect/app-node-platform"
|
||||
import { truthy } from "../flag/flag"
|
||||
import { InstallationVersion } from "../installation/version"
|
||||
import { PositiveInt } from "../schema"
|
||||
@@ -13,7 +11,6 @@ import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
import { collectBoundedResponseBody } from "./http-body"
|
||||
import { checksum } from "../util/encode"
|
||||
import { ToolRegistry } from "./registry"
|
||||
|
||||
export const name = "websearch"
|
||||
export const NO_RESULTS = "No search results found. Please try a different query."
|
||||
@@ -83,8 +80,6 @@ export const defaultConfigLayer = Layer.sync(ConfigService, () =>
|
||||
}),
|
||||
)
|
||||
|
||||
export const configNode = makeLocationNode({ service: ConfigService, layer: defaultConfigLayer, deps: [] })
|
||||
|
||||
export function selectProvider(
|
||||
sessionID: string,
|
||||
flags: Pick<Config, "enableExa" | "enableParallel"> = { enableExa: false, enableParallel: false },
|
||||
@@ -252,9 +247,3 @@ export const layer = Layer.effectDiscard(
|
||||
.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 { Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { LocationMutation } from "../location-mutation"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
||||
@@ -93,9 +91,3 @@ export const layer = Layer.effectDiscard(
|
||||
.pipe(Effect.orDie)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "tool/write",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, LocationMutation.node, FileMutation.node, PermissionV2.node],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Scope } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AgentPlugin } from "@opencode-ai/core/plugin/agent"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -9,7 +8,7 @@ import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { agentHost, host } from "./plugin/host"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(AgentV2.node))
|
||||
const it = testEffect(AgentV2.locationLayer)
|
||||
|
||||
describe("AgentV2", () => {
|
||||
it.effect("starts without agents", () =>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { BackgroundJob } from "@opencode-ai/core/background-job"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Deferred, Effect, Exit, Scope } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const jobsLayer = LayerNode.compile(BackgroundJob.node)
|
||||
|
||||
describe("BackgroundJob", () => {
|
||||
it.live("tracks process-local work through explicit observation", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -28,7 +25,7 @@ describe("BackgroundJob", () => {
|
||||
timedOut: false,
|
||||
info: { status: "completed", output: "done" },
|
||||
})
|
||||
}).pipe(Effect.provide(jobsLayer)),
|
||||
}).pipe(Effect.provide(BackgroundJob.layer)),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(CommandV2.node))
|
||||
const it = testEffect(CommandV2.locationLayer)
|
||||
|
||||
describe("CommandV2", () => {
|
||||
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 { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigProvider } from "@opencode-ai/core/config/provider"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ConfigMigrateV1 } from "@opencode-ai/core/v1/config/migrate"
|
||||
@@ -27,6 +28,11 @@ function testLayer(
|
||||
projectDirectory = directory,
|
||||
vcs?: Project.Vcs,
|
||||
) {
|
||||
const configNode = makeLocationNode({
|
||||
service: Config.Service,
|
||||
layer: Layer.fresh(Config.layer.pipe(Layer.provide(Global.layerWith({ config: globalDirectory })))),
|
||||
deps: [FSUtil.node, Location.node, Policy.node],
|
||||
})
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
@@ -36,10 +42,7 @@ function testLayer(
|
||||
),
|
||||
),
|
||||
)
|
||||
return AppNodeBuilder.build(LayerNode.group([Config.node, Policy.node]), [
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ config: globalDirectory })],
|
||||
])
|
||||
return AppNodeBuilder.build(LayerNode.group([configNode, Policy.node]), [[Location.node, locationLayer]])
|
||||
}
|
||||
|
||||
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 simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
|
||||
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -270,6 +268,7 @@ describe("DatabaseMigration", () => {
|
||||
yield* DatabaseMigration.applyOnly(db, [simplifySessionInputMigration])
|
||||
|
||||
const database = Layer.succeed(Database.Service, { db })
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
yield* EventV2.Service.use((service) =>
|
||||
service.publish(SessionV1.Event.Updated, {
|
||||
sessionID: SessionSchema.ID.make("session"),
|
||||
@@ -285,7 +284,7 @@ describe("DatabaseMigration", () => {
|
||||
}),
|
||||
).pipe(
|
||||
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
|
||||
LayerNode.compile(a, [[a, Layer.effect(A, Effect.fail(new OtherError()))]])
|
||||
|
||||
const invalidNodeErrorReplacement = () =>
|
||||
// @ts-expect-error Node replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })]])
|
||||
void invalidNodeErrorReplacement
|
||||
const nodeReplacementWithError = make({ service: A, layer: Layer.effect(A, Effect.fail(new OtherError())), deps: [] })
|
||||
|
||||
// @ts-expect-error Node replacement cannot introduce a new error
|
||||
LayerNode.compile(a, [[a, nodeReplacementWithError]])
|
||||
|
||||
class TagA extends Context.Service<TagA, {}>()("test/TagA") {}
|
||||
class TagB extends Context.Service<TagB, {}>()("test/TagB") {}
|
||||
|
||||
@@ -143,21 +143,6 @@ describe("layer node", () => {
|
||||
expect(acquisitions).toBe(0)
|
||||
})
|
||||
|
||||
test("applies later replacements inside earlier replacement nodes", async () => {
|
||||
const original = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const replacement = make({ service: Greeting, layer: greetingLayer, deps: [value] })
|
||||
const program = Effect.map(Greeting, (item) => item.value).pipe(
|
||||
Effect.provide(
|
||||
build(LayerNode.group([original]), [
|
||||
[original, replacement],
|
||||
[value, Layer.succeed(Value, Value.of({ value: "replacement dependency" }))],
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
expect(await Effect.runPromise(program)).toBe("hello replacement dependency")
|
||||
})
|
||||
|
||||
test("hoists and compiles tagged graphs", async () => {
|
||||
const tags = LayerNode.tags({ location: ["global"], global: [] })
|
||||
const global = tags.make("global")
|
||||
|
||||
@@ -6,8 +6,6 @@ import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventSequenceTable, EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -78,10 +76,9 @@ const durableData = (sessionID: Session.ID, text: string) => ({
|
||||
messageID: SessionV1.MessageID.ascending(`msg_${text}`),
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node, Location.node]), [[Location.node, locationLayer]]),
|
||||
)
|
||||
const itWithoutLocation = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, EventV2.node])))
|
||||
const eventLayer = Layer.mergeAll(EventV2.layerWith().pipe(Layer.provide(Database.defaultLayer)), Database.defaultLayer)
|
||||
const it = testEffect(eventLayer.pipe(Layer.provideMerge(locationLayer)))
|
||||
const itWithoutLocation = testEffect(eventLayer)
|
||||
|
||||
describe("EventV2", () => {
|
||||
it.effect("publishes events with the current location", () =>
|
||||
@@ -465,7 +462,7 @@ describe("EventV2", () => {
|
||||
pause
|
||||
? Deferred.succeed(readStarted, undefined).pipe(Effect.andThen(Deferred.await(continueRead)))
|
||||
: Effect.void,
|
||||
}).pipe(Layer.provide(LayerNode.compile(Database.node)))
|
||||
}).pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
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([
|
||||
[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 { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -13,17 +11,14 @@ import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
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(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
|
||||
[Location.node, activeLocation],
|
||||
[FSUtil.node, filesystemLayer],
|
||||
]),
|
||||
)
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
return Effect.provide(Layer.mergeAll(resolution, mutation))
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
}),
|
||||
).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 { ConfigProvider, Deferred, Duration, Effect, Fiber, Layer, Option, Stream } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { location } from "../fixture/location"
|
||||
@@ -19,7 +18,7 @@ const describeWatcher = Watcher.hasNativeBinding() && !process.env.CI ? describe
|
||||
|
||||
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(
|
||||
Config.Service,
|
||||
@@ -41,10 +40,12 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) }, { vcs })),
|
||||
)
|
||||
return Effect.provide(
|
||||
AppNodeBuilder.build(Watcher.node, [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]).pipe(Layer.provide(flagsLayer)),
|
||||
Watcher.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(locationLayer),
|
||||
Layer.provide(flagsLayer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,7 +196,7 @@ describeWatcher("Watcher", () => {
|
||||
yield* noUpdate((event) => event.file === file, fs.writeFileString(file, "gone")).pipe(
|
||||
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", () =>
|
||||
@@ -227,7 +228,10 @@ describeWatcher("Watcher", () => {
|
||||
yield* Effect.promise(() => $`git branch ${branch}`.cwd(directory).quiet())
|
||||
expect(
|
||||
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
|
||||
).toMatchObject({ file: head })
|
||||
).toEqual({
|
||||
file: head,
|
||||
event: "change",
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import { Effect } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
|
||||
@@ -30,7 +30,7 @@ const testGlobal = Global.layerWith({
|
||||
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() {
|
||||
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 fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstructionContext } from "@opencode-ai/core/instruction-context"
|
||||
@@ -17,17 +15,6 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
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", () => {
|
||||
it.live("loads global and upward project AGENTS.md files as one aggregate context", () =>
|
||||
Effect.acquireRelease(
|
||||
@@ -54,19 +41,19 @@ describe("InstructionContext", () => {
|
||||
|
||||
const load = SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(FSUtil.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ config: global })),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -120,14 +107,14 @@ describe("InstructionContext", () => {
|
||||
yield* Effect.promise(() => fs.writeFile(file, ""))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(FSUtil.defaultLayer),
|
||||
Effect.provide(Global.layerWith({ config: path.join(tmp.path, "global") })),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: path.join(tmp.path, "global"),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(tmp.path) })),
|
||||
),
|
||||
}),
|
||||
Layer.succeed(
|
||||
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" })) }),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
const context = yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(failingFS),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: failingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
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(
|
||||
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(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: racingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
),
|
||||
}),
|
||||
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(
|
||||
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(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: observingFS,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/repo/") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -263,20 +241,18 @@ describe("InstructionContext", () => {
|
||||
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/repo") })),
|
||||
Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||
),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/repo") }))),
|
||||
),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
@@ -295,25 +271,23 @@ describe("InstructionContext", () => {
|
||||
let scanned = false
|
||||
yield* SystemContextRegistry.Service.pipe(
|
||||
Effect.flatMap((service) => service.load()),
|
||||
Effect.provide(InstructionContext.layer.pipe(Layer.provideMerge(SystemContextRegistry.layer))),
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: "/global",
|
||||
filesystemLayer: Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node))),
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make("/outside") },
|
||||
{ projectDirectory: AbsolutePath.make("/repo") },
|
||||
),
|
||||
),
|
||||
Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.pipe(
|
||||
Effect.map((fs) => FSUtil.Service.of({ ...fs, up: () => Effect.sync(() => ((scanned = true), [])) })),
|
||||
),
|
||||
}),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer)),
|
||||
),
|
||||
Effect.provide(Global.layerWith({ config: "/global" })),
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location({ directory: AbsolutePath.make("/outside") }, { projectDirectory: AbsolutePath.make("/repo") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { define } from "@opencode-ai/plugin/v2/effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
@@ -32,8 +30,25 @@ import { Reference } from "../src/reference"
|
||||
import { ToolRegistry } from "../src/tool/registry"
|
||||
import { ApplicationTools } from "../src/tool/application-tools"
|
||||
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const it = testEffect(
|
||||
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", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -22,7 +21,7 @@ const projectLayer = Layer.succeed(
|
||||
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", () => {
|
||||
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>) =>
|
||||
// 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,
|
||||
// every test would reuse the cachedInvalidateWithTTL state from the first run.
|
||||
Layer.fresh(
|
||||
|
||||
@@ -3,34 +3,57 @@ import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { 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 { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Git } from "@opencode-ai/core/git"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const project = Project.layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const layer = MoveSession.layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(project),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
MoveSession.node,
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
ProjectDirectories.node,
|
||||
Project.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
]),
|
||||
Layer.mergeAll(
|
||||
layer,
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
project,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Option } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
@@ -19,7 +21,12 @@ const writePackage = (dir: string, pkg: Record<string, unknown>) =>
|
||||
)
|
||||
|
||||
const npmLayer = (cache: string) =>
|
||||
AppNodeBuilder.build(Npm.node, [[Global.node, Global.layerWith({ cache, state: path.join(cache, "state") })]])
|
||||
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", () => {
|
||||
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 { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -13,7 +11,9 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { location } from "./fixture/location"
|
||||
@@ -23,19 +23,24 @@ const current = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make("/project") })),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionStore.node,
|
||||
PermissionSaved.node,
|
||||
AgentV2.node,
|
||||
PermissionV2.node,
|
||||
]),
|
||||
[[Location.node, current]],
|
||||
),
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const layer = PermissionV2.locationLayer.pipe(
|
||||
Layer.provideMerge(Database.defaultLayer),
|
||||
Layer.provideMerge(SessionStore.defaultLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(current),
|
||||
Layer.provideMerge(sessions),
|
||||
Layer.provideMerge(SessionExecution.noopLayer),
|
||||
Layer.provideMerge(PermissionSaved.defaultLayer),
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function setup(rules: PermissionV2.Ruleset = []) {
|
||||
return Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CommandV2 } from "@opencode-ai/core/command"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { CommandPlugin } from "@opencode-ai/core/plugin/command"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -11,11 +10,13 @@ import { host } from "./host"
|
||||
|
||||
const directory = AbsolutePath.make("/repo/packages/app")
|
||||
const project = AbsolutePath.make("/repo")
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory }, { projectDirectory: project })),
|
||||
const it = testEffect(
|
||||
CommandV2.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }, { projectDirectory: project }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
const it = testEffect(AppNodeBuilder.build(CommandV2.node, [[Location.node, locationLayer]]))
|
||||
|
||||
describe("CommandPlugin.Plugin", () => {
|
||||
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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { RepositoryCache } from "@opencode-ai/core/repository-cache"
|
||||
import { Ripgrep } from "@opencode-ai/core/ripgrep"
|
||||
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
|
||||
const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
export const PluginTestLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Location.node,
|
||||
Npm.node,
|
||||
Credential.node,
|
||||
EventV2.node,
|
||||
LayerNodePlatform.httpClient,
|
||||
PluginV2.node,
|
||||
AgentV2.node,
|
||||
AISDK.node,
|
||||
Catalog.node,
|
||||
CommandV2.node,
|
||||
Integration.node,
|
||||
Reference.node,
|
||||
SkillV2.node,
|
||||
]),
|
||||
[
|
||||
[Location.node, tempLocationLayer],
|
||||
[Npm.node, npmLayer],
|
||||
],
|
||||
export const PluginTestLayer = Layer.mergeAll(FileSystem.locationLayer, PluginV2.locationLayer).pipe(
|
||||
Layer.provideMerge(
|
||||
Layer.mergeAll(
|
||||
Credential.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
FetchHttpClient.layer,
|
||||
FSUtil.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
install: () => Effect.void,
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
),
|
||||
RepositoryCache.defaultLayer,
|
||||
SkillDiscovery.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
tempLocationLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ import os from "os"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
@@ -18,7 +17,7 @@ import { PluginTestLayer } from "./fixture"
|
||||
const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
|
||||
const fixtureProviderPath = fileURLToPath(fixtureProvider)
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.build(AISDK.node)))
|
||||
const itWithAISDK = testEffect(AISDK.locationLayer.pipe(Layer.provideMerge(PluginTestLayer)))
|
||||
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
@@ -17,12 +16,8 @@ const it = testEffect(PluginTestLayer)
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
const events = yield* EventV2.Service
|
||||
const integration = yield* Integration.Service
|
||||
yield* OpencodePlugin.effect(host).pipe(
|
||||
Effect.provideService(EventV2.Service, events),
|
||||
Effect.provideService(Integration.Service, integration),
|
||||
)
|
||||
yield* OpencodePlugin.effect(host).pipe(Effect.provideService(Integration.Service, integration))
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Policy } from "@opencode-ai/core/policy"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -8,12 +7,11 @@ import { location } from "./fixture/location"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(Policy.node, [
|
||||
[
|
||||
Location.node,
|
||||
Policy.locationLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
describe("Policy", () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Pty } from "@opencode-ai/core/pty"
|
||||
@@ -19,10 +17,11 @@ const locationLayer = Layer.succeed(
|
||||
)
|
||||
const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
|
||||
[Config.node, configLayer],
|
||||
[Location.node, locationLayer],
|
||||
]),
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provideMerge(EventV2.defaultLayer),
|
||||
Layer.provideMerge(locationLayer),
|
||||
),
|
||||
)
|
||||
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 configuredIt = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Pty.node, EventV2.node]), [
|
||||
[
|
||||
Config.node,
|
||||
Pty.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.mock(Config.Service)({
|
||||
entries: () =>
|
||||
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
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { PtyID } from "@opencode-ai/core/pty/schema"
|
||||
import { PtyTicket } from "@opencode-ai/core/pty/ticket"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(LayerNode.compile(PtyTicket.node))
|
||||
const itExpiring = testEffect(
|
||||
LayerNode.compile(PtyTicket.node, [[PtyTicket.node, Layer.effect(PtyTicket.Service, PtyTicket.make(5))]]),
|
||||
)
|
||||
const it = testEffect(PtyTicket.layer)
|
||||
const itExpiring = testEffect(Layer.effect(PtyTicket.Service, PtyTicket.make(5)))
|
||||
|
||||
describe("PTY websocket tickets", () => {
|
||||
it.live("consumes tickets once", () =>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Reference } from "@opencode-ai/core/reference"
|
||||
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context/index"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
const guidanceLayer = (referenceLayer: Layer.Layer<Reference.Service>) =>
|
||||
AppNodeBuilder.build(ReferenceGuidance.node, [[Reference.node, referenceLayer]])
|
||||
|
||||
describe("ReferenceGuidance", () => {
|
||||
it.effect("lists available references in the system context", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -21,24 +17,23 @@ describe("ReferenceGuidance", () => {
|
||||
expect(generation.baseline).toContain("<path>/docs</path>")
|
||||
expect(generation.baseline).toContain("<description>Use for product documentation</description>")
|
||||
}).pipe(
|
||||
Effect.provide(ReferenceGuidance.layer),
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
description: "Use for product documentation",
|
||||
source: Reference.LocalSource.make({
|
||||
type: "local",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
description: "Use for product documentation",
|
||||
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 generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
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", () =>
|
||||
@@ -57,19 +55,18 @@ describe("ReferenceGuidance", () => {
|
||||
const generation = yield* SystemContext.initialize(yield* guidance.load())
|
||||
expect(generation.baseline).toBe("")
|
||||
}).pipe(
|
||||
Effect.provide(ReferenceGuidance.layer),
|
||||
Effect.provide(
|
||||
guidanceLayer(
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
Layer.mock(Reference.Service, {
|
||||
list: () =>
|
||||
Effect.succeed([
|
||||
new Reference.Info({
|
||||
name: "docs",
|
||||
path: AbsolutePath.make("/docs"),
|
||||
source: Reference.LocalSource.make({ type: "local", path: AbsolutePath.make("/docs") }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Effect, Layer, Stream } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -15,6 +13,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
@@ -35,13 +34,23 @@ const projects = Layer.succeed(
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
|
||||
@@ -239,10 +248,9 @@ describe("SessionV2.create", () => {
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const targetDatabase = Database.layerFromPath(path.join(tmp.path, "target.sqlite"))
|
||||
const targetLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node]),
|
||||
[[Database.node, targetDatabase]],
|
||||
)
|
||||
const targetEvents = EventV2.layer.pipe(Layer.provide(targetDatabase))
|
||||
const targetProjector = SessionProjector.layer.pipe(Layer.provide(targetEvents), Layer.provide(targetDatabase))
|
||||
const targetStore = SessionStore.layer.pipe(Layer.provide(targetDatabase))
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
@@ -290,7 +298,7 @@ describe("SessionV2.create", () => {
|
||||
[1, EventV2.versionedType(SessionEvent.PromptAdmitted.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 { Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -23,13 +22,23 @@ const projects = Layer.succeed(
|
||||
commit: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
Layer.provide(locationServiceMapLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(projects),
|
||||
Layer.provide(SessionExecution.noopLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[
|
||||
[ProjectV2.node, projects],
|
||||
[SessionExecution.node, SessionExecution.noopLayer],
|
||||
],
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
projects,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
SessionExecution.noopLayer,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
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 { eq } from "drizzle-orm"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
@@ -11,6 +9,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
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(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
|
||||
[[SessionExecution.node, execution]],
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
execution,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
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 { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -15,6 +12,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Prompt } from "@opencode-ai/core/session/prompt"
|
||||
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 { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -60,6 +57,8 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const agents = AgentV2.layer
|
||||
const model = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://api.openai.com/v1" },
|
||||
@@ -68,22 +67,26 @@ const model = OpenAIChat.route
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
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 referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[Config.node, config],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(client),
|
||||
Layer.provide(registry),
|
||||
Layer.provide(models),
|
||||
Layer.provide(systemContext),
|
||||
Layer.provide(location),
|
||||
Layer.provide(agents),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(config),
|
||||
)
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -98,38 +101,34 @@ const execution = Layer.effect(
|
||||
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(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
SessionV2.node,
|
||||
]),
|
||||
[
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[Config.node, config],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[SessionExecution.node, execution],
|
||||
],
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
executor,
|
||||
client,
|
||||
permission,
|
||||
agents,
|
||||
registry,
|
||||
models,
|
||||
systemContext,
|
||||
location,
|
||||
skillGuidance,
|
||||
config,
|
||||
runner,
|
||||
execution,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Tool } from "@opencode-ai/core/tool/tool"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
@@ -29,13 +27,9 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
|
||||
)
|
||||
},
|
||||
})
|
||||
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
|
||||
const it = testEffect(registryLayer)
|
||||
const integrated = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ApplicationTools.node, ToolRegistry.node]), [
|
||||
[ToolOutputStore.node, outputStore],
|
||||
]),
|
||||
)
|
||||
const registry = ToolRegistry.layer.pipe(Layer.provide(ApplicationTools.layer), Layer.provide(outputStore))
|
||||
const it = testEffect(registry)
|
||||
const integrated = testEffect(Layer.mergeAll(ApplicationTools.layer, registry))
|
||||
const identity = {
|
||||
agent: AgentV2.ID.make("build"),
|
||||
assistantMessageID: SessionMessage.ID.make("msg_registry"),
|
||||
|
||||
@@ -11,10 +11,6 @@ import {
|
||||
} from "@opencode-ai/llm"
|
||||
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { makeLocationNode } from "@opencode-ai/core/effect/app-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
@@ -23,6 +19,7 @@ import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { ContextSnapshotDecodeError } from "@opencode-ai/core/session/error"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
@@ -36,6 +33,7 @@ import { SessionRunner } from "@opencode-ai/core/session/runner"
|
||||
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
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 { testEffect } from "./lib/effect"
|
||||
|
||||
const questions = QuestionV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
||||
const requests: LLMRequest[] = []
|
||||
let response: LLMEvent[] = []
|
||||
let responses: LLMEvent[][] | undefined
|
||||
@@ -121,6 +120,13 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const applications = ApplicationTools.layer
|
||||
const registry = ToolRegistry.layer.pipe(
|
||||
Layer.provide(permission),
|
||||
Layer.provide(applications),
|
||||
Layer.provide(ToolOutputStore.defaultLayer),
|
||||
)
|
||||
const agents = AgentV2.layer
|
||||
const echo = Layer.effectDiscard(
|
||||
ToolRegistry.Service.use((registry) =>
|
||||
registry.register({
|
||||
@@ -150,8 +156,7 @@ const echo = Layer.effectDiscard(
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
).pipe(Layer.provide(registry))
|
||||
let modelResolveHook = Effect.void
|
||||
let currentModel = model
|
||||
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, {
|
||||
load: (agent) =>
|
||||
Effect.succeed(
|
||||
@@ -225,17 +231,21 @@ const config = Layer.succeed(
|
||||
]),
|
||||
}),
|
||||
)
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
])
|
||||
const runner = SessionRunnerLLM.layer.pipe(
|
||||
Layer.provide(Snapshot.noopLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(SessionStore.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
Layer.provide(client),
|
||||
Layer.provide(registry),
|
||||
Layer.provide(models),
|
||||
Layer.provide(systemContext),
|
||||
Layer.provide(location),
|
||||
Layer.provide(agents),
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
Layer.provide(config),
|
||||
)
|
||||
const execution = Layer.effect(
|
||||
SessionExecution.Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -250,42 +260,36 @@ const execution = Layer.effect(
|
||||
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(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
QuestionV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
ApplicationTools.node,
|
||||
AgentV2.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
echoNode,
|
||||
SessionRunnerModel.node,
|
||||
SystemContextRegistry.node,
|
||||
SkillGuidance.node,
|
||||
ReferenceGuidance.node,
|
||||
Config.node,
|
||||
Snapshot.node,
|
||||
SessionRunnerLLM.node,
|
||||
SessionExecution.node,
|
||||
SessionV2.node,
|
||||
]),
|
||||
[
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[PermissionV2.node, permission],
|
||||
[SessionRunnerModel.node, models],
|
||||
[SystemContextRegistry.node, systemContext],
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillGuidance.node, skillGuidance],
|
||||
[ReferenceGuidance.node, referenceGuidance],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
],
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
EventV2.defaultLayer,
|
||||
questions,
|
||||
SessionProjector.defaultLayer,
|
||||
SessionStore.defaultLayer,
|
||||
client,
|
||||
permission,
|
||||
applications,
|
||||
agents,
|
||||
registry,
|
||||
echo,
|
||||
models,
|
||||
systemContext,
|
||||
location,
|
||||
skillGuidance,
|
||||
config,
|
||||
runner,
|
||||
execution,
|
||||
sessions,
|
||||
),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_runner_test")
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SkillV2 } from "@opencode-ai/core/skill"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
@@ -29,9 +28,7 @@ const denied = SkillV2.Info.make({
|
||||
})
|
||||
|
||||
const layer = (list: () => SkillV2.Info[]) =>
|
||||
AppNodeBuilder.build(SkillGuidance.node, [
|
||||
[SkillV2.node, Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })],
|
||||
])
|
||||
SkillGuidance.layer.pipe(Layer.provide(Layer.mock(SkillV2.Service, { list: () => Effect.succeed(list()) })))
|
||||
|
||||
describe("SkillGuidance", () => {
|
||||
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 path from "path"
|
||||
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 { Location } from "@opencode-ai/core/location"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
import { Hash } from "@opencode-ai/core/util/hash"
|
||||
@@ -115,7 +118,9 @@ describe("Snapshot", () => {
|
||||
return (yield* Location.Service).project.id
|
||||
}).pipe(
|
||||
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(
|
||||
@@ -167,10 +172,16 @@ describe("Snapshot", () => {
|
||||
})
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
return AppNodeBuilder.build(Snapshot.node, [
|
||||
[Location.node, Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))],
|
||||
[Global.node, Global.layerWith({ data, config: path.join(data, "config") })],
|
||||
])
|
||||
const location = Location.layer(Location.Ref.make({ directory: AbsolutePath.make(directory) })).pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
)
|
||||
return Snapshot.layer.pipe(
|
||||
Layer.provide(location),
|
||||
Layer.provide(Config.locationLayer.pipe(Layer.provide(location))),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Cause, Effect, Exit, Schema, Scope } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { SystemContext } from "@opencode-ai/core/system-context"
|
||||
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -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", () => {
|
||||
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 { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -12,7 +10,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ApplyPatchTool } from "@opencode-ai/core/tool/apply-patch"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -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 activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const patch = ApplyPatchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, 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 { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
@@ -16,7 +14,6 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { BashTool } from "@opencode-ai/core/tool/bash"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -102,26 +99,24 @@ const withTool = <A, E, R>(
|
||||
body: (registry: ToolRegistry.Interface) => Effect.Effect<A, E, R>,
|
||||
processLayer: Layer.Layer<AppProcess.Service> = appProcess,
|
||||
) => {
|
||||
const filesystem = FSUtil.defaultLayer
|
||||
const activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const mutation = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const bash = BashTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
Layer.provide(processLayer),
|
||||
Layer.provide(config),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, bash)))
|
||||
}
|
||||
|
||||
const call = (input: typeof BashTool.Input.Type, id = "call-bash") => ({
|
||||
@@ -236,7 +231,7 @@ describe("BashTool", () => {
|
||||
return withTool(
|
||||
tmp.path,
|
||||
(registry) => settleTool(registry, call({ command: "printf core-bash" })),
|
||||
LayerNode.compile(AppProcess.node),
|
||||
AppProcess.defaultLayer,
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -3,8 +3,6 @@ import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -13,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { EditTool } from "@opencode-ai/core/tool/edit"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -74,34 +71,26 @@ const filesystem = Layer.effect(
|
||||
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 activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const edit = EditTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(filesystem),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, edit)))
|
||||
}
|
||||
|
||||
const call = (input: typeof EditTool.Input.Type, id = "call-edit") => ({
|
||||
|
||||
@@ -31,6 +31,9 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
@@ -43,13 +46,8 @@ const question = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
|
||||
[PermissionV2.node, permission],
|
||||
[QuestionV2.node, question],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
const tool = QuestionTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(question))
|
||||
const it = testEffect(Layer.mergeAll(permission, registry, question, tool))
|
||||
|
||||
describe("QuestionTool", () => {
|
||||
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 { Config } from "@opencode-ai/core/config"
|
||||
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
@@ -16,7 +14,6 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { location } from "./fixture/location"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { ReadTool } from "@opencode-ai/core/tool/read"
|
||||
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -72,8 +69,9 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
|
||||
const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]])
|
||||
const image = Image.layer.pipe(Layer.provide(config))
|
||||
const testFileSystem = Layer.effect(
|
||||
FSUtil.Service,
|
||||
FSUtil.Service.use((fs) =>
|
||||
@@ -94,10 +92,11 @@ const testFileSystem = Layer.effect(
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
|
||||
).pipe(Layer.provide(FSUtil.defaultLayer))
|
||||
const infrastructure = Layer.mergeAll(
|
||||
testFileSystem,
|
||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) }))),
|
||||
Global.layerWith({ data: Global.Path.data }),
|
||||
)
|
||||
const mutation = Layer.succeed(
|
||||
LocationMutation.Service,
|
||||
@@ -129,20 +128,28 @@ const unavailableImage = Layer.succeed(
|
||||
Image.Service,
|
||||
Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }),
|
||||
)
|
||||
const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, ReadTool.node]), [
|
||||
[ReadToolFileSystem.node, reader],
|
||||
[PermissionV2.node, permission],
|
||||
[Config.node, config],
|
||||
[Image.node, imageLayer],
|
||||
[LocationMutation.node, mutation],
|
||||
[FSUtil.node, testFileSystem],
|
||||
[Location.node, locationLayer],
|
||||
[Global.node, Global.layerWith({ data: Global.Path.data })],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const it = testEffect(readLayer(imageLayer))
|
||||
const itWithoutResizer = testEffect(readLayer(unavailableImage))
|
||||
const read = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(reader),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(image),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(infrastructure),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, reader, permission, config, image, mutation, infrastructure, read))
|
||||
const unavailableRead = ReadTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(reader),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(config),
|
||||
Layer.provide(unavailableImage),
|
||||
Layer.provide(mutation),
|
||||
Layer.provide(infrastructure),
|
||||
)
|
||||
const itWithoutResizer = testEffect(
|
||||
Layer.mergeAll(registry, reader, permission, config, unavailableImage, mutation, infrastructure, unavailableRead),
|
||||
)
|
||||
const sessionID = SessionV2.ID.make("ses_read_tool_test")
|
||||
|
||||
describe("ReadTool", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
@@ -65,14 +66,16 @@ describe("SkillTool", () => {
|
||||
list: () => Effect.succeed(current),
|
||||
}),
|
||||
)
|
||||
const skillToolLayer = AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, SkillTool.node]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[SkillV2.node, skills],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
const registry = AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode]), [
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
])
|
||||
const tool = SkillTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(LayerNode.compile(FSUtil.node)),
|
||||
Layer.provide(skills),
|
||||
)
|
||||
const layer = Layer.mergeAll(permission, skills, registry, tool)
|
||||
|
||||
return yield* Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
@@ -141,7 +144,7 @@ describe("SkillTool", () => {
|
||||
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { name: "public" } },
|
||||
}),
|
||||
).toEqual({ type: "text", value: SkillTool.toModelOutput(flat, []) })
|
||||
}).pipe(Effect.provide(skillToolLayer))
|
||||
}).pipe(Effect.provide(layer))
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
@@ -13,7 +11,6 @@ import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionTodo } from "@opencode-ai/core/session/todo"
|
||||
import { TodoWriteTool } from "@opencode-ai/core/tool/todowrite"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -35,21 +32,14 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const tool = TodoWriteTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(SessionTodo.defaultLayer),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
SessionTodo.node,
|
||||
ToolRegistry.node,
|
||||
ToolRegistry.toolsNode,
|
||||
TodoWriteTool.node,
|
||||
]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
Layer.mergeAll(Database.defaultLayer, EventV2.defaultLayer, SessionTodo.defaultLayer, permission, registry, tool),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Duration, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebFetchTool } from "@opencode-ai/core/tool/webfetch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -39,14 +35,15 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const toolLayer = (replacements: LayerNode.Replacements = []) =>
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebFetchTool.node]), [
|
||||
[PermissionV2.node, permission],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
...replacements,
|
||||
])
|
||||
const it = testEffect(toolLayer([[LayerNodePlatform.httpClient, http]]))
|
||||
const live = testEffect(toolLayer())
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const webfetch = WebFetchTool.layer.pipe(Layer.provide(registry), Layer.provide(permission), Layer.provide(http))
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, webfetch))
|
||||
const fetchWebfetch = WebFetchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const live = testEffect(Layer.mergeAll(registry, permission, FetchHttpClient.layer, fetchWebfetch))
|
||||
|
||||
const reset = () => {
|
||||
requests.length = 0
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { WebSearchTool } from "@opencode-ai/core/tool/websearch"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/tool"
|
||||
|
||||
@@ -103,6 +99,7 @@ const permission = Layer.succeed(
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const websearchConfig = Layer.succeed(
|
||||
WebSearchTool.ConfigService,
|
||||
WebSearchTool.ConfigService.of({
|
||||
@@ -123,17 +120,13 @@ const websearchConfig = Layer.succeed(
|
||||
},
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, WebSearchTool.configNode, WebSearchTool.node]),
|
||||
[
|
||||
[PermissionV2.node, permission],
|
||||
[LayerNodePlatform.httpClient, http],
|
||||
[WebSearchTool.configNode, websearchConfig],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
],
|
||||
),
|
||||
const websearch = WebSearchTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(http),
|
||||
Layer.provide(websearchConfig),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(registry, permission, http, websearchConfig, websearch))
|
||||
|
||||
describe("WebSearchTool registration", () => {
|
||||
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 { Effect, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
@@ -13,7 +11,6 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { WriteTool } from "@opencode-ai/core/tool/write"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -58,34 +55,25 @@ const filesystem = Layer.effect(
|
||||
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 activeLocation = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
)
|
||||
const resolution = LocationMutation.layer.pipe(Layer.provide(filesystem), Layer.provide(activeLocation))
|
||||
const mutation = FileMutation.layer.pipe(Layer.provide(filesystem))
|
||||
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
||||
const write = WriteTool.layer.pipe(
|
||||
Layer.provide(registry),
|
||||
Layer.provide(permission),
|
||||
Layer.provide(resolution),
|
||||
Layer.provide(mutation),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
return yield* body(yield* ToolRegistry.Service)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(Layer.mergeAll(registry, resolution, mutation, write)))
|
||||
}
|
||||
|
||||
const call = (input: typeof WriteTool.Input.Type, id = "call-write") => ({
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
@@ -22,7 +21,6 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -258,10 +256,10 @@ const signatureFromMetadata = (metadata: ProviderMetadata | undefined): string |
|
||||
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,
|
||||
description: tool.description,
|
||||
input_schema: inputSchema,
|
||||
input_schema: tool.inputSchema,
|
||||
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 toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// 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 =
|
||||
request.tools.length === 0 || request.toolChoice?.type === "none"
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(
|
||||
breakpoints,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
)
|
||||
: request.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
const system =
|
||||
request.system.length === 0
|
||||
? undefined
|
||||
@@ -543,7 +533,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? outputLimit,
|
||||
max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type ModelToolSchemaCompatibility,
|
||||
type ProviderMetadata,
|
||||
type ReasoningPart,
|
||||
type ToolCallPart,
|
||||
@@ -23,7 +21,6 @@ import { BedrockAuth } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
import { BedrockMedia } from "./utils/bedrock-media"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "bedrock-converse"
|
||||
@@ -208,22 +205,18 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: { json: inputSchema },
|
||||
inputSchema: { json: tool.inputSchema },
|
||||
},
|
||||
})
|
||||
|
||||
const lowerTools = (
|
||||
compatibility: ModelToolSchemaCompatibility | undefined,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
): BedrockTool[] => {
|
||||
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
|
||||
const result: BedrockTool[] = []
|
||||
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)
|
||||
if (cachePoint) result.push(cachePoint)
|
||||
}
|
||||
@@ -393,7 +386,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const breakpoints = BedrockCache.breakpoints()
|
||||
const toolConfig =
|
||||
request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
? { tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, request.tools), toolChoice }
|
||||
? { tools: lowerTools(breakpoints, request.tools), toolChoice }
|
||||
: undefined
|
||||
const system = request.system.length === 0 ? undefined : lowerSystem(breakpoints, request.system)
|
||||
const messages = yield* lowerMessages(request, breakpoints)
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||
@@ -168,10 +166,10 @@ interface ParserState {
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
|
||||
const lowerTool = (tool: ToolDefinition) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: GeminiToolSchema.convert(inputSchema),
|
||||
parameters: GeminiToolSchema.convert(tool.inputSchema),
|
||||
})
|
||||
|
||||
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -302,7 +300,6 @@ const thinkingConfig = (request: LLMRequest) => {
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none"
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -316,15 +313,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
contents: yield* lowerMessages(request),
|
||||
systemInstruction:
|
||||
request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] },
|
||||
tools: toolsEnabled
|
||||
? [
|
||||
{
|
||||
functionDeclarations: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
tools: toolsEnabled ? [{ functionDeclarations: request.tools.map(lowerTool) }] : undefined,
|
||||
toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined,
|
||||
generationConfig: Object.values(generationConfig).some((value) => value !== undefined)
|
||||
? generationConfig
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ReasoningPart,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
@@ -176,12 +174,12 @@ const invalid = ProviderShared.invalidRequest
|
||||
// Lowering is the only place that knows how common LLM messages map onto the
|
||||
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
|
||||
// fields into `LLMRequest`.
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
|
||||
const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
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,
|
||||
// validation, and HTTP execution are composed by `Route.make`.
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type ReasoningPart,
|
||||
@@ -22,7 +21,6 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
|
||||
import { isContextOverflow } from "../provider-error"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
@@ -256,11 +254,11 @@ const invalid = ProviderShared.invalidRequest
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
|
||||
const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
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.
|
||||
strict: false,
|
||||
})
|
||||
@@ -478,16 +476,10 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const generation = request.generation
|
||||
const options = yield* lowerOptions(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tools: request.tools.length === 0 ? undefined : request.tools.map(lowerTool),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
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 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
|
||||
* 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
|
||||
// handful of common JSON Schema shapes. Keep this projection isolated so the
|
||||
@@ -20,6 +20,8 @@ const SCHEMA_INTENT_KEYS = [
|
||||
"else",
|
||||
]
|
||||
|
||||
const isRecord = ProviderShared.isRecord
|
||||
|
||||
const hasCombiner = (schema: unknown) =>
|
||||
isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf))
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
|
||||
export type ModelOptions = RouteDefaultsInput
|
||||
|
||||
/**
|
||||
* 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") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const routeDefaults = request.model.route.defaults
|
||||
const modelDefaults = request.model.defaults
|
||||
const generation = mergeGenerationOptions(routeDefaults.generation, modelDefaults?.generation, request.generation)
|
||||
return LLMRequest.update(request, {
|
||||
generation: generation ?? new GenerationOptions({}),
|
||||
providerOptions: mergeProviderOptions(
|
||||
routeDefaults.providerOptions,
|
||||
modelDefaults?.providerOptions,
|
||||
request.providerOptions,
|
||||
),
|
||||
http: mergeHttpOptions(routeDefaults.http, modelDefaults?.http, request.http),
|
||||
const resolveRequestOptions = (request: LLMRequest) =>
|
||||
LLMRequest.update(request, {
|
||||
generation:
|
||||
mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
|
||||
providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
|
||||
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
|
||||
})
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
@@ -381,12 +374,17 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
return yield* ProviderShared.eventError(
|
||||
`${request.model.provider}/${request.model.route.id}`,
|
||||
"Provider stream ended without a terminal finish event",
|
||||
return new LLMResponse(
|
||||
yield* stream(request).pipe(
|
||||
Stream.runFold(
|
||||
() => ({ events: [] as LLMEvent[], usage: undefined as LLMResponse["usage"] }),
|
||||
(acc, event) => {
|
||||
acc.events.push(event)
|
||||
if ("usage" in event && event.usage !== undefined) acc.usage = event.usage
|
||||
return acc
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -28,56 +28,9 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
|
||||
"content",
|
||||
"contents",
|
||||
"frequencyPenalty",
|
||||
"frequency_penalty",
|
||||
"generationConfig",
|
||||
"inferenceConfig",
|
||||
"input",
|
||||
"maxTokens",
|
||||
"max_tokens",
|
||||
"messages",
|
||||
"model",
|
||||
"presencePenalty",
|
||||
"presence_penalty",
|
||||
"responseFormat",
|
||||
"response_format",
|
||||
"seed",
|
||||
"stop",
|
||||
"stopSequences",
|
||||
"stop_sequences",
|
||||
"stream",
|
||||
"streamOptions",
|
||||
"stream_options",
|
||||
"system",
|
||||
"systemInstruction",
|
||||
"system_instruction",
|
||||
"temperature",
|
||||
"thinking",
|
||||
"toolChoice",
|
||||
"toolConfig",
|
||||
"tool_choice",
|
||||
"tool_config",
|
||||
"tools",
|
||||
"topK",
|
||||
"topP",
|
||||
"top_k",
|
||||
"top_p",
|
||||
])
|
||||
|
||||
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
|
||||
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
|
||||
|
||||
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
|
||||
Effect.gen(function* () {
|
||||
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
|
||||
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
|
||||
if (forbiddenKeys.length > 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
|
||||
)
|
||||
if (ProviderShared.isRecord(body)) {
|
||||
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
|
||||
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelSchema } from "./options"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ToolOutput, ToolResultValue } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
/**
|
||||
@@ -335,234 +335,9 @@ const responseUsage = (events: ReadonlyArray<LLMEvent>) =>
|
||||
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")({
|
||||
message: Message,
|
||||
events: Schema.Array(LLMEvent),
|
||||
usage: Schema.optional(Usage),
|
||||
finishReason: FinishReason,
|
||||
}) {
|
||||
/** Concatenated assistant text assembled from streamed `text-delta` events. */
|
||||
get text() {
|
||||
@@ -581,29 +356,8 @@ export class LLMResponse extends Schema.Class<LLMResponse>("LLM.Response")({
|
||||
}
|
||||
|
||||
export namespace LLMResponse {
|
||||
export type State = ResponseState
|
||||
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. */
|
||||
export const text = (response: Output) => responseText(response.events)
|
||||
|
||||
|
||||
@@ -134,62 +134,15 @@ export namespace ModelLimits {
|
||||
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 {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute
|
||||
readonly defaults?: ModelDefaults
|
||||
readonly compatibility?: ModelCompatibility
|
||||
|
||||
constructor(input: Model.ConstructorInput) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.defaults = input.defaults
|
||||
this.compatibility = input.compatibility
|
||||
}
|
||||
|
||||
static make(input: Model.Input) {
|
||||
@@ -197,8 +150,6 @@ export class Model {
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults),
|
||||
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -207,8 +158,6 @@ export class Model {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
route: model.route,
|
||||
defaults: model.defaults,
|
||||
compatibility: model.compatibility,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,15 +175,11 @@ export namespace Model {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
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 provider: string | ProviderID
|
||||
readonly defaults?: ModelDefaults.Input
|
||||
readonly compatibility?: ModelCompatibility.Input
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
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 { Model } from "../src/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -112,16 +112,9 @@ describe("llm route", () => {
|
||||
const llm = yield* LLMClient.Service
|
||||
const events = Array.from(yield* llm.stream(request).pipe(Stream.runCollect))
|
||||
const response = yield* llm.generate(request)
|
||||
const reduced = LLMResponse.fromEvents(events)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
expect(reduced).toBeDefined()
|
||||
if (!reduced) throw new Error("stream reducer did not produce a completed response")
|
||||
expect(response.events).toEqual(events)
|
||||
expect(response.message).toEqual(reduced.message)
|
||||
expect(response.usage).toEqual(reduced.usage)
|
||||
expect(response.finishReason).toEqual(reduced.finishReason)
|
||||
expect(response.message.content).toEqual([{ type: "text", text: 'echo:{"body":"hello"}' }])
|
||||
expect(response.events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@
|
||||
"headers": {
|
||||
"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": {
|
||||
"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",
|
||||
route: chatRoute,
|
||||
})
|
||||
const updated = Model.update(base, {
|
||||
route: responsesRoute,
|
||||
defaults: { generation: { maxTokens: 20 } },
|
||||
compatibility: { toolSchema: "gemini" },
|
||||
})
|
||||
const updatedInput = Model.input(updated)
|
||||
const updated = Model.update(base, { route: responsesRoute })
|
||||
|
||||
expect(updated).toBeInstanceOf(Model)
|
||||
expect(String(updated.id)).toBe("fake-model")
|
||||
expect(updated.route).toBe(responsesRoute)
|
||||
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
|
||||
expect(updated.compatibility).toEqual({ toolSchema: "gemini" })
|
||||
expect(updatedInput.defaults).toBe(updated.defaults)
|
||||
expect(updatedInput.compatibility).toBe(updated.compatibility)
|
||||
expect(String(updatedInput.provider)).toBe("fake")
|
||||
expect(String(Model.input(updated).provider)).toBe("fake")
|
||||
expect(Model.update(updated, {})).toBe(updated)
|
||||
})
|
||||
|
||||
test("carries model defaults and compatibility through route model selection", () => {
|
||||
const model = chatRoute.model({
|
||||
id: "kimi-k2",
|
||||
defaults: {
|
||||
limits: { context: 128_000, output: 8_192 },
|
||||
generation: { maxTokens: 1_024, stop: ["END"] },
|
||||
providerOptions: { openai: { parallelToolCalls: false } },
|
||||
http: { body: { extra_body: true } },
|
||||
},
|
||||
compatibility: { toolSchema: "moonshot" },
|
||||
})
|
||||
const request = LLM.request({ model, prompt: "Say hello." })
|
||||
|
||||
expect(request.model.defaults?.limits).toEqual({ context: 128_000, output: 8_192 })
|
||||
expect(request.model.defaults?.generation).toEqual({ maxTokens: 1_024, stop: ["END"] })
|
||||
expect(request.model.defaults?.providerOptions).toEqual({ openai: { parallelToolCalls: false } })
|
||||
expect(request.model.defaults?.http).toEqual({ body: { extra_body: true } })
|
||||
expect(request.model.compatibility).toEqual({ toolSchema: "moonshot" })
|
||||
expect(request.generation).toBeUndefined()
|
||||
expect(request.providerOptions).toBeUndefined()
|
||||
expect(request.http).toBeUndefined()
|
||||
})
|
||||
|
||||
test("builds tool choices from names and tools", () => {
|
||||
const tool = ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })
|
||||
|
||||
|
||||
@@ -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({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Hello!" },
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: "stop",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, 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 OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
@@ -597,22 +597,19 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: ':"weather"}' } }] }),
|
||||
)
|
||||
const input = LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
})
|
||||
const events = Array.from(
|
||||
yield* LLMClient.stream(input).pipe(Stream.runCollect, Effect.provide(fixedResponse(body))),
|
||||
)
|
||||
const error = yield* LLMClient.generate(input).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(events).toEqual([
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
|
||||
expect(error.message).toContain("Provider stream ended without a terminal finish event")
|
||||
expect(response.toolCalls).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -778,11 +778,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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,
|
||||
Effect.gen(function* () {
|
||||
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 * as Account from "./account"
|
||||
|
||||
@@ -39,7 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ac
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
const layer = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
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 * as AccountRepo from "./repo"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Command } from "@/command"
|
||||
import { InstanceRef } from "@/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -141,7 +139,7 @@ export const loaderLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
const layer = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const loader = yield* Loader
|
||||
@@ -201,12 +199,12 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const loaderNode = LayerNode.make({
|
||||
service: Loader,
|
||||
layer: loaderLayer,
|
||||
deps: [Provider.node, Agent.node, Command.node, InstanceStore.node],
|
||||
})
|
||||
|
||||
export const node = LayerNode.make({ service: Service, layer, deps: [loaderNode] })
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(loaderLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
)
|
||||
|
||||
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