Compare commits

..

3 Commits

Author SHA1 Message Date
OpenCode Agent cc801089d7 fix(core): reject binary files before reading 2026-06-05 21:40:04 +00:00
OpenCode Agent 9a1199770b fix(core): return read images as media 2026-06-05 21:39:58 +00:00
OpenCode Agent ceba61af4e fix(core): clarify binary read errors 2026-06-05 21:06:44 +00:00
194 changed files with 2063 additions and 7705 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ const table = sqliteTable("session", {
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_input` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Historical projected prompts lazily synthesize promoted inbox records during exact retry.
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; idle or missing interruption is a no-op.
- Keep `SessionExecution` process-global and Session-ID based. It discovers placement through the read-side `SessionStore` and `LocationServiceMap.get(session.location)`; no layer should take a Session ID.
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per provider turn and reload projected history before durable continuation. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash activity recovery requires a separate explicit design before it may retry provider work.
-26
View File
@@ -39,19 +39,6 @@ An expected temporary inability to observe a **Context Source** value; the runti
**Safe Provider-Turn Boundary**:
The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically.
**Model Tool Output**:
The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit.
**Managed Tool Output File**:
A temporary file created under OpenCode's shared tool-output directory to retain complete output that was too large for Session history.
**Model Request Options**:
Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request.
_Avoid_: Request body, wire options
**Generation Controls**:
Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog.
## Relationships
- A **System Context** is an opaque carrier composed from zero or more **Context Sources**.
@@ -97,22 +84,9 @@ Provider-neutral sampling and output controls, partitioned from provider semanti
- A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix.
- Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache.
- A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history.
- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding.
- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing.
- A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise.
- When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply.
- Ambient project instruction discovery honors `OPENCODE_DISABLE_PROJECT_CONFIG`; global instructions remain eligible.
- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern.
- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction.
- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit.
- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result.
- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record.
- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure.
- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction.
- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path.
- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping.
- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority.
- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads.
## Example dialogue
+25 -26
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.16.0",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -106,7 +106,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -142,7 +142,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -169,7 +169,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -191,7 +191,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -215,7 +215,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -235,7 +235,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.16.2",
"version": "1.16.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,7 +276,6 @@
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
"@opentelemetry/sdk-trace-base": "2.6.1",
"@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4",
"ai-gateway-provider": "3.1.2",
"bun-pty": "0.4.8",
"cross-spawn": "catalog:",
@@ -325,7 +324,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -379,7 +378,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -393,7 +392,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"effect": "catalog:",
},
@@ -405,7 +404,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -436,7 +435,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -452,7 +451,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -465,7 +464,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -483,7 +482,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.16.2",
"version": "1.16.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -610,7 +609,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -648,7 +647,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -663,7 +662,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -677,7 +676,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -690,7 +689,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -723,7 +722,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -742,7 +741,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -782,7 +781,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -831,7 +830,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-uMU8I2f0FjxbtWyA6lmCOpbqZZmD9jhs/g3RRckJgp4=",
"aarch64-linux": "sha256-eYOtu/OHkOLXkk9cMF5jNJtEZVCZrND75/vGDLMCKls=",
"aarch64-darwin": "sha256-SlQvOaBWeoMaM/DJ/Eqd2u6ORMYL+EMpM3p0G8h4e/M=",
"x86_64-darwin": "sha256-IGnSsqOqY/RDkTD3fF7DUELUb2TSjUOWRr+FkYNOu78="
"x86_64-linux": "sha256-mXTzANDuuy+BY4vzhuuL5Q6JVVTJCKdHuD/Fo8pSfgI=",
"aarch64-linux": "sha256-t1Uf+PIDvj9bogsSo2Dg1e+zJM2CHQ8lpA/I3vFQA1Q=",
"aarch64-darwin": "sha256-HKpMwzpYhCQOu0xHugi4ZIC/Va2BSiQpM2TbA6BEZDU=",
"x86_64-darwin": "sha256-m5h7h9KxkcIrdTO2QzQftq68d0Ru0IsCfu3WzMp4P68="
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.16.2",
"version": "1.16.0",
"description": "",
"type": "module",
"exports": {
@@ -189,7 +189,7 @@ export function DialogSelectServer() {
)
}
export function useServerManagementController(options: { onSelect?: () => void; navigateOnAdd?: boolean } = {}) {
export function useServerManagementController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const tabs = useTabs()
@@ -265,11 +265,6 @@ export function useServerManagementController(options: { onSelect?: () => void;
}
resetAdd()
if (options.navigateOnAdd === false) {
server.add(conn)
options.onSelect?.()
return
}
await select(conn, true)
},
}))
@@ -1,59 +0,0 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import { useServerManagementController } from "@/components/dialog-select-server"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
controller: ReturnType<typeof useServerManagementController>
onEdit: (server: ServerConnection.Http) => void
open?: boolean
onOpenChange?: (open: boolean) => void
}> = (props) => {
const language = useLanguage()
const key = ServerConnection.key(props.server)
const builtin = ServerConnection.builtin(props.server)
const isDefault = () => props.controller.defaultKey() === key
return (
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={props.open} onOpenChange={props.onOpenChange}>
<MenuV2.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("settings.section.server")}</MenuV2.GroupLabel>
<MenuV2.Item
disabled={builtin || props.server.type !== "http"}
onSelect={() => props.onEdit(props.server as ServerConnection.Http)}
>
{language.t("dialog.server.menu.edit")}
</MenuV2.Item>
<Show when={props.controller.canDefault() && !isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && isDefault()}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item disabled={builtin} onSelect={() => props.controller.handleRemove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
@@ -1,129 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Component, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import "./settings-v2.css"
export const DialogServerV2: Component<{
mode: "add" | "edit"
server?: ServerConnection.Http
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController({
onSelect: () => dialog.close(),
navigateOnAdd: false,
})
const [opened, setOpened] = createSignal(false)
onMount(() => {
if (props.mode === "add") controller.startAdd()
if (props.mode === "edit" && props.server) controller.startEdit(props.server)
setOpened(true)
})
onCleanup(() => {
controller.resetForm()
})
createEffect(() => {
if (!opened()) return
if (controller.isFormMode()) return
dialog.close()
})
const keyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" || event.isComposing) return
event.preventDefault()
controller.submitForm()
}
const title = () =>
props.mode === "add" ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")
const submitLabel = () => {
if (controller.formBusy()) return language.t("dialog.server.add.checking")
if (props.mode === "add") return language.t("dialog.server.add.button")
return language.t("common.save")
}
return (
<Dialog title={title()} fit class="settings-v2-server-dialog">
<div class="flex w-full min-w-0 flex-1 flex-col px-4">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formValue()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!controller.formError()}
disabled={controller.formBusy()}
autofocus
onInput={(event) => controller.handleFormChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={controller.formError()}>
<span class="settings-v2-server-dialog-error">{controller.formError()}</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formName()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormNameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="grid w-full min-w-0 grid-cols-2 gap-4">
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.username")}</label>
<TextInputV2
type="text"
appearance="large"
class="!w-full self-stretch"
value={controller.formUsername()}
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormUsernameChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex min-w-0 flex-col gap-2">
<label class="settings-v2-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInputV2
type="password"
appearance="large"
class="!w-full self-stretch"
value={controller.formPassword()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={controller.formBusy()}
onInput={(event) => controller.handleFormPasswordChange()(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={controller.formBusy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="contrast" disabled={controller.formBusy()} onClick={controller.submitForm}>
{submitLabel()}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
@@ -9,7 +9,7 @@ import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { SettingsServers } from "../settings-servers"
export const DialogSettings: Component = () => {
const language = useLanguage()
@@ -33,16 +33,16 @@ export const DialogSettings: Component = () => {
<Icon name="keyboard" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
</div>
</div>
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="providers">
<Icon name="providers" />
{language.t("settings.providers.title")}
@@ -68,7 +68,7 @@ export const DialogSettings: Component = () => {
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
<SettingsServers />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 />
@@ -1,137 +0,0 @@
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import fuzzysort from "fuzzysort"
import { type Component, For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css"
export const SettingsServersV2: Component = () => {
const dialog = useDialog()
const language = useLanguage()
const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" })
const showSearch = createMemo(() => controller.sortedItems().length > 1)
const filtered = createMemo(() => {
const items = controller.sortedItems()
const query = store.filter.trim()
if (!query) return items
return fuzzysort
.go(query, items, {
keys: [(item) => serverName(item), (item) => item.http.url],
})
.map((result) => result.obj)
})
const openAdd = () => {
dialog.push(() => <DialogServerV2 mode="add" />)
}
const openEdit = (server: ServerConnection.Http) => {
dialog.push(() => <DialogServerV2 mode="edit" server={server} />)
}
return (
<>
<div
class="settings-v2-tab-header settings-v2-servers-header"
classList={{ "settings-v2-tab-header--stacked": showSearch() }}
>
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("status.popover.tab.servers")}</h2>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("dialog.server.add.button")}
</ButtonV2>
</div>
<Show when={showSearch()}>
<div class="settings-v2-tab-search">
<TextInputV2
type="search"
appearance="base"
value={store.filter}
onInput={(event) => setStore("filter", event.currentTarget.value)}
placeholder={language.t("dialog.server.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t("dialog.server.search.placeholder")}
/>
<Show when={store.filter}>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
class="settings-v2-tab-search-clear"
icon={<IconV2 name="close" size="large" class="text-v2-icon-icon-muted" />}
onClick={() => setStore("filter", "")}
/>
</Show>
</div>
</Show>
</div>
<div class="settings-v2-tab-body settings-v2-servers">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-v2-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
<Show when={store.filter}>
<span class="settings-v2-servers-status-filter">&quot;{store.filter}&quot;</span>
</Show>
</div>
}
>
<SettingsListV2>
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
const health = () => controller.status()[key]
const isDefault = () => controller.defaultKey() === key
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={health()} />
<div class="settings-v2-servers-copy">
<span class="settings-v2-servers-name">{serverName(item)}</span>
<span class="settings-v2-servers-meta">
<Show when={health()?.version}>v{health()?.version}</Show>
<Show when={health()?.version && item.type === "http"}> • </Show>
<Show
when={item.type === "http" && item.http.username}
fallback={<Show when={item.type === "http"}>{language.t("server.row.noUsername")}</Show>}
>
{item.http.username}
</Show>
</span>
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={controller.canDefault() && isDefault()}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<ServerRowMenu server={item} controller={controller} onEdit={openEdit} />
</div>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</>
)
}
@@ -511,144 +511,3 @@
.settings-v2-shortcuts-status-filter {
color: var(--v2-text-text-base);
}
.settings-v2-tab-body.settings-v2-servers {
gap: 0;
}
.settings-v2-tab-header.settings-v2-servers-header {
padding-bottom: 24px;
}
.settings-v2-servers-header .settings-v2-tab-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-tab-header.settings-v2-servers-header.settings-v2-tab-header--stacked {
gap: 24px;
padding-bottom: 24px;
}
.settings-v2-servers [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-servers-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-servers-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.settings-v2-servers-lead {
display: flex;
min-width: 0;
flex: 1;
align-items: flex-start;
gap: 10px;
}
.settings-v2-servers-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-servers-name {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-servers-meta {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-servers-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
text-align: center;
}
.settings-v2-servers-status-filter {
color: var(--v2-text-text-base);
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
height: auto;
border-radius: 8px;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-content"] {
align-items: stretch;
width: 100%;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-header"] {
align-items: center;
padding: 24px 24px 0;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-body"] {
display: flex;
width: 100%;
min-width: 0;
flex-direction: column;
align-items: stretch;
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-footer"] {
padding: 24px;
}
.settings-v2-server-dialog-label {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-server-dialog-error {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-state-fg-danger);
}
@@ -133,7 +133,6 @@ describe("createChildStoreManager", () => {
const [store] = manager.child("/project")
expect(store.status).toBe("loading")
expect(store.limit).toBe(5)
expect(bootstraps).toEqual(["/project"])
} finally {
dispose()
@@ -134,27 +134,6 @@ describe("applyGlobalEvent", () => {
})
describe("applyDirectoryEvent", () => {
test("preserves a Home-specific retained session limit", () => {
const [store, setStore] = createStore(
baseState({
limit: 1,
session: [rootSession({ id: "a" }), rootSession({ id: "b" }), rootSession({ id: "c" })],
}),
)
applyDirectoryEvent({
event: { type: "session.created", properties: { info: rootSession({ id: "d" }) } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
retainedLimit: 3,
})
expect(store.session).toHaveLength(3)
})
test("inserts root sessions in sorted order and updates sessionTotal", () => {
const [store, setStore] = createStore(
baseState({
@@ -99,10 +99,8 @@ export function applyDirectoryEvent(input: {
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
retainedLimit?: number
}) {
const event = input.event
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
case "server.instance.disposed": {
input.push(input.directory)
@@ -117,7 +115,7 @@ export function applyDirectoryEvent(input: {
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
@@ -147,7 +145,7 @@ export function applyDirectoryEvent(input: {
}
const next = input.store.session.slice()
next.splice(result.index, 0, info)
const trimmed = trimSessions(next, { limit, permission: input.store.permission })
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
input.setStore("session", reconcile(trimmed, { key: "id" }))
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
break
+6 -11
View File
@@ -247,21 +247,17 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
},
})
async function loadSessions(directory: string, options?: { limit?: number }) {
async function loadSessions(directory: string) {
const key = directoryKey(directory)
const pending = sessionLoads.get(key)
if (pending) {
await pending
return loadSessions(directory, options)
}
if (pending) return pending
children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key)
const retainedLimit = Math.max(store.limit, options?.limit ?? 0, meta?.limit ?? 0)
if (meta && meta.limit >= retainedLimit) {
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, {
limit: retainedLimit,
limit: store.limit,
permission: store.permission,
})
if (next.length !== store.session.length) {
@@ -272,7 +268,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return
}
const limit = Math.max(retainedLimit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient
.fetchQuery({
...queryOptionsApi.sessions(key),
@@ -287,7 +283,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = Math.max(store.limit, options?.limit ?? 0, sessionMeta.get(key)?.limit ?? 0)
const limit = store.limit
const childSessions = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...childSessions], {
limit,
@@ -404,7 +400,6 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
setStore,
push: queue.push,
setSessionTodo,
retainedLimit: sessionMeta.get(key)?.limit,
vcsCache: children.vcsCache.get(key),
loadLsp: () => {
void queryClient.fetchQuery(queryOptionsApi.lsp(key))
-2
View File
@@ -202,8 +202,6 @@ export namespace ServerConnection {
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
-10
View File
@@ -1,6 +1,4 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
@@ -20,14 +18,6 @@ export type Tab = SessionTab
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
const dirBase64 = base64Encode(session.directory)
return tabs.some(
(tab) =>
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
+155 -136
View File
@@ -11,6 +11,7 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TabStateIndicator } from "@opencode-ai/ui/v2/tab-state-indicator"
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
@@ -19,35 +20,34 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server"
import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { ServerConnection, useServer } from "@/context/server"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import {
closeHomeProject,
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
homeSessionServerStatus,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { useGlobal } from "@/context/global"
import { useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { type ServerHealth } from "@/utils/server-health"
const HOME_SESSION_LIMIT = 64
const HOME_SESSION_LIMIT = 15
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"
const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0`
@@ -62,6 +62,8 @@ type HomeSessionRecord = {
projectName: string
}
type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -108,6 +110,53 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
function createHomeSessionStatus(input: {
record: () => HomeSessionRecord
sync: () => HomeSessionSync
activeServer: () => boolean
}) {
const notification = useNotification()
const permission = usePermission()
const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
const unseenCount = createMemo(() =>
input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0,
)
const hasError = createMemo(
() => input.activeServer() && notification.session.unseenHasError(input.record().session.id),
)
const hasPermissions = createMemo(
() =>
input.activeServer() &&
!!sessionPermissionRequest(
sessionStore().session,
sessionStore().permission,
input.record().session.id,
(item) => {
return !permission.autoResponds(item, input.record().session.directory)
},
),
)
const serverStatus = createMemo(() =>
homeSessionServerStatus(input.activeServer(), () => ({
working: sessionStore().session_working(input.record().session.id),
tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
})),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return serverStatus().working
})
const tint = createMemo(() => serverStatus().tint)
return {
unseenCount,
hasError,
hasPermissions,
isWorking,
tint,
show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
}
}
function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
@@ -166,11 +215,7 @@ function HomeDesign() {
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(
projectDirectories().map((directory) =>
focusedSync().project.loadSessions(directory, { limit: HOME_SESSION_LIMIT }),
),
)
await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
return null
},
}))
@@ -343,7 +388,7 @@ function HomeDesign() {
}
return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
@@ -369,17 +414,14 @@ function HomeDesign() {
language={language}
/>
<section
class="min-h-0 min-w-0 flex-1 flex flex-col pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<section class="min-w-0 flex-1 flex flex-col pt-12" aria-label={language.t("sidebar.project.recentSessions")}>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
server={state.selection.server}
sync={focusedSync()}
activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
@@ -419,7 +461,7 @@ function HomeDesign() {
{(record) => (
<HomeSessionRow
record={record}
server={state.selection.server}
sync={focusedSync()}
activeServer={state.selection.server === server.key}
openSession={openSession}
/>
@@ -455,8 +497,6 @@ function HomeProjectColumn(props: {
language: ReturnType<typeof useLanguage>
}) {
const global = useGlobal()
const dialog = useDialog()
const controller = useServerManagementController({ navigateOnAdd: false })
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] mt-14 gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
@@ -484,17 +524,29 @@ function HomeProjectColumn(props: {
const serverCtx = global.createServerCtx(item)
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()}
health={global.servers.health[key]}
controller={controller}
focusServer={props.focusServer}
chooseProject={props.chooseProject}
openEdit={(server) => dialog.show(() => <DialogServerV2 mode="edit" server={server} />)}
language={props.language}
/>
<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.server === key && !props.selected.directory ? "" : undefined}
disabled={!healthy()}
onClick={() => props.focusServer(item)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={global.servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
</button>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(item)}
/>
</div>
<Show when={healthy()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList {...props} server={item} projects={serverCtx.projects.list()} />
@@ -504,7 +556,7 @@ function HomeProjectColumn(props: {
}}
</For>
</Show>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<div class="flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -526,56 +578,6 @@ function HomeProjectColumn(props: {
)
}
function HomeServerRow(props: {
server: ServerConnection.Any
selected: boolean
healthy: boolean
health: ServerHealth | undefined
controller: ReturnType<typeof useServerManagementController>
focusServer: (server: ServerConnection.Any) => void
chooseProject: (server: ServerConnection.Any) => void
openEdit: (server: ServerConnection.Http) => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
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={!props.healthy}
onClick={() => props.focusServer(props.server)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={props.health} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{props.server.displayName ?? new URL(props.server.http.url).host}</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/server:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={state.menuOpen}
>
<ServerRowMenu
server={props.server}
controller={props.controller}
onEdit={props.openEdit}
open={state.menuOpen}
onOpenChange={(open) => setState("menuOpen", open)}
/>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(props.server)}
/>
</div>
</div>
)
}
function HomeProjectList(props: {
server: ServerConnection.Any
projects: LocalProject[]
@@ -703,50 +705,13 @@ function HomeProjectAvatar(props: { project: LocalProject }) {
)
}
function HomeSessionAvatar(props: { project: LocalProject; session: Session; activeServer: boolean }) {
const directory = () => props.session.directory
const sessionId = () => props.session.id
const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer)
return (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
unread={state.unread()}
loading={state.loading()}
/>
)
}
function HomeSessionLeading(props: {
project: LocalProject
session: Session
server: ServerConnection.Key
activeServer: boolean
}) {
const tabs = useTabs()
const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session))
return (
<div class="relative shrink-0">
<Show when={hasOpenTab()}>
<span
aria-hidden="true"
class="pointer-events-none absolute top-1/2 h-[7px] w-[3px] -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
style={{ right: "calc(100% + 12px)" }}
/>
</Show>
<HomeSessionAvatar project={props.project} session={props.session} activeServer={props.activeServer} />
</div>
)
}
function HomeSessionSearch(props: {
value: string
placeholder: string
open: boolean
loading: boolean
results: HomeSessionRecord[]
server: ServerConnection.Key
sync: HomeSessionSync
activeServer: boolean
noResultsLabel: string
bindFocus: (focus: () => void) => void
@@ -862,7 +827,7 @@ function HomeSessionSearch(props: {
{(record) => (
<HomeSessionSearchResultRow
record={record}
server={props.server}
sync={props.sync}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
@@ -948,12 +913,17 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
server: ServerConnection.Key
sync: HomeSessionSync
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
}) {
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const key = () => homeSessionSearchKey(props.record)
@@ -973,12 +943,34 @@ function HomeSessionSearchResultRow(props: {
onMouseEnter={() => props.onHighlight()}
onClick={() => props.onSelect(props.record.session)}
>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<Show
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<span
class={`${HOME_SEARCH_RESULT_TITLE} ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
@@ -1018,10 +1010,15 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
function HomeSessionRow(props: {
record: HomeSessionRecord
server: ServerConnection.Key
sync: HomeSessionSync
activeServer: boolean
openSession: (session: Session) => void
}) {
const status = createHomeSessionStatus({
record: () => props.record,
sync: () => props.sync,
activeServer: () => props.activeServer,
})
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
return (
@@ -1031,12 +1028,34 @@ function HomeSessionRow(props: {
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
>
<HomeSessionLeading
project={props.record.project}
session={props.record.session}
server={props.server}
activeServer={props.activeServer}
/>
<Show
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
</div>
}
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.16.2",
"version": "1.16.0",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.16.2",
"version": "1.16.0",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.16.2",
"version": "1.16.0",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.16.2",
"version": "1.16.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.16.2",
"version": "1.16.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.16.2",
"version": "1.16.0",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.16.2",
"version": "1.16.0",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+14 -10
View File
@@ -3,7 +3,6 @@ export * as Catalog from "./catalog"
import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { castDraft, enableMapSet, type Draft } from "immer"
import { ModelV2 } from "./model"
import { ModelRequest } from "./model-request"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
import { Location } from "./location"
@@ -107,7 +106,14 @@ export const layer = Layer.effect(
? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } }
: model.api
const request = {
...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request),
headers: {
...provider.request.headers,
...model.request.headers,
},
body: {
...provider.request.body,
...model.request.body,
},
variant: model.request.variant,
}
return new ModelV2.Info({
@@ -193,8 +199,6 @@ export const layer = Layer.effect(
}
}),
})
const available = (model: ModelV2.Info) =>
state.get().providers.get(model.providerID)?.provider.enabled !== false && model.enabled
yield* events.subscribe(PluginV2.Event.Added).pipe(
// Plugin registries are location scoped even though the event bus is process scoped.
@@ -246,17 +250,17 @@ export const layer = Layer.effect(
}),
available: Effect.fn("CatalogV2.model.available")(function* () {
return (yield* result.model.all()).filter(available)
return (yield* result.model.all()).filter((model) => {
const record = state.get().providers.get(model.providerID)
return record?.provider.enabled !== false && model.enabled
})
}),
default: Effect.fn("CatalogV2.model.default")(function* () {
const defaultModel = state.get().defaultModel
if (defaultModel) {
const provider = state.get().providers.get(defaultModel.providerID)?.provider
if (provider?.enabled !== false) {
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && available(model.value)) return model
}
const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option)
if (Option.isSome(model) && model.value.enabled) return model
}
return pipe(
-6
View File
@@ -118,12 +118,6 @@ export class Directory extends Schema.Class<Directory>("Config.Directory")({
export type Entry = Document | Directory
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
return entries
.filter((entry): entry is Document => entry.type === "document")
.findLast((entry) => entry.info[key] !== undefined)?.info[key]
}
export interface Interface {
/** Returns location config documents and supplemental directories from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
+1
View File
@@ -4,6 +4,7 @@ import { Schema } from "effect"
import { NonNegativeInt } from "../schema"
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
turns: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(Schema.optional),
}) {}
+2 -1
View File
@@ -58,7 +58,8 @@ export const Plugin = PluginV2.define({
yield* agent.update((editor) => {
const global = documents.flatMap((document) => document.info.permissions ?? [])
const configuredDefault = Config.latest(documents, "default_agent")
const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info
.default_agent
if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault))
for (const current of editor.list()) {
editor.update(current.id, (agent) => agent.permissions.push(...global))
+7 -23
View File
@@ -4,7 +4,6 @@ import { Effect } from "effect"
import { Catalog } from "../../catalog"
import { Config } from "../../config"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
@@ -14,15 +13,9 @@ export const Plugin = PluginV2.define({
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const transform = yield* catalog.transform()
const entries = yield* config.entries()
const files = entries.filter((entry): entry is Config.Document => entry.type === "document")
const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document")
yield* transform((catalog) => {
const configuredDefault = Config.latest(entries, "model")
if (configuredDefault !== undefined) {
const model = ModelV2.parse(configuredDefault)
catalog.model.default.set(model.providerID, model.modelID)
}
for (const file of files) {
for (const [id, item] of Object.entries(file.info.providers ?? {})) {
const providerID = ProviderV2.ID.make(id)
@@ -32,19 +25,16 @@ export const Plugin = PluginV2.define({
provider.enabled = { via: "custom", data: {} }
if (item.api !== undefined) provider.api = { ...item.api }
if (item.request !== undefined) {
Object.assign(provider.request.headers, item.request.headers)
Object.assign(provider.request.body, item.request.body)
Object.assign(provider.request.headers, item.request.headers ?? {})
Object.assign(provider.request.body, item.request.body ?? {})
}
})
const providerApi = catalog.provider.get(providerID)?.provider.api
const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined
for (const [id, config] of Object.entries(item.models ?? {})) {
catalog.model.update(providerID, ModelV2.ID.make(id), (model) => {
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.api !== undefined) model.api = { ...model.api, ...config.api }
const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage
if (config.capabilities !== undefined) {
model.capabilities = {
tools: config.capabilities.tools,
@@ -53,10 +43,8 @@ export const Plugin = PluginV2.define({
}
}
if (config.request !== undefined) {
ModelRequest.assign(model.request, {
headers: config.request.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}),
})
Object.assign(model.request.headers, config.request.headers ?? {})
Object.assign(model.request.body, config.request.body ?? {})
if (config.request.variant !== undefined) model.request.variant = config.request.variant
}
if (config.variants !== undefined) {
@@ -67,15 +55,11 @@ export const Plugin = PluginV2.define({
id: variant.id,
headers: {},
body: {},
generation: {},
options: {},
}
model.variants.push(existing)
}
ModelRequest.assign(existing, {
headers: variant.headers,
...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}),
})
Object.assign(existing.headers, variant.headers ?? {})
Object.assign(existing.body, variant.body ?? {})
}
}
if (config.cost !== undefined) {
+11 -19
View File
@@ -24,7 +24,6 @@ import {
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
import { ProcessOutput } from "./process-output"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
@@ -267,11 +266,18 @@ export const make = Effect.gen(function* () {
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
Deferred.doneUnsafe(signal, Exit.succeed(args))
exit = args
})
proc.on("close", (...args) => {
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, signal]))
@@ -334,16 +340,6 @@ export const make = Effect.gen(function* () {
})
}
const groupAlive = (proc: NodeChildProcess.ChildProcess) => {
if (process.platform === "win32") return false
try {
process.kill(-proc.pid!, 0)
return true
} catch {
return false
}
}
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
const opt = from ?? "stdout"
switch (opt) {
@@ -385,11 +381,9 @@ export const make = Effect.gen(function* () {
const done = yield* Deferred.isDone(signal)
const kill = timeout(proc, command, command.options)
if (done) {
const [code] = yield* Deferred.await(signal)
if (process.platform === "win32") return yield* Effect.void
if (!groupAlive(proc)) return yield* Effect.void
yield* Effect.ignore(killGroup(command, proc, command.options.killSignal ?? "SIGTERM"))
yield* Effect.sleep("100 millis")
if (groupAlive(proc)) yield* Effect.ignore(killGroup(command, proc, "SIGKILL"))
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
const send = (s: NodeJS.Signals) =>
@@ -409,7 +403,7 @@ export const make = Effect.gen(function* () {
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr)
let ref = true
const handle = makeHandle({
return makeHandle({
pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin),
stdout: out.stdout,
@@ -452,8 +446,6 @@ export const make = Effect.gen(function* () {
})
}),
})
ProcessOutput.register(handle, proc)
return handle
}
case "PipedCommand": {
const flat = flatten(command)
+19 -194
View File
@@ -13,10 +13,9 @@ import { ProjectReference } from "./project-reference"
import { NonNegativeInt, PositiveInt, RelativePath } from "./schema"
import { Protected } from "./filesystem/protected"
import { Ripgrep } from "./filesystem/ripgrep"
import { ToolOutputStore } from "./tool-output-store"
export const ReadInput = Schema.Struct({
path: Schema.String,
path: RelativePath,
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ReadInput = typeof ReadInput.Type
@@ -24,24 +23,28 @@ export type ReadInput = typeof ReadInput.Type
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
export const READ_SAMPLE_BYTES = 4 * 1024
export const MAX_MEDIA_INGEST_BYTES = 20 * 1024 * 1024
const MAX_LINE_LENGTH = 2_000
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
export class ReadLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
readonly resource: string
readonly maximumBytes: number
constructor(resource: string, maximumBytes: number) {
super(`File exceeds ${maximumBytes} byte read limit: ${resource}`)
this.name = "ReadLimitError"
this.resource = resource
this.maximumBytes = maximumBytes
}
}
export class BinaryFileError extends Error {
constructor(readonly resource: string) {
readonly resource: string
constructor(resource: string) {
super(`Cannot read binary file: ${resource}`)
this.name = "BinaryFileError"
this.resource = resource
}
}
@@ -79,31 +82,11 @@ const BINARY_EXTENSIONS = new Set([
export const isBinary = (resource: string, bytes: Uint8Array) => {
if (BINARY_EXTENSIONS.has(path.extname(resource).toLowerCase())) return true
if (bytes.length === 0) return false
let nonPrintable = 0
for (const byte of bytes) {
if (byte === 0) return true
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
}
return nonPrintable / bytes.length > 0.3
}
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const supportedImageMime = (bytes: Uint8Array) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
}
export class MediaIngestLimitError extends Error {
constructor(
readonly resource: string,
readonly maximumBytes: number,
) {
super(`Media exceeds ${maximumBytes} byte ingestion limit: ${resource}`)
this.name = "MediaIngestLimitError"
}
const nonPrintable = bytes.reduce(
(count, byte) => count + (byte === 0 || byte < 9 || (byte > 13 && byte < 32) ? 1 : 0),
0,
)
return bytes.includes(0) || nonPrintable / bytes.length > 0.3
}
export class TextContent extends Schema.Class<TextContent>("FileSystem.TextContent")({
@@ -146,7 +129,7 @@ export class ReadTarget extends Schema.Class<ReadTarget>("FileSystem.ReadTarget"
}) {}
export const ListInput = Schema.Struct({
path: Schema.String.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
})
export type ListInput = typeof ListInput.Type
@@ -240,7 +223,6 @@ export interface Interface {
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
readonly readSampleResolved: (target: ReadTarget, maximumBytes: number) => Effect.Effect<Uint8Array>
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
readonly readToolResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<Content | TextPage>
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
/** Select a contained canonical read root without asserting leaf policy. */
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
@@ -264,7 +246,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const global = yield* Effect.serviceOption(Global.Service)
const references = yield* ProjectReference.Service
const ripgrep = yield* Ripgrep.Service
const root = yield* fs.realPath(location.directory).pipe(Effect.orDie)
@@ -285,21 +266,8 @@ export const layer = Layer.effect(
if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie)
return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) }
})
const resolve = Effect.fnUntraced(function* (input?: string, reference?: string) {
const managed = path.join(
Option.match(global, { onNone: () => Global.Path.data, onSome: (value) => value.data }),
ToolOutputStore.MANAGED_DIRECTORY,
)
if (input && path.isAbsolute(input)) {
if (reference) return yield* Effect.die(new Error("Absolute paths cannot use a project reference"))
if (path.dirname(input) !== managed || !path.basename(input).startsWith("tool_"))
return yield* Effect.die(new Error("Absolute path is not managed tool output"))
const real = yield* fs.realPath(input).pipe(Effect.orDie)
const managedRoot = yield* fs.realPath(managed).pipe(Effect.orDie)
if (path.dirname(real) !== managedRoot || !path.basename(real).startsWith("tool_"))
return yield* Effect.die(new Error("Path escapes managed tool output"))
return { absolute: input, real, directory: managed, root: managedRoot }
}
const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) {
if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location"))
const selected = yield* select(reference)
const absolute = path.resolve(selected.directory, input ?? ".")
if (!FSUtil.contains(selected.directory, absolute))
@@ -529,148 +497,6 @@ export const layer = Layer.effect(
}),
)
})
const readToolResolved = Effect.fn("FileSystem.readToolResolved")(function* (
target: ReadTarget,
page: TextPageInput = {},
) {
return yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie)
const info = yield* file.stat.pipe(Effect.orDie)
if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file"))
if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino)
return yield* Effect.die(new Error("File changed after permission approval"))
const first = Option.getOrElse(
yield* file.readAlloc(Math.min(64 * 1024, Number(info.size) || READ_SAMPLE_BYTES)).pipe(Effect.orDie),
() => new Uint8Array(),
)
const mime = supportedImageMime(first)
if (mime) {
if (info.size > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
const chunks = [first]
let total = first.length
while (total <= MAX_MEDIA_INGEST_BYTES) {
const chunk = yield* file
.readAlloc(Math.min(64 * 1024, MAX_MEDIA_INGEST_BYTES + 1 - total))
.pipe(Effect.orDie)
if (Option.isNone(chunk)) break
chunks.push(chunk.value)
total += chunk.value.length
}
if (total > MAX_MEDIA_INGEST_BYTES)
return yield* Effect.die(new MediaIngestLimitError(target.resource, MAX_MEDIA_INGEST_BYTES))
return new BinaryContent({
type: "binary",
content: Buffer.concat(
chunks.map((chunk) => Buffer.from(chunk)),
total,
).toString("base64"),
encoding: "base64",
mime,
})
}
if (startsWith(first, [0x25, 0x50, 0x44, 0x46]) || isBinary(target.resource, first))
return yield* Effect.die(new BinaryFileError(target.resource))
const paged = info.size > MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined
if (!paged) {
const decoder = new TextDecoder("utf-8", { fatal: true })
const text = [yield* Effect.sync(() => decoder.decode(first, { stream: true }))]
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
if (chunk.value.includes(0)) return yield* Effect.die(new BinaryFileError(target.resource))
text.push(yield* Effect.sync(() => decoder.decode(chunk.value, { stream: true })))
}
text.push(yield* Effect.sync(() => decoder.decode()))
return new TextContent({ type: "text", content: text.join(""), mime: FSUtil.mimeType(target.real) })
}
const offset = page.offset ?? 1
const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES)
const lines: string[] = []
const decoder = new TextDecoder("utf-8", { fatal: true })
let pending = ""
let discard = false
let line = 1
let bytes = 0
let found = false
let truncated = false
let next: number | undefined
const append = (input: string) => {
if (line < offset) {
line++
return
}
if (lines.length >= limit || bytes >= MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
found = true
const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input
const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0)
if (bytes + size > MAX_READ_BYTES) {
truncated = true
next ??= line
line++
return
}
lines.push(text)
bytes += size
line++
}
const consume = (chunk: Uint8Array) => {
if (chunk.includes(0)) throw new BinaryFileError(target.resource)
let text = decoder.decode(chunk, { stream: true })
while (true) {
const index = text.indexOf("\n")
if (index === -1) {
if (!discard) {
pending += text
if (pending.length > MAX_LINE_LENGTH) {
pending = pending.slice(0, MAX_LINE_LENGTH + 1)
discard = true
}
}
break
}
const current = pending + (discard ? "" : text.slice(0, index))
pending = ""
discard = false
text = text.slice(index + 1)
append(current.endsWith("\r") ? current.slice(0, -1) : current)
}
}
yield* Effect.sync(() => consume(first))
while (true) {
const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie)
if (Option.isNone(chunk)) break
yield* Effect.sync(() => consume(chunk.value))
}
const tail = yield* Effect.sync(() => decoder.decode())
if (!discard) pending += tail
if (pending) append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)
if (!found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`))
const text = lines.join("\n")
return new TextPage({
type: "text-page",
content: text,
mime: FSUtil.mimeType(target.real),
offset,
truncated,
...(next === undefined ? {} : { next }),
})
}),
)
})
const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) {
const directory = yield* resolve(input.path, input.reference)
const info = yield* fs.stat(directory.real).pipe(Effect.orDie)
@@ -768,7 +594,6 @@ export const layer = Layer.effect(
readResolved,
readSampleResolved,
readTextPageResolved,
readToolResolved,
list: Effect.fn("FileSystem.list")(function* (input) {
return yield* listResolved(yield* resolveList(input))
}),
+4 -15
View File
@@ -30,7 +30,6 @@ export class WorktreeError extends Schema.TaggedErrorClass<WorktreeError>()("Git
operation: Schema.Literals(["create", "remove", "list"]),
message: Schema.String,
directory: Schema.optional(AbsolutePath),
forceRequired: Schema.optional(Schema.Boolean),
cause: Schema.optional(Schema.Defect),
}) {}
@@ -65,11 +64,7 @@ export interface Interface {
readonly resetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: {
repo: Repo
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, WorktreeError>
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
}
@@ -340,12 +335,10 @@ export const layer = Layer.effect(
),
)
if (result.exitCode === 0) return result.stdout.toString("utf8")
const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed"
return yield* new WorktreeError({
operation,
directory: worktreeDirectory,
message,
forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message),
message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed",
})
})
@@ -353,15 +346,11 @@ export const layer = Layer.effect(
yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory)
})
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: {
repo: Repo
directory: AbsolutePath
force: boolean
}) {
const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) {
yield* worktree(
"remove",
input.repo,
["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory],
["worktree", "remove", "--force", input.directory],
input.directory,
input.repo.store,
)
+18 -11
View File
@@ -40,14 +40,16 @@ import { LLMClient } from "@opencode-ai/llm"
import { RequestExecutor } from "@opencode-ai/llm/route"
import * as SessionRunnerLLM from "./session/runner/llm"
import { SessionRunnerModel } from "./session/runner/model"
import { SessionRunCoordinator } from "./session/run-coordinator"
import { SystemContextBuiltIns } from "./system-context/builtins"
import { FetchHttpClient } from "effect/unstable/http"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) => {
const location = Location.layer(ref)
const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer))
const systemContext = SystemContextBuiltIns.locationLayer
const base = Layer.mergeAll(
const services = Layer.mergeAll(
location,
Policy.locationLayer,
Config.locationLayer,
@@ -62,18 +64,13 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Pty.locationLayer,
SkillV2.locationLayer,
systemContext,
permissionsAndTools,
LocationMutation.locationLayer.pipe(Layer.orDie),
).pipe(Layer.provideMerge(location))
const resources = ToolOutputStore.layer.pipe(Layer.provide(base))
const permissionsAndTools = ToolRegistry.layer.pipe(
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provide(resources),
Layer.provide(base),
)
const services = Layer.mergeAll(base, resources, permissionsAndTools)
const commits = FileMutation.locationLayer.pipe(Layer.provide(services))
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
const resources = ToolOutputStore.layer.pipe(Layer.provide(services))
const todos = SessionTodo.layer.pipe(Layer.provide(services))
const questions = QuestionV2.locationLayer.pipe(Layer.provide(services))
const builtInTools = BuiltInTools.locationLayer.pipe(
@@ -90,9 +87,19 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
Layer.provide(model),
Layer.provide(skillGuidance),
)
return Layer.mergeAll(services, commits, searches, resources, todos, questions, model, runner, builtInTools).pipe(
Layer.fresh,
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
return Layer.mergeAll(
services,
commits,
searches,
resources,
todos,
questions,
model,
runner,
coordinator,
builtInTools,
).pipe(Layer.fresh)
},
idleTimeToLive: "60 minutes",
dependencies: [
+1 -1
View File
@@ -25,7 +25,7 @@ export const MAX_LINE_PREVIEW_LENGTH = 2_000
export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT))
const RootInput = {
path: Schema.String.pipe(Schema.optional),
path: RelativePath.pipe(Schema.optional),
reference: Schema.NonEmptyString.pipe(Schema.optional),
}
-124
View File
@@ -1,124 +0,0 @@
export * as ModelRequest from "./model-request"
import { Effect, Schema } from "effect"
export const Generation = Schema.Struct({
maxTokens: Schema.Number.pipe(Schema.optional),
temperature: Schema.Number.pipe(Schema.optional),
topP: Schema.Number.pipe(Schema.optional),
topK: Schema.Number.pipe(Schema.optional),
frequencyPenalty: Schema.Number.pipe(Schema.optional),
presencePenalty: Schema.Number.pipe(Schema.optional),
seed: Schema.Number.pipe(Schema.optional),
stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional),
})
export type Generation = typeof Generation.Type
export const Request = Schema.Struct({
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.Record(Schema.String, Schema.Any),
generation: Generation.pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
options: Schema.Record(Schema.String, Schema.Any).pipe(
Schema.optionalKey,
Schema.withConstructorDefault(Effect.succeed({})),
Schema.withDecodingDefaultKey(Effect.succeed({})),
),
})
export type Request = typeof Request.Type
interface MutableRequest {
headers: Record<string, string>
body: Record<string, unknown>
generation?: Generation
options?: Record<string, unknown>
}
const generationKeys = new Map<string, keyof Generation>([
["maxOutputTokens", "maxTokens"],
["maxTokens", "maxTokens"],
["temperature", "temperature"],
["topP", "topP"],
["topK", "topK"],
["frequencyPenalty", "frequencyPenalty"],
["presencePenalty", "presencePenalty"],
["seed", "seed"],
["stopSequences", "stop"],
["stop", "stop"],
])
interface Profile {
readonly namespace: string
readonly semantics: ReadonlyMap<string, string>
}
const profiles = new Map<string, Profile>([
[
"@ai-sdk/openai",
{
namespace: "openai",
semantics: new Map([
["store", "store"],
["promptCacheKey", "promptCacheKey"],
["reasoningEffort", "reasoningEffort"],
["reasoningSummary", "reasoningSummary"],
["include", "include"],
["textVerbosity", "textVerbosity"],
["serviceTier", "serviceTier"],
["service_tier", "serviceTier"],
]),
},
],
[
"@ai-sdk/openai-compatible",
{
namespace: "openai",
semantics: new Map([
["store", "store"],
["promptCacheKey", "promptCacheKey"],
["reasoningEffort", "reasoningEffort"],
["reasoning_effort", "reasoningEffort"],
]),
},
],
["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }],
])
export const namespace = (packageName: string) => profiles.get(packageName)?.namespace
export const merge = (base: Request, override: Partial<Request>) => ({
headers: { ...base.headers, ...override.headers },
body: { ...base.body, ...override.body },
generation: { ...base.generation, ...override.generation },
options: { ...base.options, ...override.options },
})
export const assign = (target: MutableRequest, override: Partial<Request>) => {
Object.assign(target.headers, override.headers)
Object.assign(target.body, override.body)
Object.assign((target.generation ??= {}), override.generation)
Object.assign((target.options ??= {}), override.options)
}
/** Partitions AI-SDK-shaped request options before they enter the Catalog. */
export function normalizeAiSdkOptions(packageName: string | undefined, input: Readonly<Record<string, unknown>>) {
const generation: Record<string, number | ReadonlyArray<string>> = {}
const options: Record<string, unknown> = {}
const body: Record<string, unknown> = {}
const semantics = profiles.get(packageName ?? "")?.semantics
for (const [key, value] of Object.entries(input)) {
const generationKey = generationKeys.get(key)
if (generationKey === "stop" && Array.isArray(value) && value.every((item) => typeof item === "string"))
generation[generationKey] = value
else if (generationKey !== undefined && generationKey !== "stop" && typeof value === "number")
generation[generationKey] = value
else if (semantics?.has(key)) options[semantics.get(key)!] = value
else body[key] = value
}
return { generation, options, body }
}
+2 -5
View File
@@ -1,7 +1,6 @@
import { DateTime, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
import { ProviderV2 } from "./provider"
import { ModelRequest } from "./model-request"
export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export type ID = typeof ID.Type
@@ -61,12 +60,12 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
api: Api,
capabilities: Capabilities,
request: Schema.Struct({
...ModelRequest.Request.fields,
...ProviderV2.Request.fields,
variant: Schema.String.pipe(Schema.optional),
}),
variants: Schema.Struct({
id: VariantID,
...ModelRequest.Request.fields,
...ProviderV2.Request.fields,
}).pipe(Schema.Array),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
@@ -98,8 +97,6 @@ export class Info extends Schema.Class<Info>("ModelV2.Info")({
request: {
headers: {},
body: {},
generation: {},
options: {},
},
variants: [],
time: {
+7 -11
View File
@@ -2,7 +2,6 @@ import { DateTime, Effect, Scope, Stream } from "effect"
import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelRequest } from "../model-request"
import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
import { ProviderV2 } from "../provider"
@@ -39,15 +38,12 @@ function cost(input: ModelsDev.Model["cost"]) {
]
}
function variants(model: ModelsDev.Model, packageName?: string) {
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => {
const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {})
return {
id: ModelV2.VariantID.make(id),
headers: { ...(item.provider?.headers ?? {}) },
...request,
}
})
function variants(model: ModelsDev.Model) {
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
id: ModelV2.VariantID.make(id),
headers: { ...(item.provider?.headers ?? {}) },
body: { ...(item.provider?.body ?? {}) },
}))
}
export const ModelsDevPlugin = PluginV2.define({
@@ -102,7 +98,7 @@ export const ModelsDevPlugin = PluginV2.define({
input: [...(model.modalities?.input ?? [])],
output: [...(model.modalities?.output ?? [])],
}
draft.variants = variants(model, model.provider?.npm ?? item.npm)
draft.variants = variants(model)
draft.time.released = released(model.release_date)
draft.cost = cost(model.cost)
draft.status = model.status ?? "active"
@@ -63,10 +63,7 @@ export const GoogleVertexPlugin = PluginV2.define({
if (item.provider.api.type !== "aisdk") continue
if (
item.provider.api.package !== "@ai-sdk/google-vertex" &&
!(
item.provider.id === ProviderV2.ID.googleVertex &&
item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
!item.provider.api.package.includes("@ai-sdk/openai-compatible")
)
continue
const project = resolveProject(item.provider.request.body)
-68
View File
@@ -1,68 +0,0 @@
import { Cause, Deferred, Duration, Effect, Exit, Fiber } from "effect"
import type { ChildProcess } from "node:child_process"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
const processes = new WeakMap<ChildProcessHandle, ChildProcess>()
export const register = (handle: ChildProcessHandle, process: ChildProcess) => {
processes.set(handle, process)
}
export const drain = <E, R>(
handle: ChildProcessHandle,
drains: ReadonlyArray<Effect.Effect<unknown, E, R>>,
options?: { readonly grace?: Duration.Input; readonly onClose?: () => void },
) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const fibers = yield* Effect.forEach(drains, (drain) => Effect.forkDetach(drain))
let closed = false
const close = Effect.sync(() => {
if (closed) return
closed = true
options?.onClose?.()
const process = processes.get(handle)
process?.stdout?.destroy()
process?.stderr?.destroy()
for (const stream of process?.stdio.slice(3) ?? []) {
if (stream && "readable" in stream && stream.readable) stream.destroy()
}
for (const fiber of fibers) fiber.interruptUnsafe()
})
const failed = yield* Deferred.make<Cause.Cause<E>>()
const observers = fibers.map((fiber) =>
fiber.addObserver((exit) => {
if (Exit.isFailure(exit)) Deferred.doneUnsafe(failed, Effect.succeed(exit.cause))
}),
)
const run = Effect.gen(function* () {
const outcome = yield* Effect.raceAllFirst([
handle.exitCode.pipe(
Effect.exit,
Effect.map((exit) => ({ type: "exit" as const, exit })),
),
Deferred.await(failed).pipe(Effect.map((cause) => ({ type: "failure" as const, cause }))),
])
for (const remove of observers) remove()
if (outcome.type === "failure") {
yield* close
yield* handle.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore)
return yield* Effect.failCause(outcome.cause)
}
const exits = yield* Effect.forEach(fibers, (fiber) => Fiber.await(fiber), { concurrency: "unbounded" }).pipe(
Effect.timeoutOrElse({ duration: options?.grace ?? "1 second", orElse: () => Effect.succeed(undefined) }),
)
if (exits) {
const failure = exits.find((exit) => Exit.isFailure(exit))
if (failure) return yield* Effect.failCause(failure.cause)
} else {
yield* close
}
return Exit.isFailure(outcome.exit) ? yield* Effect.failCause(outcome.exit.cause) : outcome.exit.value
})
return yield* restore(run).pipe(Effect.onInterrupt(() => close))
}),
)
export * as ProcessOutput from "./process-output"
+12 -23
View File
@@ -3,7 +3,6 @@ import type { PlatformError } from "effect/PlatformError"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
import { ProcessOutput } from "./process-output"
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
command: Schema.String,
@@ -126,19 +125,6 @@ export const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>,
},
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
const collector = (stream: Stream.Stream<Uint8Array, PlatformError>, maxBytes: number | undefined) => {
const state = { chunks: [] as Uint8Array[], bytes: 0, truncated: false }
const drain = Stream.runForEach(stream, (chunk) =>
Effect.sync(() => {
const remaining = maxBytes === undefined ? chunk.length : maxBytes - state.bytes
if (remaining > 0) state.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
state.bytes += chunk.length
state.truncated = maxBytes !== undefined && state.bytes > maxBytes
}),
)
return { drain, result: () => ({ buffer: Buffer.concat(state.chunks), truncated: state.truncated }) }
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -149,18 +135,21 @@ export const layer = Layer.effect(
const collect = Effect.scoped(
Effect.gen(function* () {
const handle = yield* spawner.spawn(command)
const stdout = collector(handle.stdout, options?.maxOutputBytes)
const stderr = collector(handle.stderr, options?.maxErrorBytes)
const exitCode = yield* ProcessOutput.drain(handle, [stdout.drain, stderr.drain])
const out = stdout.result()
const err = stderr.result()
const [stdout, stderr, exitCode] = yield* Effect.all(
[
collectStream(handle.stdout, options?.maxOutputBytes),
collectStream(handle.stderr, options?.maxErrorBytes),
handle.exitCode,
],
{ concurrency: "unbounded" },
)
return {
command: description,
exitCode,
stdout: out.buffer,
stderr: err.buffer,
stdoutTruncated: out.truncated,
stderrTruncated: err.truncated,
stdout: stdout.buffer,
stderr: stderr.buffer,
stdoutTruncated: stdout.truncated,
stderrTruncated: stderr.truncated,
} satisfies RunResult
}),
)
+4 -3
View File
@@ -2,7 +2,7 @@ export * as ProjectV2 from "./project"
export * as Project from "./project"
import { Context, Effect, Layer, Schema } from "effect"
import { asc, desc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import path from "path"
import { AbsolutePath, withStatics } from "./schema"
import { FSUtil } from "./fs-util"
@@ -76,10 +76,11 @@ export const layer = Layer.effect(
.select({ directory: ProjectDirectoryTable.directory })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => AbsolutePath.make(row.directory))
return rows
.toSorted((a, b) => a.directory.localeCompare(b.directory))
.map((row) => AbsolutePath.make(row.directory))
})
const cached = Effect.fnUntraced(function* (dir: string) {
+4 -4
View File
@@ -19,10 +19,10 @@ export function makeStrategies(input: {
yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.find(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force })
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) {
const found = yield* input.git.find(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
yield* input.git.worktreeRemove({ repo: found, directory })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.find(directory)
+2 -6
View File
@@ -34,7 +34,6 @@ export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: Project.ID,
directory: AbsolutePath,
force: Schema.Boolean,
}).annotate({ identifier: "ProjectCopy.RemoveInput" })
export type RemoveInput = typeof RemoveInput.Type
@@ -83,10 +82,7 @@ export interface Strategy {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
}
@@ -213,7 +209,7 @@ export const layer = Layer.effect(
const copyDirectory = yield* canonical(input.directory)
const id = yield* detect({ directory: copyDirectory })
if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory })
yield* strategy(id).remove({ directory: copyDirectory, force: input.force })
yield* strategy(id).remove(copyDirectory)
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
})
+9 -63
View File
@@ -1,11 +1,9 @@
export * as OpenCode from "./opencode"
import { Context, Effect, Layer } from "effect"
import { Catalog } from "../catalog"
import { Database } from "../database/database"
import { EventV2 } from "../event"
import { LocationServiceMap } from "../location-layer"
import { PluginBoot } from "../plugin/boot"
import { ProjectV2 } from "../project"
import { SessionV2 } from "../session"
import * as SessionExecutionLocal from "../session/execution/local"
@@ -23,61 +21,16 @@ export interface Interface {
/** Intentional public native API for Effect applications embedding OpenCode. */
export class Service extends Context.Service<Service, Interface>()("@opencode/public/OpenCode") {}
class SessionModelValidation extends Context.Service<
SessionModelValidation,
{
readonly validate: (
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
}
>()("@opencode/public/OpenCode/SessionModelValidation") {}
const LocationServicesLayer = LocationServiceMap.layer
const SessionModelValidationLayer = Layer.effect(
SessionModelValidation,
Effect.gen(function* () {
const locations = yield* LocationServiceMap
return SessionModelValidation.of({
validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) {
yield* Effect.gen(function* () {
yield* (yield* PluginBoot.Service).wait()
const catalog = yield* Catalog.Service
const model = (yield* catalog.model.available()).find(
(model) => model.providerID === input.model.providerID && model.id === input.model.id,
)
if (!model)
return yield* new Session.ModelUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
})
if (
input.model.variant !== undefined &&
input.model.variant !== "default" &&
!model.variants.some((variant) => variant.id === input.model.variant)
)
return yield* new Session.VariantUnavailableError({
providerID: input.model.providerID,
modelID: input.model.id,
variant: input.model.variant,
})
}).pipe(Effect.provide(locations.get(input.location)))
}),
})
}),
const SessionsLayer = SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(LocationServiceMap.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
)
const SessionsLayer = Layer.merge(
SessionV2.layer.pipe(
Layer.provide(SessionProjector.layer),
Layer.provide(SessionExecutionLocal.layer),
Layer.provide(SessionStore.layer),
Layer.provide(EventV2.layer),
Layer.provide(Database.defaultLayer),
Layer.provide(ProjectV2.defaultLayer),
Layer.orDie,
),
SessionModelValidationLayer,
).pipe(Layer.provide(LocationServicesLayer))
const ApplicationToolsLayer = ApplicationTools.layer
// TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence.
@@ -86,7 +39,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
const tools = yield* ApplicationTools.Service
const validation = yield* SessionModelValidation
return Service.of({
tools: { attach: tools.attach },
sessions: {
@@ -99,12 +51,6 @@ export const layer = Layer.effect(
}),
get: sessions.get,
list: sessions.list,
switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) {
const session = yield* sessions.get(input.sessionID)
yield* validation.validate({ ...input, location: session.location })
yield* sessions.switchModel(input)
}),
interrupt: sessions.interrupt,
prompt: (input) =>
sessions.prompt({
id: input.id,
+1 -29
View File
@@ -1,8 +1,7 @@
export * as Session from "./session"
import { Effect, Schema, Stream } from "effect"
import { Effect, Stream } from "effect"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { SessionV2 } from "../session"
import { MessageDecodeError } from "../session/error"
import { SessionEvent } from "../session/event"
@@ -44,23 +43,6 @@ export type NotFoundError = SessionV2.NotFoundError
export const PromptConflictError = SessionV2.PromptConflictError
export type PromptConflictError = SessionV2.PromptConflictError
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"Session.ModelUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
},
) {}
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"Session.VariantUnavailableError",
{
providerID: Model.Ref.fields.providerID,
modelID: Model.Ref.fields.id,
variant: ModelV2.VariantID,
},
) {}
export { MessageDecodeError }
export interface CreateInput {
@@ -77,11 +59,6 @@ export interface PromptInput {
readonly delivery?: Delivery
}
export interface SwitchModelInput {
readonly sessionID: ID
readonly model: Model.Ref
}
export interface MessagesInput {
readonly sessionID: ID
readonly limit?: number
@@ -107,11 +84,6 @@ export interface Interface {
readonly get: (sessionID: ID) => Effect.Effect<Info, NotFoundError>
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
readonly switchModel: (
input: SwitchModelInput,
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
/** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */
readonly interrupt: (sessionID: ID) => Effect.Effect<void>
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
+9 -31
View File
@@ -1,7 +1,7 @@
export * as SessionV2 from "./session"
export * from "./session/schema"
import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect"
import { Cause, Effect, Layer, Schema, Context, Stream } from "effect"
import { and, asc, desc, eq, gt, like, lt, or, type SQL } from "drizzle-orm"
import { ProjectV2 } from "./project"
import { WorkspaceV2 } from "./workspace"
@@ -88,7 +88,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
"Session.OperationUnavailableError",
{
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]),
operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "switchModel", "compact", "wait"]),
},
) {}
@@ -132,7 +132,7 @@ export interface Interface {
readonly switchModel: (input: {
sessionID: SessionSchema.ID
model: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
}) => Effect.Effect<void, OperationUnavailableError>
readonly prompt: (input: {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
@@ -155,7 +155,6 @@ export interface Interface {
readonly compact: (input: CompactInput) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError | OperationUnavailableError>
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError | SessionRunner.RunError>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Session") {}
@@ -172,13 +171,13 @@ export const layer = Layer.effect(
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const scope = yield* Effect.scope
const enqueueWake = (admitted: SessionInput.Admitted) =>
execution.wake(admitted.sessionID, admitted.admittedSeq).pipe(
const enqueueWake = (sessionID: SessionSchema.ID) =>
execution.wake(sessionID).pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to wake Session").pipe(
Effect.annotateLogs("sessionID", admitted.sessionID),
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
),
@@ -352,7 +351,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) {
if (input.resume !== false) yield* enqueueWake(admitted)
if (input.resume !== false) yield* enqueueWake(input.sessionID)
return admitted
}, Effect.uninterruptible)
const messageID = input.id ?? SessionMessage.ID.create()
@@ -385,14 +384,8 @@ export const layer = Layer.effect(
switchAgent: Effect.fn("V2Session.switchAgent")(function* () {
return yield* new OperationUnavailableError({ operation: "switchAgent" })
}),
switchModel: Effect.fn("V2Session.switchModel")(function* (input) {
yield* result.get(input.sessionID)
yield* events.publish(SessionEvent.ModelSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
timestamp: yield* DateTime.now,
model: input.model,
})
switchModel: Effect.fn("V2Session.switchModel")(function* () {
return yield* new OperationUnavailableError({ operation: "switchModel" })
}),
compact: Effect.fn("V2Session.compact")(function* (input) {
yield* result.get(input.sessionID)
@@ -406,21 +399,6 @@ export const layer = Layer.effect(
yield* result.get(sessionID)
yield* execution.resume(sessionID)
}),
interrupt: Effect.fn("V2Session.interrupt")((sessionID) =>
Effect.uninterruptible(
Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* execution.interrupt(sessionID)
const event = yield* events.publish(SessionEvent.InterruptRequested, {
sessionID,
timestamp: yield* DateTime.now,
})
if (event.seq === undefined)
return yield* Effect.die("Interrupt request event is missing aggregate sequence")
yield* execution.interrupt(sessionID, event.seq)
}),
),
),
})
return result
-246
View File
@@ -1,246 +0,0 @@
export * as SessionCompaction from "./compaction"
import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm"
import { DateTime, Effect, Stream } from "effect"
import type { Config } from "../config"
import type { EventV2 } from "../event"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionSchema } from "./schema"
import { Token } from "../util/token"
const DEFAULT_BUFFER = 20_000
const DEFAULT_KEEP_TOKENS = 8_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const SUMMARY_OUTPUT_TOKENS = 4_096
const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
<template>
## Goal
- [single-sentence task summary]
## Constraints & Preferences
- [user constraints, preferences, specs, or "(none)"]
## Progress
### Done
- [completed work or "(none)"]
### In Progress
- [current work or "(none)"]
### Blocked
- [blockers or "(none)"]
## Key Decisions
- [decision and why, or "(none)"]
## Next Steps
- [ordered next actions or "(none)"]
## Critical Context
- [important technical facts, errors, open questions, or "(none)"]
## Relevant Files
- [file or directory path: why it matters, or "(none)"]
</template>
Rules:
- Keep every section, even when empty.
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, commands, error strings, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
type Entry = {
readonly seq: number
readonly message: SessionMessage.Message
}
type Settings = {
readonly auto: boolean
readonly buffer: number
readonly tokens: number
}
type Dependencies = {
readonly events: EventV2.Interface
readonly llm: {
readonly stream: (request: LLMRequest) => Stream.Stream<LLMEvent, LLMError>
}
readonly config: readonly Config.Entry[]
}
type Input = {
readonly sessionID: SessionSchema.ID
readonly entries: readonly Entry[]
readonly model: Model
readonly request: LLMRequest
}
const estimate = (value: unknown) => Token.estimate(JSON.stringify(value))
const truncate = (value: string) =>
value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]`
export const serializeToolContent = (content: SessionMessage.ToolStateCompleted["content"]) =>
content
.map((item) =>
item.type === "text" ? item.text : `[Attached ${item.mime}${item.name === undefined ? "" : `: ${item.name}`}]`,
)
.join("\n")
const serialize = (message: SessionMessage.Message) => {
if (message.type === "user") {
const files = message.files?.map((file) => `[Attached ${file.mime}: ${file.name ?? file.uri}]`) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "assistant") {
return message.content
.flatMap((part) => {
if (part.type === "text") return [`[Assistant]: ${part.text}`]
if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : []
const input = typeof part.state.input === "string" ? part.state.input : JSON.stringify(part.state.input)
if (part.state.status === "completed")
return [
`[Assistant tool call]: ${part.name}(${input})`,
`[Tool result]: ${truncate(serializeToolContent(part.state.content))}`,
]
if (part.state.status === "error")
return [`[Assistant tool call]: ${part.name}(${input})`, `[Tool error]: ${part.state.error.message}`]
return [`[Assistant tool call]: ${part.name}(${input})`]
})
.join("\n")
}
if (message.type === "system") return `[System update]: ${message.text}`
if (message.type === "synthetic") return `[Synthetic context]: ${message.text}`
if (message.type === "shell") return `[Shell]: ${message.command}\n${truncate(message.output)}`
return ""
}
const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
entries: readonly Entry[],
tokens: number,
): { readonly head: string; readonly recent: string } | undefined => {
const conversation = entries
.filter((entry) => entry.message.type !== "compaction")
.map((entry) => serialize(entry.message))
.filter(Boolean)
if (conversation.length === 0) return
let total = 0
let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index])
if (next > tokens) {
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
total = next
split = index
}
return {
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
const compactAfterOverflow = Effect.fn("SessionCompaction.compactAfterOverflow")(function* (input: Input) {
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
const selected = select(input.entries, config.tokens)
const previousSummary = input.entries.find((entry) => entry.message.type === "compaction")?.message
if (!selected || (selected.head.length === 0 && previousSummary?.type !== "compaction")) return false
const summaryPrompt = buildPrompt({
previousSummary: previousSummary?.type === "compaction" ? previousSummary.summary : undefined,
context: [previousSummary?.type === "compaction" ? previousSummary.recent : "", selected.head].filter(Boolean),
})
const summaryOutput = Math.min(output || SUMMARY_OUTPUT_TOKENS, SUMMARY_OUTPUT_TOKENS)
if (Token.estimate(summaryPrompt) > context - summaryOutput) return false
const messageID = SessionMessage.ID.create()
yield* dependencies.events.publish(SessionEvent.Compaction.Started, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
})
const chunks: string[] = []
let failed = false
const summarized = yield* dependencies.llm
.stream(
LLM.request({
model: input.model,
messages: [Message.user(summaryPrompt)],
tools: [],
generation: { maxTokens: summaryOutput },
}),
)
.pipe(
Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
return Effect.void
}),
Effect.as(true),
Effect.catchTag("LLM.Error", () => Effect.succeed(false)),
)
const summary = chunks.join("")
if (!summarized || failed || !summary.trim()) return false
yield* dependencies.events.publish(SessionEvent.Compaction.Ended, {
sessionID: input.sessionID,
messageID,
timestamp: yield* DateTime.now,
reason: "auto",
text: summary,
recent: selected.recent,
})
return true
})
const compactIfNeeded = Effect.fn("SessionCompaction.compactIfNeeded")(function* (input: Input) {
if (!config.auto) return false
const context = input.model.route.defaults.limits?.context
if (context === undefined || context <= 0) return false
const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0
if (
estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <=
context - Math.max(output, config.buffer)
)
return false
return yield* compactAfterOverflow(input)
})
return {
compactIfNeeded,
compactAfterOverflow,
}
}
+4 -25
View File
@@ -119,13 +119,6 @@ export namespace PromptLifecycle {
export type Promoted = typeof Promoted.Type
}
export const InterruptRequested = EventV2.define({
type: "session.next.interrupt.requested",
...options,
schema: Base,
})
export type InterruptRequested = typeof InterruptRequested.Type
export const ContextUpdated = EventV2.define({
type: "session.next.context.updated",
...options,
@@ -373,7 +366,6 @@ export namespace Tool {
...ToolBase,
structured: ToolOutput.Structured,
content: Schema.Array(ToolOutput.Content),
outputPaths: Schema.Array(Schema.String).pipe(Schema.optional),
result: Schema.Unknown.pipe(Schema.optional),
provider: Schema.Struct({
executed: Schema.Boolean,
@@ -436,16 +428,15 @@ export namespace Compaction {
export const Delta = EventV2.define({
type: "session.next.compaction.delta",
...options,
schema: {
...Base,
messageID: SessionMessageID.ID,
text: Schema.String,
},
})
export type Delta = typeof Delta.Type
// Retain the unpublished v1 decoder so stored beta events remain replayable.
export const EndedV1 = EventV2.define({
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
...options,
schema: {
@@ -454,18 +445,6 @@ export namespace Compaction {
include: Schema.String.pipe(Schema.optional),
},
})
export const Ended = EventV2.define({
type: "session.next.compaction.ended",
sync: { aggregate: "sessionID", version: 2 },
schema: {
...Base,
messageID: SessionMessageID.ID,
reason: Started.data.fields.reason,
text: Schema.String,
recent: Schema.String,
},
})
export type Ended = typeof Ended.Type
}
@@ -476,7 +455,6 @@ const DurableDefinitions = [
Prompted,
PromptLifecycle.Admitted,
PromptLifecycle.Promoted,
InterruptRequested,
ContextUpdated,
Synthetic,
Shell.Started,
@@ -496,9 +474,10 @@ const DurableDefinitions = [
Reasoning.Ended,
Retried,
Compaction.Started,
Compaction.Delta,
Compaction.Ended,
] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta, Compaction.Delta] as const
const EphemeralDefinitions = [Text.Delta, Tool.Input.Delta, Reasoning.Delta] as const
export const Durable = Schema.Union(DurableDefinitions, { mode: "oneOf" }).pipe(Schema.toTaggedUnion("type"))
export type DurableEvent = typeof Durable.Type
+2 -7
View File
@@ -8,16 +8,11 @@ export interface Interface {
/** Explicitly drain one Session, making at least one provider attempt. */
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Schedule a drain after durable work is recorded. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void, SessionRunner.RunError>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID, seq?: number) => Effect.Effect<void>
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
}
/** Routes execution from a Session ID to the runner owned by that Session's Location. */
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionExecution") {}
/** Low-level compatibility layer for callers that only need durable Session recording. */
export const noopLayer = Layer.succeed(
Service,
Service.of({ resume: () => Effect.void, wake: () => Effect.void, interrupt: () => Effect.void }),
)
export const noopLayer = Layer.succeed(Service, Service.of({ resume: () => Effect.void, wake: () => Effect.void }))
+16 -17
View File
@@ -1,7 +1,6 @@
import { Effect, Layer } from "effect"
import { LocationServiceMap } from "../../location-layer"
import { SessionRunCoordinator } from "../run-coordinator"
import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
import { SessionExecution } from "../execution"
@@ -12,25 +11,25 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, mode) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunner.Service.use((runner) => runner.run({ sessionID, force: mode === "run" })).pipe(
Effect.provide(locations.get(session.location)),
)
}),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
const scope = yield* Effect.scope
const withCoordinator = Effect.fnUntraced(function* <A, E>(
sessionID: SessionSchema.ID,
use: (coordinator: SessionRunCoordinator.Interface) => Effect.Effect<A, E>,
) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
return yield* SessionRunCoordinator.Service.use(use).pipe(Effect.provide(locations.get(session.location)))
})
return SessionExecution.Service.of({
interrupt: coordinator.interrupt,
resume: coordinator.run,
wake: coordinator.wake,
resume: Effect.fn("SessionExecution.resume")(function* (sessionID) {
return yield* withCoordinator(sessionID, (coordinator) => coordinator.run(sessionID))
}),
wake: Effect.fn("SessionExecution.wake")(function* (sessionID) {
yield* withCoordinator(sessionID, (coordinator) =>
coordinator.wake(sessionID).pipe(Effect.andThen(coordinator.awaitIdle(sessionID))),
).pipe(Effect.forkIn(scope), Effect.asVoid)
}),
})
}),
)
+5 -14
View File
@@ -12,7 +12,7 @@ const decode = Schema.decodeUnknownEffect(SessionMessage.Message)
const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select()
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction")))
.orderBy(desc(SessionMessageTable.seq))
@@ -27,7 +27,7 @@ const messageRows = Effect.fnUntraced(function* (
compaction: { readonly seq: number } | undefined,
baselineSeq?: number,
) {
const rows = yield* db
return yield* db
.select()
.from(SessionMessageTable)
.where(
@@ -49,7 +49,6 @@ const messageRows = Effect.fnUntraced(function* (
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
return rows
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -84,17 +83,9 @@ export const loadForRunner = Effect.fn("SessionHistory.loadForRunner")(function*
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
return (yield* entriesForRunner(db, sessionID, baselineSeq)).map((entry) => entry.message)
})
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
baselineSeq: number,
) {
const rows = yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq)
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
return yield* Effect.forEach(
yield* messageRows(db, sessionID, yield* latestCompaction(db, sessionID), baselineSeq),
decodeMessageRow,
)
})
+47 -7
View File
@@ -10,8 +10,10 @@ export type MemoryState = {
export interface Adapter {
readonly getCurrentAssistant: () => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getAssistant: (messageID: SessionMessage.ID) => Effect.Effect<SessionMessage.Assistant | undefined>
readonly getCurrentCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined>
readonly getCurrentShell: (callID: string) => Effect.Effect<SessionMessage.Shell | undefined>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void>
}
@@ -21,6 +23,7 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID)
// A newer turn supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
const activeCompactionIndex = () => state.messages.findLastIndex((message) => message.type === "compaction")
const activeShellIndex = (callID: string) =>
state.messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
@@ -41,6 +44,14 @@ export function memory(state: MemoryState): Adapter {
return assistant?.type === "assistant" ? assistant : undefined
})
},
getCurrentCompaction() {
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const compaction = state.messages[index]
return compaction?.type === "compaction" ? compaction : undefined
})
},
getCurrentShell(callID) {
return Effect.sync(() => {
const index = activeShellIndex(callID)
@@ -58,6 +69,15 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = assistant
})
},
updateCompaction(compaction) {
return Effect.sync(() => {
const index = activeCompactionIndex()
if (index < 0) return
const current = state.messages[index]
if (current?.type !== "compaction") return
state.messages[index] = compaction
})
},
updateShell(shell) {
return Effect.sync(() => {
const index = activeShellIndex(shell.callID)
@@ -139,7 +159,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
},
"session.next.prompt.admitted": () => Effect.void,
"session.next.prompt.promoted": () => Effect.void,
"session.next.interrupt.requested": () => Effect.void,
"session.next.context.updated": (event) =>
adapter.appendMessage(
new SessionMessage.System({
@@ -308,7 +327,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
input: match.state.input,
structured: event.data.structured,
content: [...event.data.content],
outputPaths: event.data.outputPaths ? [...event.data.outputPaths] : [],
result: event.data.result,
}),
)
@@ -368,21 +386,43 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
})
},
"session.next.retried": () => Effect.void,
"session.next.compaction.started": () => Effect.void,
"session.next.compaction.delta": () => Effect.void,
"session.next.compaction.ended": (event) => {
"session.next.compaction.started": (event) => {
return adapter.appendMessage(
new SessionMessage.Compaction({
id: event.data.messageID,
type: "compaction",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
summary: "",
time: { created: event.data.timestamp },
}),
)
},
"session.next.compaction.delta": (event) => {
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary += event.data.text
}),
)
}
})
},
"session.next.compaction.ended": (event) => {
return Effect.gen(function* () {
const currentCompaction = yield* adapter.getCurrentCompaction()
if (currentCompaction) {
yield* adapter.updateCompaction(
produce(currentCompaction, (draft) => {
draft.summary = event.data.text
draft.include = event.data.include
}),
)
}
})
},
})
})
}
+1 -2
View File
@@ -86,7 +86,6 @@ export class ToolStateCompleted extends Schema.Class<ToolStateCompleted>("Sessio
input: Schema.Record(Schema.String, Schema.Unknown),
attachments: SessionEvent.FileAttachment.pipe(Schema.Array, Schema.optional),
content: ToolOutput.Content.pipe(Schema.Array),
outputPaths: SessionEvent.Tool.Success.data.fields.outputPaths,
structured: ToolOutput.Structured,
result: SessionEvent.Tool.Success.data.fields.result,
}) {}
@@ -173,7 +172,7 @@ export class Compaction extends Schema.Class<Compaction>("Session.Message.Compac
type: Schema.Literal("compaction"),
reason: SessionEvent.Compaction.Started.data.fields.reason,
summary: Schema.String,
recent: Schema.String,
include: Schema.String.pipe(Schema.optional),
...Base,
}) {}
+24 -8
View File
@@ -168,6 +168,23 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
return message.type === "assistant" ? message : undefined
})
},
getCurrentCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "compaction")),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
getCurrentShell(callID) {
return Effect.gen(function* () {
const rows = yield* db
@@ -183,6 +200,7 @@ function run(db: DatabaseService, event: SessionEvent.Event) {
})
},
updateAssistant: updateMessage,
updateCompaction: updateMessage,
updateShell: updateMessage,
appendMessage,
}
@@ -410,7 +428,6 @@ export const layer = Layer.effectDiscard(
)
}),
)
yield* events.project(SessionEvent.InterruptRequested, () => Effect.void)
yield* events.project(SessionEvent.ContextUpdated, (event) => {
if (!event.replay || event.seq === undefined) return run(db, event)
return run(db, event).pipe(
@@ -434,14 +451,13 @@ export const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (event) => run(db, event))
// yield* events.project(SessionEvent.Retried, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Delta, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => {
if (event.version === 1) return Effect.void
const seq = event.seq
if (seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return Effect.gen(function* () {
yield* run(db, event)
yield* SessionContextEpoch.requestReplacement(db, event.data.sessionID, seq)
})
if (event.seq === undefined) return Effect.die("Synchronized Session event is missing aggregate sequence")
return run(db, event).pipe(
Effect.andThen(SessionContextEpoch.requestReplacement(db, event.data.sessionID, event.seq)),
)
})
}),
)
+72 -176
View File
@@ -1,14 +1,11 @@
export * as SessionRunCoordinator from "./run-coordinator"
import { Cause, Context, Deferred, Effect, Exit, Fiber, FiberSet, Layer, Scope } from "effect"
import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Scope } from "effect"
import { SessionRunner } from "./runner"
import { SessionSchema } from "./schema"
export type Mode = "run" | "wake"
/** Why one drain generation should run. Explicit runs dominate advisory wakes when demands coalesce. */
type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?: number }
/**
* Runs at most one drain chain per key while allowing different keys to drain concurrently.
*
@@ -21,44 +18,24 @@ type Demand = { readonly _tag: "run" } | { readonly _tag: "wake"; readonly seq?:
*
* `wake` reports that durable work may now be available. It starts a chain while idle or
* requests one coalesced follow-up while draining. Repeated wakes collapse together.
*
* `interrupt` stops the current ownership chain. Advisory wakes from before the interrupt
* boundary are suppressed; advisory wakes after the boundary run after cleanup.
*/
export interface Coordinator<Key, A, E> {
/** Starts or joins one explicit drain generation. */
readonly run: (key: Key) => Effect.Effect<A, E>
/** Coalesces one wake-up after durable work is recorded. */
readonly wake: (key: Key, seq?: number) => Effect.Effect<void>
readonly wake: (key: Key) => Effect.Effect<void>
/** Waits until the current ownership chain settles. */
readonly awaitIdle: (key: Key) => Effect.Effect<void, E>
/** Interrupts the active ownership chain without automatically draining pending wakes. */
readonly interrupt: (key: Key, seq?: number) => Effect.Effect<void>
}
/** One Session's process-local execution lane: one active demand and at most one coalesced follow-up. */
type Entry<A, E> = {
readonly done: Deferred.Deferred<A, E>
readonly settled: Deferred.Deferred<Exit.Exit<A, E>>
current: Demand
pending?: Demand
explicitWaiter?: Deferred.Deferred<A, E>
interruptSeq?: number
owner?: Fiber.Fiber<void, never>
stopping: boolean
mode: Mode
rerun?: Mode
explicit?: Deferred.Deferred<A, E>
}
/** Combines follow-up demand: runs dominate, while wakes retain the newest durable admission sequence. */
const coalesce = (left: Demand | undefined, right: Demand): Demand => {
if (left?._tag === "run" || right._tag === "run") return { _tag: "run" }
return { _tag: "wake", seq: maxSeq(left?.seq, right.seq) }
}
const maxSeq = (left: number | undefined, right: number | undefined) => {
if (left === undefined) return right
if (right === undefined) return left
return Math.max(left, right)
}
const strongest = (left: Mode | undefined, right: Mode): Mode => (left === "run" || right === "run" ? "run" : "wake")
/** Constructs a scoped coordinator. Every in-memory transition is synchronous. */
export const make = <Key, A, E>(options: {
@@ -67,8 +44,7 @@ export const make = <Key, A, E>(options: {
}): Effect.Effect<Coordinator<Key, A, E>, never, Scope.Scope> =>
Effect.gen(function* () {
const active = new Map<Key, Entry<A, E>>()
const interruptSeq = new Map<Key, number>()
const report = yield* FiberSet.makeRuntime<never, void, never>()
const scope = yield* Effect.scope
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const shutdown = Deferred.makeUnsafe<void>()
let closed = false
@@ -77,100 +53,67 @@ export const make = <Key, A, E>(options: {
closed = true
Deferred.doneUnsafe(shutdown, Effect.void)
active.clear()
interruptSeq.clear()
}),
)
const makeEntry = (current: Demand, explicitWaiter?: Deferred.Deferred<A, E>): Entry<A, E> => ({
const makeEntry = (mode: Mode, explicit?: Deferred.Deferred<A, E>): Entry<A, E> => ({
done: Deferred.makeUnsafe<A, E>(),
settled: Deferred.makeUnsafe<Exit.Exit<A, E>>(),
current,
explicitWaiter,
stopping: false,
mode,
explicit,
})
const start = (key: Key, entry: Entry<A, E>, demand: Demand, successor = false) => {
const ready = Deferred.makeUnsafe<void>()
const drain = Effect.suspend(() => options.drain(key, demand._tag))
// Initial work retains immediate-start behavior but cannot run before ownership is published.
// Observer-started successors yield once so synchronous drains cannot recurse on the JS stack.
const owner = fork(
(successor
? Effect.yieldNow.pipe(Effect.andThen(drain))
: Deferred.await(ready).pipe(Effect.andThen(drain))
).pipe(
Effect.onExit((exit) => Effect.sync(() => settle(key, entry, demand, exit))),
Effect.exit,
Effect.asVoid,
),
const start = (key: Key, entry: Entry<A, E>, mode: Mode) => {
fork(own(key, entry, mode))
}
const own = (key: Key, entry: Entry<A, E>, mode: Mode): Effect.Effect<void> =>
Effect.suspend(() => options.drain(key, mode)).pipe(
Effect.exit,
Effect.flatMap((exit) => {
if (closed) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
if (mode === "run" && entry.explicit !== undefined) {
Deferred.doneUnsafe(entry.explicit, exit)
entry.explicit = undefined
}
if (exit._tag === "Success") {
if (active.get(key) !== entry) return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
if (entry.rerun !== undefined) {
const mode = entry.rerun
entry.rerun = undefined
entry.mode = mode
return own(key, entry, mode)
}
active.delete(key)
return Deferred.done(entry.done, exit).pipe(Effect.asVoid)
}
const successor =
active.get(key) === entry && entry.rerun !== undefined ? makeEntry(entry.rerun, entry.explicit) : undefined
if (successor === undefined) active.delete(key)
else {
active.set(key, successor)
}
if (successor !== undefined) start(key, successor, successor.mode)
const report =
mode === "wake" && options.onFailure !== undefined
? options.onFailure(key, exit.cause).pipe(Effect.forkIn(scope), Effect.asVoid)
: Effect.void
return Deferred.done(entry.done, exit).pipe(Effect.andThen(report), Effect.asVoid)
}),
)
entry.owner = owner
if (!successor) Deferred.doneUnsafe(ready, Effect.void)
}
const settle = (key: Key, entry: Entry<A, E>, demand: Demand, exit: Exit.Exit<A, E>) => {
if (closed) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (demand._tag === "run" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (entry.stopping && demand._tag === "wake" && entry.explicitWaiter !== undefined) {
Deferred.doneUnsafe(entry.explicitWaiter, exit)
entry.explicitWaiter = undefined
}
if (active.get(key) !== entry) {
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
if (exit._tag === "Success" && !entry.stopping) {
if (entry.pending !== undefined) {
const pending = entry.pending
entry.pending = undefined
entry.current = pending
start(key, entry, pending, true)
return
}
active.delete(key)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
return
}
const successor = entry.pending !== undefined ? makeEntry(entry.pending, entry.explicitWaiter) : undefined
if (successor === undefined) active.delete(key)
else active.set(key, successor)
if (successor !== undefined) start(key, successor, successor.current, true)
Deferred.doneUnsafe(entry.done, exit)
Deferred.doneUnsafe(entry.settled, Effect.succeed(exit))
if (
exit._tag === "Failure" &&
!(entry.stopping && Cause.hasInterruptsOnly(exit.cause)) &&
demand._tag === "wake" &&
options.onFailure !== undefined
) {
report(Effect.suspend(() => options.onFailure!(key, exit.cause)))
}
}
const wake = (key: Key, seq?: number) =>
const wake = (key: Key) =>
Effect.sync(() => {
if (closed) return
if (!isAfterInterrupt(key, seq)) return
const entry = active.get(key)
if (entry !== undefined) {
if (!acceptsWake(entry, seq)) return
entry.pending = coalesce(entry.pending, { _tag: "wake", seq })
entry.rerun = strongest(entry.rerun, "wake")
return
}
const next = makeEntry({ _tag: "wake", seq })
const next = makeEntry("wake")
active.set(key, next)
start(key, next, next.current)
start(key, next, "wake")
})
const awaitIdle = (key: Key): Effect.Effect<void, E> =>
@@ -180,7 +123,7 @@ export const make = <Key, A, E>(options: {
const entry = active.get(key)
if (entry === undefined) break
const exit = yield* Effect.raceFirst(
Deferred.await(entry.settled),
Deferred.await(entry.done).pipe(Effect.exit),
Deferred.await(shutdown).pipe(Effect.as(Exit.void)),
)
if (closed) break
@@ -189,53 +132,24 @@ export const make = <Key, A, E>(options: {
if (firstFailure !== undefined) return yield* Effect.failCause(firstFailure)
})
const interrupt = (key: Key, seq?: number): Effect.Effect<void> =>
Effect.suspend(() => {
const entry = active.get(key)
const latest = interruptSeq.get(key)
if (seq !== undefined && latest !== undefined && seq <= latest)
return entry?.stopping && entry.owner !== undefined ? Fiber.interrupt(entry.owner) : Effect.void
if (seq !== undefined) interruptSeq.set(key, seq)
if (entry?.owner === undefined) return Effect.void
if (
seq !== undefined &&
entry.current._tag === "wake" &&
entry.current.seq !== undefined &&
entry.current.seq > seq
)
return Effect.void
if (entry.stopping) {
entry.interruptSeq = maxSeq(entry.interruptSeq, seq)
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
}
entry.stopping = true
entry.interruptSeq = seq
suppressPendingAtOrBefore(entry, seq)
return Fiber.interrupt(entry.owner)
})
return { run, wake, awaitIdle, interrupt }
return { run, wake, awaitIdle }
function run(key: Key): Effect.Effect<A, E> {
return Effect.uninterruptibleMask((restore) => {
if (closed) return Effect.interrupt
const entry = active.get(key)
if (entry !== undefined) {
if (entry.stopping) {
return restore(Deferred.await(entry.settled).pipe(Effect.andThen(run(key))))
}
if (entry.current._tag === "wake") {
entry.pending = coalesce(entry.pending, { _tag: "run" })
entry.explicitWaiter ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicitWaiter))
if (entry.mode === "wake") {
entry.rerun = "run"
entry.explicit ??= Deferred.makeUnsafe<A, E>()
return restore(awaitRun(entry.explicit))
}
return restore(awaitRun(entry.done))
}
const next = makeEntry({ _tag: "run" })
const next = makeEntry("run")
active.set(key, next)
start(key, next, next.current)
start(key, next, "run")
return restore(awaitRun(next.done))
})
}
@@ -243,26 +157,6 @@ export const make = <Key, A, E>(options: {
function awaitRun(done: Deferred.Deferred<A, E>): Effect.Effect<A, E> {
return Effect.raceFirst(Deferred.await(done), Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}
function acceptsWake(entry: Entry<A, E>, seq: number | undefined) {
return !entry.stopping || (entry.interruptSeq !== undefined && seq !== undefined && seq > entry.interruptSeq)
}
function isAfterInterrupt(key: Key, seq: number | undefined) {
const latest = interruptSeq.get(key)
return latest === undefined || (seq !== undefined && seq > latest)
}
function suppressPendingAtOrBefore(entry: Entry<A, E>, seq: number | undefined) {
if (
entry.pending?._tag === "wake" &&
seq !== undefined &&
entry.pending.seq !== undefined &&
entry.pending.seq > seq
)
return
entry.pending = undefined
}
})
export interface Interface extends Coordinator<SessionSchema.ID, void, SessionRunner.RunError> {}
@@ -271,17 +165,19 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
SessionRunner.Service.pipe(
Effect.flatMap((runner) =>
make<SessionSchema.ID, void, SessionRunner.RunError>({
Effect.gen(function* () {
const runner = yield* SessionRunner.Service
return Service.of(
yield* make<SessionSchema.ID, void, SessionRunner.RunError>({
drain: (sessionID, mode) => runner.run({ sessionID, force: mode === "run" }),
onFailure: (sessionID, cause) =>
Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to drain Session").pipe(
Effect.annotateLogs("sessionID", sessionID),
Effect.annotateLogs("cause", cause),
),
}),
),
Effect.map(Service.of),
),
)
}),
)
+41 -115
View File
@@ -1,18 +1,8 @@
import {
LLM,
LLMClient,
LLMError,
LLMEvent,
SystemPart,
isContextOverflowFailure,
type ProviderErrorEvent,
} from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm"
import { Cause, DateTime, Effect, FiberSet, Layer, Schema, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent"
import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { QuestionV2 } from "../../question"
@@ -21,9 +11,7 @@ import { SystemContextRegistry } from "../../system-context/registry"
import { SkillGuidance } from "../../skill/guidance"
import { ToolRegistry } from "../../tool/registry"
import { SessionContextEpoch } from "../context-epoch"
import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event"
import { SessionHistory } from "../history"
import { SessionInput } from "../input"
import { SessionSchema } from "../schema"
import { SessionStore } from "../store"
@@ -94,12 +82,9 @@ export const layer = Layer.effect(
const tools = yield* ToolRegistry.Service
const models = yield* SessionRunnerModel.Service
const store = yield* SessionStore.Service
const location = yield* Location.Service
const systemContext = yield* SystemContextRegistry.Service
const skillGuidance = yield* SkillGuidance.Service
const config = yield* Config.Service
const db = (yield* Database.Service).db
const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() })
const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(`Session not found: ${sessionID}`)
@@ -138,29 +123,14 @@ export const layer = Layer.effect(
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
type TurnTransition =
// Request preparation observed a concurrent Session change and must restart from durable state.
| { readonly _tag: "RebuildPreparedTurn"; readonly promotion?: SessionInput.Delivery }
// Overflow compaction completed; rebuild once through the path without overflow recovery.
| { readonly _tag: "ContinueAfterOverflowCompaction" }
class TurnTransitionError extends Error {
constructor(readonly transition: TurnTransition) {
class RetryTurn extends Error {
constructor(readonly promotion: SessionInput.Delivery | undefined) {
super()
}
}
const rebuildPreparedTurn = (promotion?: SessionInput.Delivery) =>
new TurnTransitionError({ _tag: "RebuildPreparedTurn", promotion })
const continueAfterOverflowCompaction = new TurnTransitionError({
_tag: "ContinueAfterOverflowCompaction",
})
const retryAgentMismatch = (promotion: SessionInput.Delivery | undefined) =>
Effect.catchDefect((defect) =>
defect instanceof SessionContextEpoch.AgentMismatch
? Effect.die(rebuildPreparedTurn(promotion))
: Effect.die(defect),
defect instanceof SessionContextEpoch.AgentMismatch ? Effect.die(new RetryTurn(promotion)) : Effect.die(defect),
)
const sameModel = Schema.toEquivalence(Schema.UndefinedOr(ModelV2.Ref))
@@ -172,11 +142,8 @@ export const layer = Layer.effect(
const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
const agent = yield* agents.select(session.agent)
const initialized = yield* SessionContextEpoch.initialize(
db,
@@ -207,21 +174,17 @@ export const layer = Layer.effect(
).pipe(retryAgentMismatch(undefined)))
const current = yield* getSession(sessionID)
if ((yield* agents.select(current.agent)).id !== agent.id || !sameModel(current.model, session.model))
return yield* Effect.die(rebuildPreparedTurn())
return yield* Effect.die(new RetryTurn(undefined))
const model = yield* models.resolve(session)
const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq)
const context = entries.map((entry) => entry.message)
const context = yield* store.runnerContext(session.id, system.baselineSeq)
const request = LLM.request({
model,
providerOptions: { openai: { promptCacheKey: session.id } },
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: toLLMMessages(context, model),
tools: yield* tools.definitions(),
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(rebuildPreparedTurn())
const publisher = createLLMEventPublisher(events, {
sessionID: session.id,
agent: agent.id,
@@ -232,47 +195,35 @@ export const layer = Layer.effect(
},
})
const withPublication = Semaphore.makeUnsafe(1).withPermit
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) =>
withPublication(publisher.publish(event, outputPaths))
let overflowFailure: ProviderErrorEvent | undefined
const publish = (event: LLMEvent) => withPublication(publisher.publish(event))
if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision)))
return yield* Effect.die(rebuildPreparedTurn())
return yield* Effect.die(new RetryTurn(undefined))
const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) =>
Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) {
overflowFailure = event
return
}
}
yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return
needsContinuation = true
yield* Effect.uninterruptibleMask((restore) =>
restore(tools.settle({ sessionID: session.id, agent: agent.id, call: event })).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause) || Cause.hasInterrupts(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
outputPaths: [],
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
settlement.outputPaths ?? [],
),
yield* tools.settle({ sessionID: session.id, agent: agent.id, call: event }).pipe(
Effect.catchCause((cause) => {
if (isQuestionRejected(cause)) return Effect.failCause(cause)
return Effect.succeed({
result: { type: "error" as const, value: String(Cause.squash(cause)) },
output: undefined,
})
}),
Effect.flatMap((settlement) =>
publish(
LLMEvent.toolResult({
id: event.id,
name: event.name,
result: settlement.result,
output: settlement.output,
}),
),
),
).pipe(FiberSet.run(toolFibers))
FiberSet.run(toolFibers),
)
}),
),
Effect.ensuring(withPublication(publisher.flush())),
@@ -281,17 +232,13 @@ export const layer = Layer.effect(
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const stream = yield* restore(providerStream).pipe(Effect.exit)
const failure =
stream._tag === "Failure" ? Option.getOrUndefined(Cause.findErrorOption(stream.cause)) : undefined
if (
recoverOverflow &&
!publisher.hasAssistantStarted() &&
isContextOverflowFailure(overflowFailure ?? failure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, entries, model, request })))
)
return yield* Effect.die(continueAfterOverflowCompaction)
if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = failure instanceof LLMError ? failure : undefined
let llmFailure: LLMError | undefined
if (stream._tag === "Failure") {
for (const reason of stream.cause.reasons) {
if (!Cause.isFailReason(reason)) continue
if (reason.error instanceof LLMError) llmFailure = reason.error
}
}
if (llmFailure && !publisher.hasProviderError()) {
yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true))
yield* withPublication(
@@ -327,38 +274,17 @@ export const layer = Layer.effect(
}),
)
}, Effect.scoped)
type RunTurn = (
const runTurn: (
sessionID: SessionSchema.ID,
promotion: SessionInput.Delivery | undefined,
) => Effect.Effect<boolean, RunError>
const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow")
yield* Effect.yieldNow
return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion)
}),
) => Effect.Effect<boolean, RunError> = (sessionID, promotion) =>
runTurnAttempt(sessionID, promotion).pipe(
Effect.catchDefect((defect) =>
defect instanceof RetryTurn
? Effect.yieldNow.pipe(Effect.andThen(runTurn(sessionID, defect.promotion)))
: Effect.die(defect),
),
)
})
const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) {
return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe(
Effect.catchDefect(
Effect.fnUntraced(function* (defect) {
if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect)
yield* Effect.yieldNow
if (defect.transition._tag === "ContinueAfterOverflowCompaction")
return yield* runAfterOverflowCompaction(sessionID, undefined)
return yield* runTurn(sessionID, defect.transition.promotion)
}),
),
)
})
const run = Effect.fn("SessionRunner.run")(function* (input: {
readonly sessionID: SessionSchema.ID
+9 -15
View File
@@ -9,7 +9,6 @@ import { Context, Effect, Layer, Option, Schema } from "effect"
import { produce } from "immer"
import { Catalog } from "../../catalog"
import { ModelV2 } from "../../model"
import { ModelRequest } from "../../model-request"
import { PluginBoot } from "../../plugin/boot"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
@@ -51,30 +50,24 @@ const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => {
return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) => {
const options = model.request.options ?? {}
const namespace = model.api.type === "aisdk" ? ModelRequest.namespace(model.api.package) : undefined
const body = model.request.body
const httpBody = Object.hasOwn(body, "apiKey")
? Object.fromEntries(Object.entries(body).filter(([key]) => key !== "apiKey"))
: body
return route.with({
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: model.api.url === undefined ? undefined : { baseURL: model.api.url },
headers: model.request.headers,
generation: model.request.generation,
providerOptions: namespace && Object.keys(options).length > 0 ? { [namespace]: options } : undefined,
http: { body: httpBody },
http: {
body: Object.fromEntries(Object.entries(model.request.body).filter(([key]) => key !== "apiKey")),
},
limits: { context: model.limit.context, output: model.limit.output },
})
}
const withVariant = (model: ModelV2.Info, variantID: ModelV2.VariantID | undefined) => {
const id = variantID === "default" || variantID === undefined ? model.request.variant : variantID
const variant = model.variants.find((item) => item.id === id)
if (!variant) return model
return produce(model, (draft) => {
ModelRequest.assign(draft.request, variant)
Object.assign(draft.request.headers, variant.headers)
Object.assign(draft.request.body, variant.body)
})
}
@@ -135,9 +128,10 @@ export const locationLayer = Layer.effect(
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
yield* boot.wait()
const preferred = yield* catalog.model.default()
const selected = session.model
? yield* catalog.model.get(session.model.providerID, session.model.id)
: (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ??
: (Option.getOrUndefined(preferred.pipe(Option.filter(supported))) ??
(yield* catalog.model.available()).find(supported))
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID))
@@ -165,7 +165,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) {
if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`)
const assistantMessageID = yield* startAssistant()
const assistantMessageID = yield* currentAssistantMessageID()
tools.set(event.id, {
assistantMessageID,
name: event.name,
@@ -218,12 +218,10 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
})
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent,
outputPaths: ReadonlyArray<string> = [],
) {
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) {
switch (event.type) {
case "step-start":
yield* startAssistant()
return
case "text-start":
yield* text.start(event.id)
@@ -349,8 +347,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID: tool.assistantMessageID,
callID: event.id,
...result,
outputPaths,
...(provider.executed ? { result: event.result } : {}),
result: event.result,
provider,
})
return
@@ -380,7 +377,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* events.publish(SessionEvent.Step.Ended, {
sessionID: input.sessionID,
timestamp: yield* timestamp,
assistantMessageID: yield* startAssistant(),
assistantMessageID: yield* currentAssistantMessageID(),
finish: event.reason,
cost: 0,
tokens: tokens(event.usage),
@@ -401,12 +398,5 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
}
})
return {
publish,
flush,
failUnsettledTools,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed,
startAssistant,
}
return { publish, flush, failUnsettledTools, hasProviderError: () => providerFailed, startAssistant }
}
@@ -129,17 +129,7 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
Message.make({
id: message.id,
role: "user",
content: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
${message.summary}
</summary>
<recent-context>
${message.recent}
</recent-context>
</conversation-checkpoint>`,
content: `Summary of earlier conversation:\n${message.summary}`,
metadata: message.metadata,
}),
]
+216 -78
View File
@@ -1,19 +1,57 @@
export * as ToolOutputStore from "./tool-output-store"
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { Config } from "./config"
import { FSUtil } from "./fs-util"
import { Global } from "./global"
import { NonNegativeInt, PositiveInt } from "./schema"
import { SessionSchema } from "./session/schema"
import { Identifier } from "./util/identifier"
import type { ToolOutput } from "@opencode-ai/llm"
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024
export const MAX_READ_BYTES = 50 * 1024
export const RETENTION = Duration.days(7)
export const MANAGED_DIRECTORY = "tool-output"
const URI_PREFIX = "tool-output://"
const MANAGED_DIRECTORY = path.join("tool-output", "managed")
const ID_PATTERN = /^[0-9a-f]{12}[0-9A-Za-z]{14}$/
export class Resource extends Schema.Class<Resource>("ToolOutputStore.Resource")({
uri: Schema.String,
mime: Schema.String,
name: Schema.String.pipe(Schema.optional),
size: NonNegativeInt,
}) {}
export class Page extends Schema.Class<Page>("ToolOutputStore.Page")({
resource: Resource,
content: Schema.String,
offset: NonNegativeInt,
truncated: Schema.Boolean,
next: NonNegativeInt.pipe(Schema.optional),
}) {}
export class AccessDeniedError extends Schema.TaggedErrorClass<AccessDeniedError>()(
"ToolOutputStore.AccessDeniedError",
{
uri: Schema.String,
sessionID: SessionSchema.ID,
},
) {}
export class InvalidResourceError extends Schema.TaggedErrorClass<InvalidResourceError>()(
"ToolOutputStore.InvalidResourceError",
{
uri: Schema.String,
},
) {}
export class ResourceNotFoundError extends Schema.TaggedErrorClass<ResourceNotFoundError>()(
"ToolOutputStore.ResourceNotFoundError",
{ uri: Schema.String },
) {}
export interface WriteInput {
readonly sessionID: SessionSchema.ID
@@ -28,31 +66,70 @@ export interface TruncateInput extends WriteInput {
readonly maxBytes?: number
}
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly outputPath: string }
export interface BoundInput {
export interface ReadInput {
readonly sessionID: SessionSchema.ID
readonly toolCallID: string
readonly output: ToolOutput
readonly uri: string
/** Zero-based byte offset. Returned `next` values preserve UTF-8 boundaries. */
readonly offset?: number
readonly limit?: number
}
export interface BoundResult {
readonly output: ToolOutput
readonly outputPaths: ReadonlyArray<string>
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly resource: Resource }
interface Record {
readonly version: 1
readonly id: string
readonly uri: string
readonly sessionID: string
readonly toolCallID: string
readonly mime: string
readonly name?: string
readonly size: number
readonly created: number
}
export interface Interface {
readonly limits: () => Effect.Effect<{ readonly maxLines: number; readonly maxBytes: number }>
readonly write: (input: WriteInput) => Effect.Effect<string>
readonly write: (input: WriteInput) => Effect.Effect<Resource>
readonly truncate: (input: TruncateInput) => Effect.Effect<TruncateResult>
readonly bound: (input: BoundInput) => Effect.Effect<BoundResult>
readonly read: (
input: ReadInput,
) => Effect.Effect<Page, AccessDeniedError | InvalidResourceError | ResourceNotFoundError>
readonly cleanup: () => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolOutputStore") {}
const uri = (id: string) => URI_PREFIX + id
const idFromUri = (input: string) => {
if (!input.startsWith(URI_PREFIX)) return
const id = input.slice(URI_PREFIX.length)
if (!ID_PATTERN.test(id)) return
return id
}
const validRecord = (input: unknown, id: string): input is Record => {
if (!input || typeof input !== "object") return false
const record = input as Partial<Record>
return (
record.version === 1 &&
record.id === id &&
record.uri === uri(id) &&
typeof record.sessionID === "string" &&
typeof record.toolCallID === "string" &&
typeof record.mime === "string" &&
(record.name === undefined || typeof record.name === "string") &&
typeof record.size === "number" &&
Number.isSafeInteger(record.size) &&
record.size >= 0 &&
typeof record.created === "number" &&
Number.isFinite(record.created)
)
}
const takePrefix = (input: string, maximumBytes: number) => {
let bytes = 0
let content = ""
@@ -101,14 +178,6 @@ const preview = (text: string, maxLines: number, maxBytes: number) => {
return { head: takePrefix(sampled, headBytes), tail: takeSuffix(sampled, tailBytes) }
}
const boundedPreview = (text: string, marker: string, maxLines: number, maxBytes: number) => {
const markerOnly = takePrefix(marker, maxBytes).split("\n").slice(0, maxLines).join("\n")
const markerBytes = Buffer.byteLength(marker, "utf-8")
if (maxLines <= 4 || maxBytes <= markerBytes + 4) return markerOnly
const bounded = preview(text, maxLines - 4, maxBytes - markerBytes - 4)
return bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -116,6 +185,21 @@ export const layer = Layer.effect(
const global = yield* Global.Service
const config = yield* Effect.serviceOption(Config.Service)
const directory = path.join(global.data, MANAGED_DIRECTORY)
const metadataPath = (id: string) => path.join(directory, `${id}.json`)
const contentPath = (id: string) => path.join(directory, `${id}.txt`)
const load = Effect.fn("ToolOutputStore.load")(function* (resourceUri: string) {
const id = idFromUri(resourceUri)
if (!id) return yield* Effect.fail(new InvalidResourceError({ uri: resourceUri }))
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.orDie)
if (!text) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
const record = yield* Effect.sync(() => JSON.parse(text)).pipe(Effect.catch(() => Effect.void))
if (!validRecord(record, id)) return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
if (!info || info.type !== "File" || Number(info.size) !== record.size)
return yield* Effect.fail(new ResourceNotFoundError({ uri: resourceUri }))
return record
})
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
@@ -128,10 +212,32 @@ export const layer = Layer.effect(
})
const write = Effect.fn("ToolOutputStore.write")(function* (input: WriteInput) {
const file = path.join(directory, `tool_${Identifier.ascending()}`)
const id = Identifier.ascending()
const resourceUri = uri(id)
const size = Buffer.byteLength(input.content, "utf-8")
const record: Record = {
version: 1,
id,
uri: resourceUri,
sessionID: input.sessionID,
toolCallID: input.toolCallID,
mime: input.mime ?? "text/plain",
...(input.name === undefined ? {} : { name: input.name }),
size,
created: Date.now(),
}
yield* fs.ensureDir(directory).pipe(Effect.orDie)
yield* fs.writeFileString(file, input.content, { flag: "wx" }).pipe(Effect.orDie)
return file
yield* fs.writeFileString(contentPath(id), input.content, { flag: "wx" }).pipe(Effect.orDie)
yield* fs.writeFileString(metadataPath(id), JSON.stringify(record), { flag: "wx" }).pipe(
Effect.onError(() => fs.remove(contentPath(id)).pipe(Effect.catch(() => Effect.void))),
Effect.orDie,
)
return new Resource({
uri: resourceUri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size,
})
})
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
@@ -141,73 +247,105 @@ export const layer = Layer.effect(
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
}
const outputPath = yield* write(input)
const marker = `... output truncated; full content saved to ${outputPath} ...`
const resource = yield* write(input)
const bounded = preview(input.content, maxLines, maxBytes)
const marker = `... output truncated; full content available as ${resource.uri} ...`
return {
content: boundedPreview(input.content, marker, maxLines, maxBytes),
content: bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`,
truncated: true,
outputPath,
resource,
} as const
})
const bound = Effect.fn("ToolOutputStore.bound")(function* (input: BoundInput) {
const text = input.output.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n\n")
const structured = yield* Effect.sync(() => JSON.stringify(input.output.structured)).pipe(
Effect.catch(() => Effect.succeed(String(input.output.structured))),
)
const content = text || input.output.content.length > 0 ? text : structured
if (content === undefined) return { output: input.output, outputPaths: [] }
const truncated = yield* truncate({
sessionID: input.sessionID,
toolCallID: input.toolCallID,
content,
mime: "text/plain",
name: `${input.toolCallID}.txt`,
}).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("Unable to retain complete tool output", cause).pipe(
Effect.andThen(limits()),
Effect.map(({ maxLines, maxBytes }) => {
const marker = "... output truncated; omitted content could not be retained ..."
return {
content: boundedPreview(content, marker, maxLines, maxBytes),
truncated: true as const,
}
}),
),
),
)
if (!truncated.truncated) return { output: input.output, outputPaths: [] }
return {
output: {
structured: input.output.structured,
content: [
{ type: "text" as const, text: truncated.content },
...input.output.content.filter((item) => item.type === "file"),
],
},
outputPaths: "outputPath" in truncated ? [truncated.outputPath] : [],
const read = Effect.fn("ToolOutputStore.read")(function* (input: ReadInput) {
const record = yield* load(input.uri)
if (record.sessionID !== input.sessionID) {
return yield* Effect.fail(new AccessDeniedError({ uri: input.uri, sessionID: input.sessionID }))
}
const offset = Math.max(0, Math.min(input.offset ?? 0, record.size))
const limit = Math.max(1, Math.min(input.limit ?? MAX_READ_BYTES, MAX_READ_BYTES))
const bytes = yield* Effect.scoped(
Effect.gen(function* () {
const file = yield* fs.open(contentPath(record.id), { flag: "r" }).pipe(Effect.orDie)
yield* file.seek(offset, "start")
const chunk = yield* file.readAlloc(Math.min(limit + 3, record.size - offset)).pipe(Effect.orDie)
return Option.getOrElse(chunk, () => new Uint8Array())
}),
)
let start = 0
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++
let end = Math.min(start + limit, bytes.length)
while (end > start && end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--
if (end === start && end < bytes.length) {
end = Math.min(start + limit, bytes.length)
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end++
}
const absoluteStart = offset + start
const absoluteEnd = offset + end
const truncated = absoluteEnd < record.size
return new Page({
resource: new Resource({
uri: record.uri,
mime: record.mime,
...(record.name === undefined ? {} : { name: record.name }),
size: record.size,
}),
content: Buffer.from(bytes.subarray(start, end)).toString("utf-8"),
offset: absoluteStart,
truncated,
...(truncated ? { next: absoluteEnd } : {}),
})
})
const cleanup = Effect.fn("ToolOutputStore.cleanup")(function* () {
const entries = yield* fs.readDirectory(directory).pipe(Effect.catch(() => Effect.succeed([])))
const cutoff = Date.now() - Duration.toMillis(RETENTION)
for (const entry of entries) {
if (!entry.startsWith("tool_")) continue
const file = path.join(directory, entry)
const info = yield* fs.stat(file).pipe(Effect.catch(() => Effect.void))
const modified = info?.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
const ids = new Set(
entries.flatMap((entry) => {
const match = entry.match(/^([0-9a-f]{12}[0-9A-Za-z]{14})\.(?:json|txt)$/)
return match ? [match[1]] : []
}),
)
const removeIfPresent = (target: string) =>
fs.existsSafe(target).pipe(Effect.flatMap((exists) => (exists ? fs.remove(target) : Effect.void)))
const removePair = (id: string) =>
Effect.gen(function* () {
yield* removeIfPresent(contentPath(id))
yield* removeIfPresent(metadataPath(id))
}).pipe(Effect.catch(() => Effect.void))
for (const id of ids) {
const text = yield* fs.readFileStringSafe(metadataPath(id)).pipe(Effect.catch(() => Effect.succeed(undefined)))
const contentExists = yield* fs.existsSafe(contentPath(id))
if (!text) {
if (!contentExists) continue
const info = yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void))
const modified = info
? info.mtime.pipe(
Option.map((date) => date.getTime()),
Option.getOrElse(() => 0),
)
: 0
if (modified < cutoff) yield* removePair(id)
continue
}
const record = yield* Effect.try({
try: () => JSON.parse(text),
catch: () => new globalThis.Error("Invalid metadata"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
const info = contentExists ? yield* fs.stat(contentPath(id)).pipe(Effect.catch(() => Effect.void)) : undefined
if (
!contentExists ||
!validRecord(record, id) ||
!info ||
info.type !== "File" ||
Number(info.size) !== record.size ||
record.created < cutoff
)
if (modified !== undefined && modified < cutoff) yield* fs.remove(file).pipe(Effect.catch(() => Effect.void))
yield* removePair(id)
}
})
return Service.of({ limits, write, truncate, bound, cleanup })
return Service.of({ limits, write, truncate, read, cleanup })
}),
)
+2 -3
View File
@@ -41,7 +41,7 @@ const Success = Schema.Struct({
truncated: Schema.Boolean,
stdoutTruncated: Schema.Boolean.pipe(Schema.optional),
stderrTruncated: Schema.Boolean.pipe(Schema.optional),
outputPath: Schema.String.pipe(Schema.optional),
resource: ToolOutputStore.Resource.pipe(Schema.optional),
timedOut: Schema.Boolean.pipe(Schema.optional),
warnings: Schema.Array(Schema.String).pipe(Schema.optional),
})
@@ -121,7 +121,6 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const plan = yield* mutation.resolve({ path: parameters.workdir ?? ".", kind: "directory" })
@@ -188,7 +187,7 @@ export const layer = Layer.effectDiscard(
...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
...(result.stderrTruncated ? { stderrTruncated: true } : {}),
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
? { outputPath: truncated.outputPath }
? { resource: truncated.resource }
: {}),
}
}).pipe(
+1 -1
View File
@@ -53,7 +53,7 @@ export const toModelOutput = (output: Success) => {
const definition = Tool.make({
description:
"Search file contents by regular expression within the active Location, a named project reference, or an absolute managed tool-output file. Use a path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise file resources, line numbers, and bounded line previews.",
"Search file contents by regular expression within the active Location or a named project reference. Use a relative path to narrow the search, include to filter files by glob, and limit to bound the match count. Returns concise relative file resources, line numbers, and bounded line previews.",
parameters: Parameters,
success: LocationSearch.GrepResult,
toModelOutput: ({ output }) => [toolText({ type: "text", text: toModelOutput(output) })],
+108 -128
View File
@@ -1,46 +1,34 @@
export * as ReadTool from "./read"
import { Tool, ToolFailure } from "@opencode-ai/llm"
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Cause, Effect, Layer, Schema } from "effect"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { Config } from "../config"
import { FileSystem } from "../filesystem"
import { NonNegativeInt, PositiveInt } from "../schema"
import { PermissionV2 } from "../permission"
import { ToolOutputStore } from "../tool-output-store"
import { FSUtil } from "../fs-util"
import { ToolRegistry } from "./registry"
export const name = "read"
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
const MAX_IMAGE_WIDTH = 2_000
const MAX_IMAGE_HEIGHT = 2_000
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
class ImageDecodeError extends Error {
constructor(readonly resource: string) {
super(`Image could not be decoded: ${resource}`)
this.name = "ImageDecodeError"
}
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
const imageMime = (bytes: Uint8Array, fallback: string) => {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
return fallback
}
class ImageSizeError extends Error {
constructor(
readonly resource: string,
readonly width: number,
readonly height: number,
readonly bytes: number,
readonly maxWidth: number,
readonly maxHeight: number,
readonly maxBytes: number,
) {
super(
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
)
this.name = "ImageSizeError"
}
}
class ImageSizeError extends Error {}
const LocationInput = Schema.Struct({
...FileSystem.ReadInput.fields,
offset: FileSystem.ListPageInput.fields.offset.annotate({
@@ -50,18 +38,19 @@ const LocationInput = Schema.Struct({
description: "The maximum number of directory entries or text lines to read",
}),
})
const Input = LocationInput
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage])
const ResourceInput = Schema.Struct({
resource: Schema.String,
offset: NonNegativeInt.pipe(Schema.optional),
limit: PositiveInt.check(Schema.isLessThanOrEqualTo(ToolOutputStore.MAX_READ_BYTES)).pipe(Schema.optional),
})
const Input = Schema.Union([LocationInput, ResourceInput])
const Success = Schema.Union([FileSystem.Content, FileSystem.TextPage, FileSystem.ListPage, ToolOutputStore.Page])
const definition = Tool.make({
description:
"Read a text file or supported image, page through a large UTF-8 text file by line offset, or list a directory page relative to the current location. Absolute paths are accepted only for managed tool-output files.",
"Read a text file or supported image, page through a large UTF-8 text file by line offset, list a directory page relative to the current location, or page through a managed tool-output resource by opaque URI.",
parameters: Input,
success: Success,
toStructuredOutput: (output) =>
"type" in output && output.type === "binary" && SUPPORTED_IMAGE_MIMES.has(output.mime)
? { type: "media", mime: output.mime }
: output,
toModelOutput: ({ parameters, output }) => {
if (!("type" in output) || output.type !== "binary" || !SUPPORTED_IMAGE_MIMES.has(output.mime)) return []
return [
@@ -70,7 +59,7 @@ const definition = Tool.make({
type: "file",
source: { type: "data", data: output.content },
mime: output.mime,
name: parameters.path,
...(parameters && "path" in parameters ? { name: parameters.path } : {}),
},
]
},
@@ -80,9 +69,11 @@ export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const filesystem = yield* FileSystem.Service
const resources = yield* ToolOutputStore.Service
const config = yield* Config.Service
const loadPhoton = yield* Effect.cached(
Effect.sync(() => {
const photonWasm = fileURLToPath(import.meta.resolve("@silvia-odwyer/photon-node/photon_rs_bg.wasm"))
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
@@ -94,6 +85,8 @@ export const layer = Layer.effectDiscard(
execute: ({ parameters, sessionID, assertPermission }) => {
const input = parameters
return Effect.gen(function* () {
if ("resource" in input)
return yield* resources.read({ sessionID, uri: input.resource, offset: input.offset, limit: input.limit })
const resolved = yield* filesystem.resolveReadPath(input)
if (resolved.type === "directory") {
const { offset, limit } = input
@@ -117,104 +110,93 @@ export const layer = Layer.effectDiscard(
const final = yield* filesystem.resolveReadPath(input)
if (final.type !== "file" || final.target.resource !== target.resource || final.target.real !== target.real)
return yield* Effect.die(new Error("File changed after permission approval"))
const content = yield* filesystem.readToolResolved(final.target, {
offset: input.offset,
limit: input.limit,
})
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
const mime = content.mime
const base64 = content.content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
const sample = yield* filesystem.readSampleResolved(final.target, FileSystem.READ_SAMPLE_BYTES)
const mime = imageMime(sample, FSUtil.mimeType(final.target.real))
if (!SUPPORTED_IMAGE_MIMES.has(mime)) {
if (FileSystem.isBinary(final.target.resource, sample))
return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource))
if (
final.target.size > FileSystem.MAX_READ_BYTES ||
input.offset !== undefined ||
input.limit !== undefined
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: () => new ImageDecodeError(final.target.resource),
})
try {
const width = decoded.get_width()
const height = decoded.get_height()
const bytes = Buffer.byteLength(base64, "utf-8")
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
if (!limits.autoResize)
return yield* Effect.die(
new ImageSizeError(
final.target.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
),
)
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height)
? acc
: [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
try {
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
} finally {
resized.free()
}
}
return yield* filesystem.readTextPageResolved(final.target, { offset: input.offset, limit: input.limit })
return yield* filesystem.readResolved(final.target, FileSystem.MAX_READ_BYTES)
}
const content = yield* filesystem.readResolved(final.target)
if (content.type !== "binary") return content
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const limits = {
autoResize: image.auto_resize ?? true,
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
}
const photon = yield* loadPhoton
const decoded = yield* Effect.sync(() =>
photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
)
try {
const width = decoded.get_width()
const height = decoded.get_height()
if (
width <= limits.maxWidth &&
height <= limits.maxHeight &&
Buffer.byteLength(content.content, "utf8") <= limits.maxBase64Bytes
)
return new FileSystem.BinaryContent({ ...content, mime })
if (!limits.autoResize)
return yield* Effect.die(
new ImageSizeError(
final.target.resource,
width,
height,
bytes,
limits.maxWidth,
limits.maxHeight,
limits.maxBase64Bytes,
`Image ${width}x${height} with base64 size ${Buffer.byteLength(content.content, "utf8")} exceeds configured limits ${limits.maxWidth}x${limits.maxHeight}/${limits.maxBase64Bytes} bytes`,
),
)
} finally {
decoded.free()
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
const previous = acc.at(-1) ?? {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
const next =
acc.length === 0
? previous
: {
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
}
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
}, [])
for (const size of sizes) {
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
const candidate = [
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
...JPEG_QUALITIES.map((quality) => ({
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
mime: "image/jpeg",
})),
].find((item) => Buffer.byteLength(item.content, "utf8") <= limits.maxBase64Bytes)
resized.free()
if (candidate)
return new FileSystem.BinaryContent({
type: "binary",
content: candidate.content,
encoding: "base64",
mime: candidate.mime,
})
}
return yield* Effect.die(
new ImageSizeError(
`Image ${width}x${height} with base64 size ${Buffer.byteLength(content.content, "utf8")} exceeds configured limits and could not be resized below ${limits.maxWidth}x${limits.maxHeight}/${limits.maxBase64Bytes} bytes`,
),
)
} finally {
decoded.free()
}
if (content.type === "binary")
return yield* Effect.die(new FileSystem.BinaryFileError(final.target.resource))
return content
}).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
@@ -222,11 +204,9 @@ export const layer = Layer.effectDiscard(
const message =
error instanceof FileSystem.BinaryFileError ||
error instanceof FileSystem.ReadLimitError ||
error instanceof FileSystem.MediaIngestLimitError ||
error instanceof ImageDecodeError ||
error instanceof ImageSizeError
? error.message
: `Unable to read ${input.path}`
: `Unable to read ${"resource" in input ? input.resource : input.path}`
return yield* new ToolFailure({ message, error })
}),
),
@@ -239,6 +219,6 @@ export const layer = Layer.effectDiscard(
export const locationLayer = layer.pipe(
Layer.provideMerge(ToolRegistry.defaultLayer),
Layer.provideMerge(FileSystem.locationLayer),
Layer.provideMerge(Config.locationLayer),
Layer.provideMerge(PermissionV2.locationLayer),
Layer.provideMerge(ToolOutputStore.defaultLayer),
)
+9 -41
View File
@@ -18,7 +18,6 @@ import { State } from "../state"
import { SessionSchema } from "../session/schema"
import type { SessionV2 } from "../session"
import { ApplicationTools } from "./application-tools"
import { ToolOutputStore } from "../tool-output-store"
import { AgentV2 } from "../agent"
export type ExecuteInput = {
@@ -58,7 +57,6 @@ export type Entry<
readonly execute?: (
input: AuthorizeInput<Schema.Schema.Type<Parameters>>,
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
readonly outputPaths?: (output: Schema.Schema.Type<Success>) => ReadonlyArray<string>
}
type Data = {
@@ -80,11 +78,7 @@ export interface Interface {
readonly contribute: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
readonly definitions: () => Effect.Effect<ReadonlyArray<ReturnType<typeof Tool.toDefinitions>[number]>>
readonly execute: (input: ExecuteInput) => Effect.Effect<ToolResultValue>
readonly settle: (input: ExecuteInput) => Effect.Effect<Settlement>
}
export interface Settlement extends ToolSettlement {
readonly outputPaths?: ReadonlyArray<string>
readonly settle: (input: ExecuteInput) => Effect.Effect<ToolSettlement>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@@ -96,7 +90,6 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const permission = yield* PermissionV2.Service
const applications = yield* ApplicationTools.Service
const resources = yield* ToolOutputStore.Service
const state = State.create<Data, Editor>({
initial: () => ({ entries: new Map() }),
editor: (draft) => ({
@@ -169,16 +162,12 @@ export const layer = Layer.effect(
),
),
),
Effect.map((value): Settlement => {
const settled = (() => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
})()
const retained = entry.outputPaths?.(value) ?? []
return retained.length > 0 ? { ...settled, outputPaths: retained } : settled
Effect.map((value): ToolSettlement => {
if (entry.tool._legacyResult && ToolResult.is(value))
return { result: value, output: ToolOutput.fromResultValue(value) }
const output = entry.tool._project(parameters, input.call.id, value)
const result = ToolOutput.toResultValue(output)
return result.type === "error" ? { result } : { result, output }
}),
)
}),
@@ -188,25 +177,7 @@ export const layer = Layer.effect(
)
})
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const settled = yield* restore(settleEntry(entry(input.call.name), input))
if (!settled.output) return settled
const bounded = yield* resources.bound({
sessionID: input.sessionID,
toolCallID: input.call.id,
output: settled.output,
})
if (bounded.output === settled.output && bounded.outputPaths.length === 0) return settled
const retained = [...(settled.outputPaths ?? []), ...bounded.outputPaths]
const result = ToolOutput.toResultValue(bounded.output)
return result.type === "error"
? { result, outputPaths: retained }
: { result, output: bounded.output, outputPaths: retained }
}),
),
)
const settle = Effect.fn("ToolRegistry.settle")((input: ExecuteInput) => settleEntry(entry(input.call.name), input))
const execute = Effect.fn("ToolRegistry.execute")(function* (input: ExecuteInput) {
return (yield* settle(input)).result
})
@@ -224,7 +195,4 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(ApplicationTools.layer),
Layer.provide(ToolOutputStore.defaultLayer),
)
export const defaultLayer = layer.pipe(Layer.provide(ApplicationTools.layer))
+2 -3
View File
@@ -22,7 +22,7 @@ export const Success = Schema.Struct({
directory: Schema.String,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
resource: ToolOutputStore.Resource.pipe(Schema.optional),
})
export const description = [
@@ -73,7 +73,6 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const current = yield* skills.list()
@@ -99,7 +98,7 @@ export const layer = Layer.effectDiscard(
directory,
output: output.content,
truncated: output.truncated,
...(output.truncated ? { outputPath: output.outputPath } : {}),
...(output.truncated ? { resource: output.resource } : {}),
}
}).pipe(Effect.catchCause((cause) => Effect.fail(unableToLoad(parameters.name, Cause.squash(cause)))))
}),
+3 -4
View File
@@ -15,7 +15,7 @@ export const MAX_TIMEOUT_SECONDS = 120
export const description = `Fetch content from an HTTP or HTTPS URL and return it as text, markdown, or HTML. Markdown is the default.
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated and saved to a managed file that ordinary Read, Grep, and Bash tools can inspect.`
Use a more targeted tool when one is available. This tool is read-only. Large text results are truncated with an opaque managed resource URI for paging.`
const Timeout = Schema.Number.check(Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(MAX_TIMEOUT_SECONDS))
@@ -35,7 +35,7 @@ const Success = Schema.Struct({
format: Parameters.fields.format,
output: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
resource: ToolOutputStore.Resource.pipe(Schema.optional),
})
type Format = (typeof Parameters.Type)["format"]
@@ -141,7 +141,6 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) =>
Effect.gen(function* () {
const parsed = new URL(parameters.url)
@@ -179,7 +178,7 @@ export const layer = Layer.effectDiscard(
format: parameters.format,
output: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
...(truncated.truncated ? { resource: truncated.resource } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
+2 -3
View File
@@ -179,7 +179,7 @@ const Success = Schema.Struct({
provider: Provider,
text: Schema.String,
truncated: Schema.Boolean,
outputPath: Schema.String.pipe(Schema.optional),
resource: ToolOutputStore.Resource.pipe(Schema.optional),
})
const definition = Tool.make({
@@ -199,7 +199,6 @@ export const layer = Layer.effectDiscard(
yield* registry.contribute((editor) =>
editor.set(name, {
tool: definition,
outputPaths: (output) => (output.outputPath ? [output.outputPath] : []),
execute: ({ parameters, sessionID, call, assertPermission }) => {
const provider = selectProvider(sessionID, config, config.provider)
return Effect.gen(function* () {
@@ -240,7 +239,7 @@ export const layer = Layer.effectDiscard(
provider,
text: truncated.content,
truncated: truncated.truncated,
...(truncated.truncated ? { outputPath: truncated.outputPath } : {}),
...(truncated.truncated ? { resource: truncated.resource } : {}),
}
}).pipe(
Effect.catchCause((cause) =>
-5
View File
@@ -1,5 +0,0 @@
export * as Token from "./token"
const CHARS_PER_TOKEN = 4
export const estimate = (input: string) => Math.max(0, Math.round(input.length / CHARS_PER_TOKEN))
+5 -15
View File
@@ -6,7 +6,6 @@ import { ConfigMCPV1 } from "./mcp"
import { ConfigPermissionV1 } from "./permission"
import { ConfigProviderV1 } from "./provider"
import { ConfigProviderOptionsV1 } from "./provider-options"
import { ModelRequest } from "../../model-request"
const keys = new Set([
"logLevel",
@@ -56,6 +55,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
turns: info.compaction.tail_turns,
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
@@ -183,13 +183,6 @@ function migrateProvider(info: ConfigProviderV1.Info) {
}
function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: string) {
const packageID = info.provider?.npm ?? packageName
const lowerer = ConfigProviderOptionsV1.get(packageID)
const ingest = (options: Readonly<Record<string, unknown>>) => {
const request = ModelRequest.normalizeAiSdkOptions(packageID, options)
return { ...lowerer.request(request.body), ...request.generation, ...request.options }
}
const request = info.options && ingest(info.options)
const costs = info.cost && [
{
input: info.cost.input,
@@ -211,6 +204,7 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
: undefined
const lowerer = ConfigProviderOptionsV1.get(info.provider?.npm ?? packageName)
return {
family: info.family,
name: info.name,
@@ -226,16 +220,12 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type, packageName?: st
? undefined
: { id: info.id },
capabilities,
request: (info.headers || request) && {
request: (info.headers || info.options) && {
headers: info.headers,
body: request,
body: info.options && lowerer.request(info.options),
},
variants:
info.variants &&
Object.entries(info.variants).map(([id, options]) => ({
id,
body: ingest(options),
})),
info.variants && Object.entries(info.variants).map(([id, options]) => ({ id, body: lowerer.request(options) })),
cost: costs,
disabled: info.status === "deprecated" ? true : undefined,
limit: info.limit && {
+1 -6
View File
@@ -4,7 +4,6 @@ import { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { PermissionV2 } from "@opencode-ai/core/permission"
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 { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@@ -12,11 +11,7 @@ const permission = Layer.mock(PermissionV2.Service, {
assert: () => Effect.void,
})
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
const it = testEffect(Layer.mergeAll(applications, registry))
const sessionID = SessionV2.ID.make("ses_application_tool")
-32
View File
@@ -219,16 +219,12 @@ describe("CatalogV2", () => {
model.request.headers.shared = "model"
model.request.body.model = true
model.request.body.request = true
const options = (model.request.options ??= {})
options.shared = "model"
options.model = true
})
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.request.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.request.body).toEqual({ provider: true, model: true, request: true })
expect(model.request.options).toEqual({ shared: "model", model: true })
}),
)
@@ -285,34 +281,6 @@ describe("CatalogV2", () => {
}),
)
it.effect("ignores a configured default on a disabled provider", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const disabledProvider = ProviderV2.ID.make("disabled")
const enabledProvider = ProviderV2.ID.make("enabled")
const disabledModel = ModelV2.ID.make("configured")
const fallbackModel = ModelV2.ID.make("fallback")
const transform = yield* catalog.transform()
yield* transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.enabled = false
})
catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(enabledProvider, fallbackModel, () => {})
catalog.model.default.set(disabledProvider, disabledModel)
})
expect(Option.getOrUndefined(yield* catalog.model.default())).toMatchObject({
providerID: enabledProvider,
id: fallbackModel,
})
}),
)
it.effect("small model prefers small keyword candidates before cost scoring", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
+5 -53
View File
@@ -52,20 +52,6 @@ const provider = {
}
describe("Config", () => {
it.effect("returns the latest defined scalar from priority-ordered documents", () =>
Effect.sync(() => {
const entries = [
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5" }) }),
new Config.Directory({ type: "directory", path: AbsolutePath.make("/skills") }),
new Config.Document({ type: "document", info: new Config.Info({}) }),
new Config.Document({ type: "document", info: new Config.Info({ model: "openrouter/openai/gpt-5.5" }) }),
]
expect(Config.latest(entries, "model")).toBe("openrouter/openai/gpt-5.5")
expect(Config.latest(entries, "default_agent")).toBeUndefined()
}),
)
it.effect("detects v1 configuration from any v1-only top-level key", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
@@ -318,7 +304,7 @@ describe("Config", () => {
compaction: {
auto: true,
prune: false,
keep: { tokens: 2000 },
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
},
skills: ["./skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
@@ -403,7 +389,7 @@ describe("Config", () => {
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: false,
keep: { tokens: 2000 },
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.skills).toEqual([
@@ -479,22 +465,7 @@ describe("Config", () => {
npm: "@ai-sdk/openai",
options: { apiKey: "secret", organization: "org" },
models: {
model: {
options: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
variants: { high: { reasoningEffort: "high", reasoningSummary: "auto" } },
},
},
},
anthropic: {
npm: "@ai-sdk/anthropic",
models: {
model: {
options: {
effort: "high",
taskBudget: 4096,
metadata: { userId: "user-1" },
},
},
model: { options: { reasoningEffort: "high", serviceTier: "priority" } },
},
},
},
@@ -552,31 +523,12 @@ describe("Config", () => {
expect(documents[0]?.info.providers?.openai).toMatchObject({
api: { settings: {} },
request: { headers: { Authorization: "Bearer secret", "OpenAI-Organization": "org" } },
models: {
model: {
request: {
body: { temperature: 0.3, reasoningEffort: "high", serviceTier: "priority" },
},
variants: [{ id: "high", body: { reasoningEffort: "high", reasoningSummary: "auto" } }],
},
},
})
expect(documents[0]?.info.providers?.anthropic).toMatchObject({
models: {
model: {
request: {
body: {
output_config: { effort: "high", task_budget: 4096 },
metadata: { user_id: "user-1" },
},
},
},
},
models: { model: { request: { body: { reasoning_effort: "high", service_tier: "priority" } } } },
})
expect(documents[0]?.info.compaction).toEqual({
auto: true,
prune: undefined,
keep: { tokens: 2000 },
keep: { turns: 3, tokens: 2000 },
buffer: 10000,
})
expect(documents[0]?.info.mcp).toMatchObject({
+1 -119
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Effect, Option, Schema } from "effect"
import { Effect, Schema } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider"
@@ -18,118 +18,6 @@ function request(headers: Record<string, string>, variant?: string) {
const decode = Schema.decodeUnknownSync(Config.Info)
describe("ConfigProviderPlugin.Plugin", () => {
it.effect("partitions existing model variant bodies without changing config shape", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
models: {
"alpha-gpt-next": {
variants: [
{
id: "high",
body: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
],
},
},
},
},
}),
}),
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.variants).toMatchObject([
{
id: "high",
body: {},
options: {
reasoningEffort: "high",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
},
},
])
}),
)
it.effect("uses the effective provider package across layered config", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.opencode
const modelID = ModelV2.ID.make("alpha-gpt-next")
const config = Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
api: { type: "aisdk", package: "@ai-sdk/openai", url: "https://opencode.test/v1" },
},
},
}),
}),
new Config.Document({
type: "document",
info: decode({
providers: {
opencode: {
models: {
"alpha-gpt-next": {
variants: [{ id: "high", body: { reasoningEffort: "high" } }],
},
},
},
},
}),
}),
]),
})
yield* plugin.add({
...ConfigProviderPlugin.Plugin,
effect: ConfigProviderPlugin.Plugin.effect.pipe(
Effect.provideService(Config.Service, config),
Effect.provideService(Catalog.Service, catalog),
),
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.variants[0]).toMatchObject({
id: "high",
body: {},
options: { reasoningEffort: "high" },
})
}),
)
it.effect("loads configured providers and applies later model overrides", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -142,7 +30,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
new Config.Document({
type: "document",
info: decode({
model: "custom/first",
providers: {
custom: {
name: "Configured",
@@ -172,15 +59,11 @@ describe("ConfigProviderPlugin.Plugin", () => {
new Config.Document({
type: "document",
info: decode({
model: "custom/default",
providers: {
custom: {
api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" },
request: request({ last: "last", shared: "last" }),
models: {
default: {
name: "Default",
},
chat: {
api: { id: "api-chat" },
name: "Last",
@@ -223,7 +106,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
const provider = yield* catalog.provider.get(providerID)
const model = yield* catalog.model.get(providerID, modelID)
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default"))
expect(provider.name).toBe("Renamed")
expect(provider.env).toEqual(["CUSTOM_API_KEY"])
expect(provider.enabled).toEqual({ via: "custom", data: {} })
@@ -6,7 +6,6 @@ import { Effect, Exit, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ProcessOutput } from "@opencode-ai/core/process-output"
import { testEffect } from "../lib/effect"
const live = CrossSpawnSpawner.defaultLayer
@@ -97,28 +96,6 @@ describe("cross-spawn spawner", () => {
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
}),
)
fx.live(
"reports direct exit while a descendant holds stdout open",
Effect.gen(function* () {
if (process.platform === "win32") return
const handle = yield* ChildProcess.make("sh", ["-c", "sleep 30 &"])
expect(yield* handle.exitCode.pipe(Effect.timeout("1 second"))).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.live(
"terminates the process when an owned drain fails before exit",
Effect.gen(function* () {
const handle = yield* js("setInterval(() => {}, 10_000)")
const pid = Number(handle.pid)
const exit = yield* ProcessOutput.drain(handle, [Effect.fail("drain failed")]).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* Effect.promise(() => gone(pid))).toBe(true)
}),
5_000,
)
})
describe("cwd option", () => {
+1 -1
View File
@@ -99,7 +99,7 @@ describe("Git worktrees", () => {
expect(linked?.directory).toBe(AbsolutePath.make(yield* Effect.promise(() => fs.realpath(worktree))))
expect(linked?.store).toBe(repo.store)
if (!linked) throw new Error("Linked worktree not found")
yield* git.worktreeRemove({ repo: linked, directory: worktree, force: false })
yield* git.worktreeRemove({ repo: linked, directory: worktree })
expect((yield* git.worktreeList(repo)).some((entry) => entry.endsWith("-git-worktree"))).toBe(false)
}),
)
+2 -194
View File
@@ -2,14 +2,13 @@ import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe, expect, test } from "bun:test"
import { Effect, Exit, Fiber, Layer, Schema } from "effect"
import { Effect, Exit, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { FileSystem } from "@opencode-ai/core/filesystem"
import { Ripgrep } from "@opencode-ai/core/filesystem/ripgrep"
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Repository } from "@opencode-ai/core/repository"
import { Global } from "@opencode-ai/core/global"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
@@ -23,12 +22,7 @@ const inertReferences = ProjectReference.Service.of({
containsManagedPath: () => Effect.succeed(false),
})
function provide(
directory: string,
references = inertReferences,
filesystem = FSUtil.defaultLayer,
data = Global.Path.data,
) {
function provide(directory: string, references = inertReferences, filesystem = FSUtil.defaultLayer) {
return Effect.provide(
FileSystem.layer.pipe(
Layer.provide(
@@ -37,7 +31,6 @@ function provide(
Ripgrep.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, references),
Global.layerWith({ data }),
),
),
),
@@ -52,27 +45,6 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("FileSystem", () => {
it.live("accepts generated managed output paths and rejects other absolute paths", () =>
withTmp((directory) => {
const worktree = directory
const data = path.join(directory, "data")
return Effect.gen(function* () {
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
const unrelated = path.join(directory, "secret.txt")
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "failure here"))
yield* Effect.promise(() => fs.writeFile(unrelated, "secret"))
const service = yield* FileSystem.Service
expect(yield* service.read({ path: output })).toMatchObject({ type: "text", content: "failure here" })
expect((yield* service.resolveRoot({ path: output })).real).toBe(output)
expect(yield* Effect.exit(service.read({ path: unrelated }))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.exit(service.read({ path: managed }))).toMatchObject({ _tag: "Failure" })
}).pipe(provide(worktree, inertReferences, FSUtil.defaultLayer, data))
}),
)
it.live("reads text and binary files", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -97,24 +69,6 @@ describe("FileSystem", () => {
),
)
it.live("revalidates file identity before sampled classification", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "image.png")
yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])))
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("image.png") })
yield* Effect.promise(() => fs.rename(file, path.join(directory, "original.png")))
yield* Effect.promise(() => fs.writeFile(file, Buffer.from([0xff, 0xd8, 0xff])))
expect(
Exit.isFailure(yield* service.readSampleResolved(target, FileSystem.READ_SAMPLE_BYTES).pipe(Effect.exit)),
).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("pages large UTF-8 text files by line with continuation", () =>
withTmp((directory) =>
Effect.gen(function* () {
@@ -150,152 +104,6 @@ describe("FileSystem", () => {
),
)
it.live("rejects paged text when a late NUL appears after the requested page", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "late-binary.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0])]),
),
)
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("late-binary.txt") })
expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects paged text when invalid UTF-8 appears near EOF", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "invalid-utf8.txt")
yield* Effect.promise(() =>
fs.writeFile(
file,
Buffer.concat([Buffer.from("first\nsecond\n"), Buffer.alloc(80_000, 0x61), Buffer.from([0xc3, 0x28])]),
),
)
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("invalid-utf8.txt") })
expect(Exit.isFailure(yield* service.readToolResolved(target, { limit: 1 }).pipe(Effect.exit))).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects PDFs for direct, large, and paged reads", () =>
withTmp((directory) =>
Effect.gen(function* () {
const small = path.join(directory, "small.pdf")
const large = path.join(directory, "large.pdf")
yield* Effect.promise(() => fs.writeFile(small, "%PDF-1.7\nsmall"))
yield* Effect.promise(() =>
fs.writeFile(large, Buffer.concat([Buffer.from("%PDF-1.7\n"), Buffer.alloc(80_000)])),
)
const service = yield* FileSystem.Service
const smallTarget = yield* service.resolveRead({ path: RelativePath.make("small.pdf") })
const largeTarget = yield* service.resolveRead({ path: RelativePath.make("large.pdf") })
expect(Exit.isFailure(yield* service.readToolResolved(smallTarget).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* service.readToolResolved(largeTarget).pipe(Effect.exit))).toBe(true)
expect(Exit.isFailure(yield* service.readToolResolved(largeTarget, { limit: 1 }).pipe(Effect.exit))).toBe(true)
}).pipe(provide(directory)),
),
)
it.live("rejects signature-bearing media beyond the ingestion cap before loading", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "huge.png")
yield* Effect.promise(async () => {
const handle = await fs.open(file, "w")
try {
await handle.write(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), 0, 8, 0)
await handle.truncate(FileSystem.MAX_MEDIA_INGEST_BYTES + 1)
} finally {
await handle.close()
}
})
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("huge.png") })
const exit = yield* service.readToolResolved(target).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(String(exit.cause)).toContain("Media exceeds")
}).pipe(provide(directory)),
),
)
it.live("never mixes a sampled image with replacement-path content", () =>
withTmp((directory) =>
Effect.gen(function* () {
const file = path.join(directory, "race.png")
const moved = path.join(directory, "original.png")
const original = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(4 * 1024 * 1024, 0x11),
])
const replacement = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff]), Buffer.alloc(1024, 0x22)])
yield* Effect.promise(() => fs.writeFile(file, original))
const service = yield* FileSystem.Service
const target = yield* service.resolveRead({ path: RelativePath.make("race.png") })
const reading = yield* service.readToolResolved(target).pipe(Effect.forkChild)
yield* Effect.promise(async () => {
await fs.rename(file, moved)
await fs.writeFile(file, replacement)
})
const exit = yield* Fiber.join(reading).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
expect(exit.value).toMatchObject({ type: "binary", mime: "image/png" })
if (exit.value.type === "binary") expect(exit.value.content).toBe(original.toString("base64"))
}
}).pipe(provide(directory)),
),
)
it.live("closes validated descriptors after successful and failed reads", () =>
withTmp((directory) => {
let active = 0
const filesystem = Layer.effect(
FSUtil.Service,
Effect.gen(function* () {
const service = yield* FSUtil.Service
return FSUtil.Service.of({
...service,
open: (target, options) =>
Effect.acquireRelease(
service.open(target, options).pipe(Effect.tap(() => Effect.sync(() => active++))),
() => Effect.sync(() => active--),
),
})
}),
).pipe(Layer.provide(FSUtil.defaultLayer))
return Effect.gen(function* () {
const text = path.join(directory, "text.txt")
const binary = path.join(directory, "binary.pdf")
yield* Effect.promise(() => fs.writeFile(text, "hello"))
yield* Effect.promise(() => fs.writeFile(binary, "%PDF-1.7"))
const service = yield* FileSystem.Service
const before =
process.platform === "win32"
? undefined
: yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
for (let index = 0; index < 50; index++) {
yield* service.readToolResolved(yield* service.resolveRead({ path: RelativePath.make("text.txt") }))
yield* service
.readToolResolved(yield* service.resolveRead({ path: RelativePath.make("binary.pdf") }))
.pipe(Effect.exit)
}
expect(active).toBe(0)
if (before !== undefined) {
const after = yield* Effect.promise(() => fs.readdir("/dev/fd").then((entries) => entries.length))
expect(after).toBeLessThanOrEqual(before + 2)
}
yield* Effect.promise(() => fs.rename(text, text + ".moved"))
yield* Effect.promise(() => fs.rename(binary, binary + ".moved"))
}).pipe(provide(directory, inertReferences, filesystem))
}),
)
it.live("lists direct children with relative paths and resolved URIs", () =>
withTmp((directory) =>
Effect.gen(function* () {
+1 -18
View File
@@ -11,21 +11,19 @@ import { Ripgrep as FileSystemRipgrep } from "@opencode-ai/core/filesystem/ripgr
import { ProjectReference } from "@opencode-ai/core/project-reference"
import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global"
import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location"
import { it } from "./lib/effect"
const inertReferences = references({})
function provide(directory: string, projectReferences = inertReferences, data = Global.Path.data) {
function provide(directory: string, projectReferences = inertReferences) {
const dependencies = Layer.mergeAll(
FSUtil.defaultLayer,
FileSystemRipgrep.defaultLayer,
AppProcess.defaultLayer,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Layer.succeed(ProjectReference.Service, projectReferences),
Global.layerWith({ data }),
)
const filesystem = FileSystem.layer.pipe(Layer.provide(dependencies))
const search = LocationSearch.layer.pipe(
@@ -45,21 +43,6 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("LocationSearch", () => {
it.live("greps an absolute managed tool-output file", () =>
withTmp((directory) => {
const data = path.join(directory, "data")
const managed = path.join(data, "tool-output")
const output = path.join(managed, "tool_123")
return Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(managed, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(output, "ok\nFAIL here\nok"))
const search = yield* LocationSearch.Service
const result = yield* search.grep({ pattern: "FAIL", path: output })
expect(result.items).toMatchObject([{ canonical: output, line: 2, lines: "FAIL here\n" }])
}).pipe(provide(directory, inertReferences, data))
}),
)
it.live("searches files in the active Location with structured bounded results", () =>
withTmp((directory) =>
Effect.gen(function* () {
-44
View File
@@ -1,44 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ModelRequest } from "@opencode-ai/core/model-request"
describe("ModelRequest", () => {
test("partitions AI SDK model and models.dev mode options", () => {
expect(
ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", {
maxOutputTokens: 4096,
temperature: 0.2,
reasoningEffort: "high",
serviceTier: "priority",
custom_extension: { enabled: true },
}),
).toEqual({
generation: { maxTokens: 4096, temperature: 0.2 },
options: { reasoningEffort: "high", serviceTier: "priority" },
body: { custom_extension: { enabled: true } },
})
})
test("keeps unknown-provider options as compatibility fields", () => {
expect(ModelRequest.normalizeAiSdkOptions(undefined, { temperature: 0.2, reasoningEffort: "high" })).toEqual({
generation: { temperature: 0.2 },
options: {},
body: { reasoningEffort: "high" },
})
})
test("does not consult inherited package-name properties", () => {
expect(ModelRequest.normalizeAiSdkOptions("__proto__", { reasoningEffort: "high" })).toEqual({
generation: {},
options: {},
body: { reasoningEffort: "high" },
})
})
test("normalizes models.dev wire aliases owned by native protocols", () => {
expect(ModelRequest.normalizeAiSdkOptions("@ai-sdk/openai", { service_tier: "priority" })).toEqual({
generation: {},
options: { serviceTier: "priority" },
body: {},
})
})
})
@@ -35,27 +35,6 @@ void mock.module("google-auth-library", () => ({
}))
describe("GoogleVertexPlugin", () => {
it.effect("ignores OpenAI-compatible providers that are not Google Vertex", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const transform = yield* catalog.transform()
yield* transform((catalog) =>
catalog.provider.update(ProviderV2.ID.opencode, (provider) => {
provider.api = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://opencode.ai/zen/v1",
}
}),
)
const provider = yield* catalog.provider.get(ProviderV2.ID.opencode)
expect(provider.request.body).toEqual({})
}),
)
it.effect("resolves project and location from env using legacy precedence", () =>
withEnv(
{
@@ -25,19 +25,6 @@ const waitForFile = (file: string) =>
}
})
const gone = (pid: number) =>
Effect.promise(async () => {
for (let i = 0; i < 200; i++) {
try {
process.kill(pid, 0)
} catch {
return true
}
await new Promise<void>((resolve) => setTimeout(resolve, 25))
}
return false
})
describe("AppProcess", () => {
describe("run", () => {
it.effect(
@@ -147,22 +134,6 @@ describe("AppProcess", () => {
)
if (process.platform !== "win32") {
it.live(
"captures large output and cleans a descendant holding stdout open",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const size = 8 * 1024 * 1024
const command = `sleep 30 & child=$!; printf "%s\\n" "$child"; dd if=/dev/zero bs=${size} count=1 2>/dev/null | tr '\\0' x`
const result = yield* svc.run(ChildProcess.make("sh", ["-c", command]))
const newline = result.stdout.indexOf(10)
const pid = Number(result.stdout.subarray(0, newline).toString("utf8"))
expect(result.stdout.length).toBe(newline + 1 + size)
expect(yield* gone(pid)).toBe(true)
}),
8_000,
)
it.live(
"timeout cleans up the scoped child process",
Effect.acquireUseRelease(
+2 -37
View File
@@ -124,48 +124,13 @@ describe("ProjectCopy", () => {
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("requires force to remove a dirty git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const created = yield* copy.create({
projectID: input.projectID,
strategy: "git_worktree",
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "copy",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* copy
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Git.WorktreeError)
if (error instanceof Git.WorktreeError) {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
it.live("adds a numeric suffix when a copy directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
@@ -195,7 +160,7 @@ describe("ProjectCopy", () => {
true,
)
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* copy.remove({ projectID: input.projectID, directory: created.directory })
}),
)
+5 -5
View File
@@ -65,7 +65,7 @@ describe("Project directories schemas", () => {
}),
)
it.effect("lists stored project directories newest first for the requested project", () =>
it.effect("lists stored project directories only for the requested project", () =>
Effect.gen(function* () {
const project = yield* ProjectV2.Service
const { db } = yield* Database.Service
@@ -82,16 +82,16 @@ describe("Project directories schemas", () => {
yield* db
.insert(ProjectDirectoryTable)
.values([
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root", time_created: 2 },
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
{ project_id: projectID, directory: AbsolutePath.make("/repo/z"), type: "root" },
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main" },
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main" },
])
.run()
.pipe(Effect.orDie)
expect(yield* project.directories({ projectID })).toEqual([
AbsolutePath.make("/repo/z"),
AbsolutePath.make("/repo/a"),
AbsolutePath.make("/repo/z"),
])
}),
)
+1 -140
View File
@@ -1,9 +1,6 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { AbsolutePath, Location, Model, OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { tmpdir } from "./fixture/tmpdir"
import { OpenCode, Session, Tool } from "@opencode-ai/core/public"
import { testEffect } from "./lib/effect"
const it = testEffect(OpenCode.layer)
@@ -20,12 +17,10 @@ describe("public native OpenCode API", () => {
"create",
"events",
"get",
"interrupt",
"list",
"message",
"messages",
"prompt",
"switchModel",
])
expect(Session.ID.create()).toStartWith("ses_")
expect(Session.MessageID.create()).toStartWith("msg_")
@@ -40,138 +35,4 @@ describe("public native OpenCode API", () => {
})
}),
)
it.effect("switches to an available model and variant", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_available")
const model = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
yield* opencode.sessions.switchModel({ sessionID, model })
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(model)
}),
),
),
)
it.effect("rejects missing and Location-disabled models without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
(dirs) => Effect.promise(() => Promise.all(dirs.map((dir) => dir[Symbol.asyncDispose]())).then(() => undefined)),
).pipe(
Effect.flatMap(([available, disabled]) =>
Effect.gen(function* () {
yield* writeProvider(available.path)
yield* writeProvider(disabled.path, true)
const opencode = yield* OpenCode.Service
const availableID = Session.ID.make("ses_public_switch_exact_available")
const disabledID = Session.ID.make("ses_public_switch_exact_disabled")
yield* opencode.sessions.create({
id: availableID,
location: Location.Ref.make({ directory: AbsolutePath.make(available.path) }),
})
yield* opencode.sessions.create({
id: disabledID,
location: Location.Ref.make({ directory: AbsolutePath.make(disabled.path) }),
})
yield* opencode.sessions.switchModel({ sessionID: availableID, model: ref({ variant: "default" }) })
const disabledError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref() })
.pipe(Effect.flip)
const missingError = yield* opencode.sessions
.switchModel({ sessionID: disabledID, model: ref({ id: "missing" }) })
.pipe(Effect.flip)
expect(disabledError).toBeInstanceOf(Session.ModelUnavailableError)
expect(missingError).toBeInstanceOf(Session.ModelUnavailableError)
expect((yield* opencode.sessions.get(availableID)).model).toEqual(ref({ variant: "default" }))
expect((yield* opencode.sessions.get(disabledID)).model).toBeUndefined()
}),
),
),
)
it.effect("rejects an unavailable variant without changing the Session", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
yield* writeProvider(tmp.path)
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_variant")
const selected = ref({ variant: "fast" })
yield* opencode.sessions.create({
id: sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make(tmp.path) }),
})
yield* opencode.sessions.switchModel({ sessionID, model: selected })
const error = yield* opencode.sessions
.switchModel({ sessionID, model: ref({ variant: "unknown" }) })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.VariantUnavailableError)
expect((yield* opencode.sessions.get(sessionID)).model).toEqual(selected)
}),
),
),
)
it.effect("preserves the typed not-found error for a missing Session", () =>
Effect.gen(function* () {
const opencode = yield* OpenCode.Service
const sessionID = Session.ID.make("ses_public_switch_missing")
const error = yield* opencode.sessions
.switchModel({
sessionID,
model: Schema.decodeUnknownSync(Model.Ref)({ id: "claude-sonnet-4-5", providerID: "anthropic" }),
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Session.NotFoundError)
if (error instanceof Session.NotFoundError) expect(error.sessionID).toBe(sessionID)
}),
)
})
const ref = (input: { id?: string; variant?: string } = {}) =>
Schema.decodeUnknownSync(Model.Ref)({
id: input.id ?? "chat",
providerID: "public-test",
variant: input.variant,
})
const writeProvider = (directory: string, disabled = false) =>
Effect.promise(() =>
fs.writeFile(
path.join(directory, "opencode.json"),
JSON.stringify({
providers: {
"public-test": {
name: "Public test",
api: { type: "native", settings: {} },
models: {
chat: {
disabled,
variants: [{ id: "fast" }],
},
},
},
},
}),
),
)
@@ -1,18 +0,0 @@
import { expect, test } from "bun:test"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
{ type: "text", text: "Image read successfully" },
{
type: "file",
source: { type: "data", data: base64 },
mime: "image/png",
name: "pixel.png",
},
])
expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
expect(serialized).not.toContain(base64)
})
+6 -53
View File
@@ -337,61 +337,14 @@ describe("SessionV2.create", () => {
expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell")
expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill")
expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent")
}),
)
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
const model = ModelV2.Ref.make({
id: ModelV2.ID.make("sonnet"),
providerID: ProviderV2.ID.anthropic,
variant: ModelV2.VariantID.make("high"),
})
yield* session.switchModel({ sessionID: created.id, model })
expect(yield* session.get(created.id)).toMatchObject({ model })
expect(
Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)),
).toMatchObject([{ event: { type: "session.next.model.switched", data: { model } } }])
}),
)
it.effect("persists repeated switches as distinct durable Session events", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const created = yield* session.create({ location })
const model = ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic })
yield* session.switchModel({ sessionID: created.id, model })
yield* session.switchModel({ sessionID: created.id, model })
const { db } = yield* Database.Service
expect(
yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
).toHaveLength(3)
expect(yield* session.get(created.id)).toMatchObject({ model })
}),
)
it.effect("rejects a model switch for a missing Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const missing = SessionV2.ID.make("ses_missing_model_switch")
expect(
yield* session
.switchModel({
sessionID: missing,
yield* unavailable(
session.switchModel({
sessionID: created.id,
model: ModelV2.Ref.make({ id: ModelV2.ID.make("sonnet"), providerID: ProviderV2.ID.anthropic }),
})
.pipe(
Effect.flip,
Effect.map((error) => error._tag),
),
).toBe("Session.NotFoundError")
}),
),
).toBe("switchModel")
}),
)
})
+4 -29
View File
@@ -3,7 +3,6 @@ import { DateTime, Effect, Layer, Schema } from "effect"
import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { ModelV2 } from "@opencode-ai/core/model"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
@@ -219,42 +218,18 @@ describe("SessionProjector", () => {
callID: "shell-1",
output: "/project",
})
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
messageID: SessionMessage.ID.create(),
timestamp: created,
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Delta, {
sessionID,
messageID: compactionID,
timestamp: created,
text: "partial",
})
expect(
yield* db
.select({ id: EventTable.id })
.from(EventTable)
.where(eq(EventTable.type, SessionEvent.Compaction.Delta.type))
.all()
.pipe(Effect.orDie),
).toEqual([])
expect(
yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.type, "compaction"))
.all()
.pipe(Effect.orDie),
).toEqual([])
yield* events.publish(SessionEvent.Compaction.Delta, { sessionID, timestamp: created, text: "partial" })
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
text: "summary",
recent: "recent context",
include: "msg-1",
})
const rows = yield* db
@@ -281,7 +256,7 @@ describe("SessionProjector", () => {
})
expect(messages.find((message) => message.type === "compaction")).toMatchObject({
summary: "summary",
recent: "recent context",
include: "msg-1",
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
+3 -60
View File
@@ -23,10 +23,7 @@ const events = EventV2.layer.pipe(Layer.provide(database))
const projector = SessionProjector.layer.pipe(Layer.provide(events), Layer.provide(database))
const store = SessionStore.layer.pipe(Layer.provide(database))
const executionCalls: SessionV2.ID[] = []
const interruptCalls: SessionV2.ID[] = []
const interruptSeqs: Array<number | undefined> = []
const wakeCalls: SessionV2.ID[] = []
const wakeSeqs: Array<number | undefined> = []
const execution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
@@ -34,15 +31,9 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
interrupt: (sessionID, seq) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptSeqs.push(seq)
}),
wake: (sessionID, seq) =>
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
wakeSeqs.push(seq)
}),
}),
)
@@ -104,15 +95,6 @@ const eventCount = (type: string) =>
),
)
const interruptEvent = Database.Service.use(({ db }) =>
db
.select()
.from(EventTable)
.where(eq(EventTable.type, "session.next.interrupt.requested.1"))
.get()
.pipe(Effect.orDie),
)
describe("SessionV2.prompt", () => {
it.effect("delegates execution continuation through SessionExecution", () =>
Effect.gen(function* () {
@@ -126,35 +108,6 @@ describe("SessionV2.prompt", () => {
}),
)
it.effect("delegates interruption through SessionExecution", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(sessionID)
expect(interruptCalls).toEqual([sessionID])
expect(interruptSeqs).toHaveLength(1)
expect(typeof interruptSeqs[0]).toBe("number")
expect(yield* eventCount("session.next.interrupt.requested.1")).toBe(1)
expect(yield* interruptEvent).toMatchObject({ aggregate_id: sessionID, seq: interruptSeqs[0] })
expect(yield* session.messages({ sessionID })).toEqual([])
}),
)
it.effect("delegates interruption without requiring a recorded Session", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
interruptCalls.length = 0
interruptSeqs.length = 0
yield* session.interrupt(SessionV2.ID.make("ses_missing"))
expect(interruptCalls).toEqual([SessionV2.ID.make("ses_missing")])
expect(interruptSeqs).toEqual([undefined])
}),
)
it.effect("durably admits one user message before transcript promotion", () =>
Effect.gen(function* () {
yield* setup
@@ -560,13 +513,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run by default" }) })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@@ -576,17 +527,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
const admitted = yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run explicitly" }),
resume: true,
})
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Run explicitly" }), resume: true })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([sessionID])
expect(wakeSeqs).toEqual([admitted.admittedSeq])
}),
)
@@ -596,13 +541,11 @@ describe("SessionV2.prompt", () => {
const session = yield* SessionV2.Service
executionCalls.length = 0
wakeCalls.length = 0
wakeSeqs.length = 0
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Do not run" }), resume: false })
expect(executionCalls).toEqual([])
expect(wakeCalls).toEqual([])
expect(wakeSeqs).toEqual([])
}),
)
})
@@ -41,534 +41,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("does nothing when interrupted while idle", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void })
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses stale wakes after an idle interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.sync(() => runs++) })
yield* coordinator.interrupt("session", 2)
yield* coordinator.wake("session", 1)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(0)
yield* coordinator.wake("session", 3)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
}),
),
)
it.effect("does not interrupt a wake newer than the interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const gate = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Deferred.await(gate)),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
),
})
yield* coordinator.wake("session", 3)
yield* Deferred.await(started)
yield* coordinator.interrupt("session", 2)
expect(yield* Deferred.isDone(interrupted)).toBeFalse()
yield* Deferred.succeed(gate, undefined)
yield* coordinator.awaitIdle("session")
}),
),
)
it.effect("preserves a queued wake newer than the interrupt boundary", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", 3)
yield* coordinator.interrupt("session", 2)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts only the requested key", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
const secondGate = yield* Deferred.make<void>()
const secondInterrupted = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) =>
key === "first"
? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never))
: Deferred.succeed(secondStarted, undefined).pipe(
Effect.andThen(Deferred.await(secondGate)),
Effect.onInterrupt(() => Deferred.succeed(secondInterrupted, undefined)),
),
})
yield* coordinator.wake("first")
yield* coordinator.wake("second")
yield* Effect.all([Deferred.await(firstStarted), Deferred.await(secondStarted)])
yield* coordinator.interrupt("first")
expect(yield* Deferred.isDone(secondInterrupted)).toBeFalse()
yield* Deferred.succeed(secondGate, undefined)
yield* coordinator.awaitIdle("second")
}),
),
)
it.effect("interrupts the active drain and suppresses its queued wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
)
: Effect.void,
),
),
})
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session")
yield* coordinator.interrupt("session")
yield* Deferred.await(interrupted)
yield* coordinator.awaitIdle("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
yield* coordinator.interrupt("session")
}),
),
)
it.effect("suppresses a wake received during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", 1)
yield* Deferred.await(firstInterrupted)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 3)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("remembers a wake received after the interrupt boundary during cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const staleInterrupt = yield* coordinator.interrupt("session", 1).pipe(Effect.forkChild)
expect(runs).toBe(1)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(staleInterrupt)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("moves the stop barrier forward for repeated interrupts", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const firstInterrupted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(firstInterrupted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session", 2).pipe(Effect.forkChild)
yield* Deferred.await(firstInterrupted)
yield* coordinator.wake("session", 3)
const secondInterrupt = yield* coordinator.interrupt("session", 4).pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(firstInterrupt)
yield* Fiber.join(secondInterrupt)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(1)
yield* coordinator.wake("session", 5)
yield* Deferred.await(secondStarted)
yield* coordinator.awaitIdle("session")
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts an explicit run queued before the interruption request", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1 ? Deferred.succeed(firstStarted, undefined).pipe(Effect.andThen(Effect.never)) : Effect.void,
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.interrupt("session")
const exit = yield* Fiber.await(run)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(runs).toBe(1)
}),
),
)
it.effect("settles a pre-interrupt explicit run only after active wake cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const run = yield* coordinator
.run("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.join(run)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("starts an explicit run arriving during interrupt cleanup after the stop barrier", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(interrupt)
yield* Fiber.join(run)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("interrupts pre-stop waiters and runs post-stop waiters after cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const before = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
const interrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const after = yield* coordinator.run("session").pipe(Effect.exit, Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
const beforeExit = yield* Fiber.join(before)
expect(Exit.isFailure(beforeExit) && Cause.hasInterruptsOnly(beforeExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
yield* Fiber.join(after)
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("waits for interrupt cleanup before settling callers", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const runSettled = yield* Deferred.make<void>()
const idleSettled = yield* Deferred.make<void>()
const interruptSettled = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
const run = yield* coordinator
.run("session")
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
yield* Deferred.await(started)
const idle = yield* coordinator
.awaitIdle("session")
.pipe(Effect.exit, Effect.ensuring(Deferred.succeed(idleSettled, undefined)), Effect.forkChild)
const interrupt = yield* coordinator
.interrupt("session")
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
yield* Deferred.await(cleanupStarted)
expect(yield* Deferred.isDone(runSettled)).toBeFalse()
expect(yield* Deferred.isDone(idleSettled)).toBeFalse()
expect(yield* Deferred.isDone(interruptSettled)).toBeFalse()
yield* Deferred.succeed(cleanupGate, undefined)
const runExit = yield* Fiber.await(run)
const idleExit = yield* Fiber.join(idle)
expect(Exit.isFailure(runExit) && Cause.hasInterruptsOnly(runExit.cause)).toBeTrue()
expect(Exit.isFailure(idleExit) && Cause.hasInterruptsOnly(idleExit.cause)).toBeTrue()
yield* Fiber.join(interrupt)
}),
),
)
it.effect("joins concurrent interruption requests for one active drain", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Deferred.succeed(started, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
const first = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
}),
),
)
it.effect("does not discard a post-stop explicit run when interrupted again", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>()
let runs = 0
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () =>
Effect.sync(() => ++runs).pipe(
Effect.flatMap((run) =>
run === 1
? Deferred.succeed(firstStarted, undefined).pipe(
Effect.andThen(Effect.never),
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
: Deferred.succeed(secondStarted, undefined),
),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const firstInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
const secondInterrupt = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(firstInterrupt), Fiber.join(secondInterrupt), Fiber.join(run)])
yield* Deferred.await(secondStarted)
expect(runs).toBe(2)
}),
),
)
it.effect("coalesces wakes received during an active run", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -909,103 +381,4 @@ describe("SessionRunCoordinator", () => {
}),
),
)
it.effect("reports an advisory drain failure exactly once", () =>
Effect.scoped(
Effect.gen(function* () {
const failure = new Error("wake failed")
const reported: Cause.Cause<Error>[] = []
const reportedOnce = yield* Deferred.make<void>()
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(failure),
onFailure: (_key, cause) =>
Effect.sync(() => reported.push(cause)).pipe(Effect.andThen(Deferred.succeed(reportedOnce, undefined))),
})
yield* coordinator.wake("session")
yield* Deferred.await(reportedOnce)
yield* Effect.yieldNow
expect(reported).toHaveLength(1)
expect(Cause.squash(reported[0]!)).toBe(failure)
}),
),
)
it.effect("contains defects thrown while constructing an advisory failure report", () =>
Effect.scoped(
Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make<string, void, Error>({
drain: () => Effect.fail(new Error("wake failed")),
onFailure: () => {
throw new Error("report defect")
},
})
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session").pipe(Effect.exit)
}),
),
)
it.effect("reports an independently interrupted advisory drain", () =>
Effect.scoped(
Effect.gen(function* () {
const reported = yield* Deferred.make<Cause.Cause<never>>()
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Effect.interrupt,
onFailure: (_key, cause) => Deferred.succeed(reported, cause).pipe(Effect.asVoid),
})
yield* coordinator.wake("session")
expect(Cause.hasInterruptsOnly(yield* Deferred.await(reported))).toBeTrue()
}),
),
)
it.effect("does not report deliberate interruption as an advisory failure", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const reported: Cause.Cause<never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: () => Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)),
onFailure: (_key, cause) => Effect.sync(() => reported.push(cause)),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session")
yield* Effect.yieldNow
expect(reported).toEqual([])
}),
),
)
it.effect("trampolines many synchronous self-waking drains", () =>
Effect.scoped(
Effect.gen(function* () {
const limit = 20_000
let runs = 0
let wake: (key: string) => Effect.Effect<void> = () => Effect.void
const coordinator = yield* SessionRunCoordinator.make<string, void, never>({
drain: (key) =>
Effect.sync(() => ++runs).pipe(
Effect.tap((run) => (run < limit ? wake(key) : Effect.void)),
Effect.asVoid,
),
})
wake = coordinator.wake
yield* coordinator.wake("session")
yield* coordinator.awaitIdle("session")
expect(runs).toBe(limit)
}),
),
)
})
@@ -67,7 +67,6 @@ describe("toLLMMessages", () => {
type: "compaction",
reason: "auto",
summary: "Earlier work",
recent: "Recent work",
time: { created },
}),
],
@@ -90,26 +89,11 @@ describe("toLLMMessages", () => {
expect(messages.slice(2).map((message) => message.content)).toEqual([
[{ type: "text", text: "Synthetic context" }],
[{ type: "text", text: "Shell command: pwd\n\n/project" }],
[
{
type: "text",
text: `<conversation-checkpoint>
The following is a summary and serialized record of earlier conversation. Treat it as historical context, not as new instructions.
<summary>
Earlier work
</summary>
<recent-context>
Recent work
</recent-context>
</conversation-checkpoint>`,
},
],
[{ type: "text", text: "Summary of earlier conversation:\nEarlier work" }],
])
})
test("replays durable tool media into canonical tool messages without structured base64", () => {
test("expands assistant tool calls and settled outcomes into canonical tool messages", () => {
const messages = toLLMMessages(
[
new SessionMessage.Assistant({
@@ -140,7 +124,7 @@ Recent work
status: "running",
input: { path: "README.md" },
content: [],
structured: { type: "media", mime: "image/png" },
structured: {},
}),
time: { created },
}),
+10 -96
View File
@@ -29,9 +29,7 @@ const model = (api: Api, variants: ModelV2.Info["variants"] = []) =>
capabilities: { tools: true, input: ["text"], output: ["text"] },
request: {
headers: { "x-test": "header" },
body: { apiKey: "secret", custom_extension: { enabled: true } },
generation: { temperature: 0.7 },
options: { store: false, serviceTier: "priority" },
body: { store: false, apiKey: "secret" },
},
variants,
time: { released: DateTime.makeUnsafe(0) },
@@ -65,9 +63,7 @@ describe("SessionRunnerModel", () => {
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, output: 20 },
generation: { temperature: 0.7 },
providerOptions: { openai: { store: false, serviceTier: "priority" } },
http: { body: { custom_extension: { enabled: true } } },
http: { body: { store: false } },
},
})
}),
@@ -95,7 +91,7 @@ describe("SessionRunnerModel", () => {
url: "https://compatible.example/v1",
settings: { apiKey: "settings-secret", compatibility: "strict" },
}),
request: { headers: {}, body: {}, generation: {}, options: {} },
request: { headers: {}, body: {} },
}),
)
const request = LLM.request({ model: resolved, prompt: "Hello" })
@@ -112,21 +108,15 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("lowers selected OpenAI Session variants into Responses options", () =>
it.effect("applies the selected Session variant to request options", () =>
Effect.gen(function* () {
const base = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
const catalog = model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
headers: { "x-variant": "high" },
body: {},
generation: { temperature: 0.2 },
options: { reasoningEffort: "high" },
body: { reasoningEffort: "high" },
},
])
const catalog = new ModelV2.Info({
...base,
request: { ...base.request, options: { ...base.request.options, reasoningEffort: "medium" } },
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
@@ -143,87 +133,11 @@ describe("SessionRunnerModel", () => {
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
expect(prepared.body).toMatchObject({
store: false,
service_tier: "priority",
temperature: 0.2,
reasoning: { effort: "high" },
expect(resolved.route.defaults).toMatchObject({
headers: { "x-test": "header", "x-variant": "high" },
http: { body: { store: false, reasoningEffort: "high" } },
})
expect(prepared.body).not.toHaveProperty("reasoningEffort")
}),
)
it.effect("lowers selected OpenAI-compatible Session variants into Chat options", () =>
Effect.gen(function* () {
const catalog = model(
{ type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://compatible.example/v1" },
[
{
id: ModelV2.VariantID.make("high"),
headers: {},
body: {},
generation: {},
options: { reasoningEffort: "high" },
},
],
)
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_compatible_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
expect(prepared.body).toMatchObject({
store: false,
reasoning_effort: "high",
})
expect(prepared.body).not.toHaveProperty("reasoningEffort")
}),
)
it.effect("lowers selected Anthropic Session variants into Messages options", () =>
Effect.gen(function* () {
const catalog = model({ type: "aisdk", package: "@ai-sdk/anthropic", url: "https://anthropic.example/v1" }, [
{
id: ModelV2.VariantID.make("high"),
headers: {},
body: {},
generation: {},
options: { thinking: { type: "enabled", budgetTokens: 12000 } },
},
])
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_anthropic_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const prepared = yield* LLMClient.prepare(LLM.request({ model: resolved, prompt: "Hello" }))
expect(resolved.route.defaults.http?.body).toEqual({ custom_extension: { enabled: true } })
expect(prepared.body).toMatchObject({
thinking: { type: "enabled", budget_tokens: 12000 },
})
expect(JSON.stringify(prepared.body)).not.toContain("budgetTokens")
}),
)
@@ -245,7 +159,7 @@ describe("SessionRunnerModel", () => {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
new ModelV2.Info({
...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
request: { headers: {}, body: {}, generation: {}, options: {} },
request: { headers: {}, body: {} },
}),
provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }),
)
@@ -7,7 +7,6 @@ import { EventV2 } from "@opencode-ai/core/event"
import { EventTable } from "@opencode-ai/core/event/sql"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -21,7 +20,6 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { Location } from "@opencode-ai/core/location"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SystemContext } from "@opencode-ai/core/system-context"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
@@ -63,9 +61,7 @@ const model = OpenAIChat.route
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
const systemContext = SystemContextRegistry.layer
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(database),
Layer.provide(store),
@@ -74,22 +70,14 @@ const runner = SessionRunnerLLM.defaultLayer.pipe(
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) =>
SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
}),
),
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
@@ -112,9 +100,7 @@ const it = testEffect(
registry,
models,
systemContext,
location,
skillGuidance,
config,
runner,
coordinator,
execution,
@@ -1,127 +0,0 @@
import { expect, test } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { LLMEvent } from "@opencode-ai/llm"
import { EventV2 } from "@opencode-ai/core/event"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionV2 } from "@opencode-ai/core/session"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
const sessionID = SessionV2.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const capture = () => {
const published: Array<{ readonly type: string; readonly data: unknown }> = []
const events = EventV2.Service.of({
publish: (definition, data) =>
Effect.sync(() => {
const event = { id: EventV2.ID.create(), type: definition.type, data } as EventV2.Payload<typeof definition>
published.push({
type: definition.sync ? EventV2.versionedType(definition.type, definition.sync.version) : definition.type,
data,
})
return event
}),
subscribe: () => Stream.empty,
all: () => Stream.empty,
aggregateEvents: () => Stream.empty,
sync: () => Effect.succeed(Effect.void),
listen: () => Effect.succeed(Effect.void),
beforeCommit: () => Effect.void,
project: () => Effect.void,
replay: () => Effect.void,
replayAll: () => Effect.succeed(undefined),
remove: () => Effect.void,
claim: () => Effect.void,
})
return {
published,
publisher: createLLMEventPublisher(events, {
sessionID,
agent: "build",
model: {
id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"),
},
}),
}
}
const call = LLMEvent.toolCall({ id: "call-image", name: "read", input: { path: "pixel.png" } })
const result = LLMEvent.toolResult({
id: "call-image",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: base64, filename: "pixel.png" },
],
},
output: {
structured: { type: "media", mime: "image/png" },
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png", name: "pixel.png" },
],
},
})
test("local tool success serializes media base64 once and reconstructs from structured content", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(publisher.publish(result))
const success = published.find((event) => event.type === "session.next.tool.success.1")
expect(success).toBeDefined()
const serialized = JSON.stringify(success)
expect(serialized.split(base64)).toHaveLength(2)
expect(success?.data).not.toHaveProperty("result")
expect(success?.data).toMatchObject({
content: [
{ type: "text", text: "Image read successfully" },
{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" },
],
})
})
test("provider-executed success retains its compatibility result", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
const success = published.find((event) => event.type === "session.next.tool.success.1")
expect(success?.data).toHaveProperty("result")
})
test("binary failure emits no success event", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call))
await Effect.runPromise(
publisher.publish(
LLMEvent.toolResult({
id: call.id,
name: call.name,
result: { type: "error", value: "Cannot read binary file" },
}),
),
)
expect(published.some((event) => event.type === "session.next.tool.success.1")).toBe(false)
expect(published.some((event) => event.type === "session.next.tool.failed.1")).toBe(true)
})
test("old success event data containing result still decodes", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID,
timestamp: Date.now(),
assistantMessageID: SessionMessage.ID.create(),
callID: "call-old",
structured: { type: "media", mime: "image/png" },
content: [{ type: "file", source: { type: "data", data: base64 }, mime: "image/png" }],
result: { type: "content", value: [{ type: "media", mediaType: "image/png", data: base64 }] },
provider: { executed: false },
})
expect(decoded.result).toMatchObject({ type: "content" })
})
@@ -3,8 +3,6 @@ import { Tool, ToolFailure } from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
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 { ApplicationTools } from "@opencode-ai/core/tool/application-tools"
import { Effect, Exit, Layer, Schema, Scope } from "effect"
import { testEffect } from "./lib/effect"
@@ -26,15 +24,7 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const bounds: ToolOutputStore.BoundInput[] = []
const outputStore = Layer.mock(ToolOutputStore.Service, {
bound: (input) => Effect.sync(() => bounds.push(input)).pipe(Effect.as({ output: input.output, outputPaths: [] })),
})
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(ApplicationTools.layer),
Layer.provide(outputStore),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const it = testEffect(Layer.mergeAll(permission, registry))
const echo = Tool.make({
@@ -190,7 +180,6 @@ describe("ToolRegistry", () => {
it.effect("settles encoded structured output with canonical projected content", () =>
Effect.gen(function* () {
bounds.length = 0
const registry = yield* ToolRegistry.Service
const transform = yield* registry.transform()
@@ -213,17 +202,10 @@ describe("ToolRegistry", () => {
sessionID: SessionV2.ID.make("ses_registry_test"),
call: { type: "tool-call", id: "call-projected", name: "projected", input: { prefix: "count" } },
}),
).toMatchObject({
).toEqual({
result: { type: "text", value: "call-projected:count:2" },
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
})
expect(bounds).toEqual([
{
sessionID: SessionV2.ID.make("ses_registry_test"),
toolCallID: "call-projected",
output: { structured: { count: "2" }, content: [{ type: "text", text: "call-projected:count:2" }] },
},
])
}),
)
})
+12 -411
View File
@@ -6,7 +6,6 @@ import {
Model,
Tool,
TransportReason,
InvalidRequestReason,
type LLMClientShape,
type LLMRequest,
} from "@opencode-ai/llm"
@@ -33,11 +32,8 @@ 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"
import { ConfigCompaction } from "@opencode-ai/core/config/compaction"
import { NativeTool } from "@opencode-ai/core/tool/native"
import {
SessionContextEpochTable,
@@ -50,7 +46,6 @@ import { SystemContext } from "@opencode-ai/core/system-context"
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
import { ModelV2 } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
@@ -100,16 +95,6 @@ const client = Layer.succeed(
)
const model = Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route })
const replacementModel = Model.make({ id: "replacement", provider: "fake", route: OpenAIChat.route })
const compactModel = Model.make({
id: "compact",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }),
})
const recoveryModel = Model.make({
id: "recovery",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
const authorizations: ToolRegistry.AuthorizeInput[] = []
const executions: string[] = []
const permission = Layer.succeed(
@@ -124,11 +109,7 @@ const permission = Layer.succeed(
}),
)
const applications = ApplicationTools.layer
const registry = ToolRegistry.layer.pipe(
Layer.provide(permission),
Layer.provide(applications),
Layer.provide(ToolOutputStore.defaultLayer),
)
const registry = ToolRegistry.layer.pipe(Layer.provide(permission), Layer.provide(applications))
const agents = AgentV2.layer
const echo = Layer.effectDiscard(
ToolRegistry.Service.use((registry) =>
@@ -168,9 +149,8 @@ const echo = Layer.effectDiscard(
),
).pipe(Layer.provide(registry))
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : currentModel)),
modelResolveHook.pipe(Effect.as(session.model?.id === "replacement" ? replacementModel : model)),
)
const systemContextKey = SystemContext.Key.make("test/context")
let systemBaseline = "Initial context"
@@ -207,7 +187,6 @@ const systemContext = Layer.effectDiscard(
),
),
).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(
@@ -223,23 +202,6 @@ const skillGuidance = Layer.mock(SkillGuidance.Service, {
: SystemContext.empty,
),
})
const config = Layer.succeed(
Config.Service,
Config.Service.of({
entries: () =>
Effect.succeed([
new Config.Document({
type: "document",
info: new Config.Info({
compaction: new ConfigCompaction.Info({
buffer: 3_000,
keep: new ConfigCompaction.Keep({ tokens: 1_000 }),
}),
}),
}),
]),
}),
)
const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(database),
Layer.provide(store),
@@ -248,22 +210,14 @@ const runner = SessionRunnerLLM.layer.pipe(
Layer.provide(registry),
Layer.provide(models),
Layer.provide(systemContext),
Layer.provide(location),
Layer.provide(agents),
Layer.provide(skillGuidance),
Layer.provide(config),
)
const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner))
const execution = Layer.effect(
SessionExecution.Service,
SessionRunCoordinator.Service.pipe(
Effect.map((coordinator) =>
SessionExecution.Service.of({
resume: coordinator.run,
wake: coordinator.wake,
interrupt: coordinator.interrupt,
}),
),
Effect.map((coordinator) => SessionExecution.Service.of({ resume: coordinator.run, wake: coordinator.wake })),
),
).pipe(Layer.provide(coordinator))
const sessions = SessionV2.layer.pipe(
@@ -288,9 +242,7 @@ const it = testEffect(
echo,
models,
systemContext,
location,
skillGuidance,
config,
runner,
coordinator,
execution,
@@ -326,7 +278,6 @@ const setup = Effect.gen(function* () {
systemUnavailable = false
systemLoadHook = Effect.void
modelResolveHook = Effect.void
currentModel = model
skillBaselines.clear()
responses = undefined
streamFailure = undefined
@@ -354,21 +305,6 @@ const providerUnavailable = () =>
reason: new TransportReason({ message: "Provider unavailable" }),
})
const setupOverflowRecovery = Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Earlier question ".repeat(700) }),
resume: false,
})
yield* session.resume(sessionID)
currentModel = recoveryModel
requests.length = 0
return session
})
const userTexts = (request: LLMRequest) =>
request.messages.flatMap((message) =>
message.role === "user"
@@ -688,7 +624,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a source Location runner after a Session moves", () =>
it.effect("requires a complete new baseline after a Session moves", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
@@ -712,10 +648,12 @@ describe("SessionRunnerLLM", () => {
.get(),
).toBeUndefined()
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(SystemContext.InitializationBlocked)
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
}),
@@ -1391,20 +1329,16 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
response = []
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
})
systemBaseline = "Replacement context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
@@ -1420,193 +1354,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("automatically compacts into a completed summary and retained recent turn", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-first", ["Earlier answer"]).completeEvents
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Earlier question ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Recent exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain("## Goal")
expect(userTexts(requests[1])).toHaveLength(1)
expect(userTexts(requests[1])[0]).toContain("<summary>\n## Goal\n- Preserve the task\n</summary>")
expect(userTexts(requests[1])[0]).toContain(`[User]: ${"Recent exact request ".repeat(180)}`)
const context = yield* (yield* SessionStore.Service).context(sessionID)
expect(context.map((message) => message.type)).toEqual(["compaction", "assistant"])
expect(context[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the task",
})
requests.length = 0
responses = [
fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents,
fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents,
]
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Newest exact request ".repeat(180) }),
resume: false,
})
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Goal\n- Preserve the task\n</previous-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
type: "compaction",
summary: "## Goal\n- Preserve the updated task",
})
}),
)
it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
],
fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])[0]).toContain("## Goal")
expect(userTexts(requests[2])[0]).toContain("<summary>\n## Goal\n- Recover overflow\n</summary>")
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover overflow" },
{ type: "assistant", finish: "stop" },
])
yield* replaySessionProjection(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("persists a second context overflow after one recovery", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
const overflow = () => [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
responses = [
overflow(),
fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents,
overflow(),
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
])
}),
)
it.effect("recovers once from a raw context overflow failure", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responseStream = Stream.fail(
new LLMError({
module: "test",
method: "stream",
reason: new InvalidRequestReason({
message: "prompt too long",
classification: "context-overflow",
}),
}),
)
responses = [
fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents,
fragmentFixture("text", "text-final", ["Recovered"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(3)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "compaction", summary: "## Goal\n- Recover raw overflow" },
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("publishes the original overflow when recovery summarization fails", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
[LLMEvent.providerError({ message: "summary unavailable" })],
]
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const context = yield* session.context(sessionID)
expect(context.some((message) => message.type === "compaction")).toBe(false)
expect(context.slice(-2)).toMatchObject([
{ type: "user", text: "Continue" },
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
])
}),
)
it.effect("interrupts overflow recovery while the summary provider is running", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
responses = [
[LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" })],
fragmentFixture("text", "text-summary", ["## Goal\n- Interrupted"]).completeEvents,
]
const firstGate = yield* Deferred.make<void>()
const summaryGate = yield* Deferred.make<void>()
streamGate = firstGate
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false })
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 1) yield* Effect.yieldNow
streamGate = summaryGate
yield* Deferred.succeed(firstGate, undefined)
while (requests.length < 2) yield* Effect.yieldNow
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
streamGate = undefined
expect(requests).toHaveLength(2)
expect((yield* session.context(sessionID)).some((message) => message.type === "compaction")).toBe(false)
}),
)
it.effect("preserves effective System updates while compaction replacement is blocked", () =>
Effect.gen(function* () {
yield* setup
@@ -1620,20 +1367,16 @@ describe("SessionRunnerLLM", () => {
systemBaseline = "Changed context"
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Second" }), resume: false })
yield* session.resume(sessionID)
const compactionID = SessionMessage.ID.create()
yield* events.publish(SessionEvent.Compaction.Started, {
sessionID,
messageID: compactionID,
messageID: SessionMessage.ID.create(),
timestamp: DateTime.makeUnsafe(1),
reason: "manual",
})
yield* events.publish(SessionEvent.Compaction.Ended, {
sessionID,
messageID: compactionID,
timestamp: DateTime.makeUnsafe(2),
reason: "manual",
text: "summary",
recent: "",
})
systemUnavailable = true
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Third" }), resume: false })
@@ -2243,92 +1986,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("preserves durable queued input for a later wake after interruption", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
[],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Run after interrupt" }),
delivery: "queue",
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(resumed)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(2)
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Run after interrupt"])
}),
)
it.effect("preserves durable steering input for a later resume after interruption", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt current work" }), resume: false })
requests.length = 0
responses = [
[],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.prompt({
sessionID,
prompt: new Prompt({ text: "Steer after interrupt" }),
})
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInput.hasPending(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
while (requests.length < 2) yield* Effect.yieldNow
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(resumed)
streamGate = undefined
streamStarted = undefined
expect(requests).toHaveLength(2)
expect(userTexts(requests[0]!)).toEqual(["Interrupt current work"])
expect(userTexts(requests[1]!)).toEqual(["Interrupt current work", "Steer after interrupt"])
}),
)
it.effect("runs queued active inputs as separate FIFO activities", () =>
Effect.gen(function* () {
yield* setup
@@ -2812,10 +2469,6 @@ describe("SessionRunnerLLM", () => {
yield* Effect.yieldNow
expect(requests).toHaveLength(2)
expect(requests.map((request) => request.providerOptions?.openai?.promptCacheKey)).toEqual([
sessionID,
otherSessionID,
])
yield* Deferred.succeed(streamGate, undefined)
yield* Fiber.join(first)
yield* Fiber.join(second)
@@ -3044,13 +2697,13 @@ describe("SessionRunnerLLM", () => {
Stream.never,
)
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
const runner = yield* SessionRunner.Service
const run = yield* runner.run({ sessionID, force: true }).pipe(Effect.forkChild)
while (executions.length === 0) yield* Effect.yieldNow
yield* session.interrupt(sessionID)
yield* Fiber.interrupt(run)
toolExecutionGate = undefined
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
yield* session.interrupt(sessionID)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Interrupt blocked tool" },
{
@@ -3079,29 +2732,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a blocked provider turn without local tool activity", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Interrupt provider" }), resume: false })
requests.length = 0
response = []
streamGate = yield* Deferred.make<void>()
streamStarted = yield* Deferred.make<void>()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* Deferred.await(streamStarted)
yield* session.interrupt(sessionID)
const exit = yield* Fiber.await(run)
streamGate = undefined
streamStarted = undefined
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(requests).toHaveLength(1)
yield* session.interrupt(sessionID)
}),
)
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
Effect.gen(function* () {
yield* setup
@@ -3263,35 +2893,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("does not recover context overflow after durable assistant output", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false })
requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text-partial" }),
LLMEvent.textDelta({ id: "text-partial", text: "Partial" }),
LLMEvent.textEnd({ id: "text-partial" }),
LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }),
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Fail after output" },
{
type: "assistant",
finish: "error",
error: { message: "prompt too long" },
content: [{ type: "text", text: "Partial" }],
},
])
}),
)
it.effect("projects raw provider stream failures as terminal assistant step failures", () =>
Effect.gen(function* () {
yield* setup
+10 -6
View File
@@ -74,7 +74,7 @@ const resources = Layer.succeed(
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: (input) => Effect.sync(() => truncations.push(input)).pipe(Effect.andThen(truncate(input))),
bound: (input) => Effect.succeed({ output: input.output, outputPaths: [] }),
read: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
}),
)
@@ -295,7 +295,7 @@ describe("BashTool", () => {
),
)
it.live("keeps non-zero exits useful and exposes managed overflow by path", () =>
it.live("keeps non-zero exits useful and exposes managed overflow by opaque URI", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
@@ -303,9 +303,13 @@ describe("BashTool", () => {
result = { ...result, exitCode: 7, stdout: Buffer.from("HEAD full output TAIL") }
truncate = (input) =>
Effect.succeed({
content: "HEAD\n\n... output truncated; full content saved to /tmp/tool-output/tool_opaque ...\n\nTAIL",
content: "HEAD\n\n... output truncated; full content available as tool-output://opaque ...\n\nTAIL",
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
resource: new ToolOutputStore.Resource({
uri: "tool-output://opaque",
mime: "text/plain",
size: input.content.length,
}),
})
return withTool(tmp.path, (registry) => registry.settle(call({ command: "false" }, "call-overflow"))).pipe(
Effect.andThen((settled) =>
@@ -319,12 +323,12 @@ describe("BashTool", () => {
cwd: realpathSync(tmp.path),
exitCode: 7,
truncated: true,
outputPath: "/tmp/tool-output/tool_opaque",
resource: { uri: "tool-output://opaque" },
})
expect(settled.outputPaths).toEqual(["/tmp/tool-output/tool_opaque"])
expect(truncations).toMatchObject([
{ sessionID, toolCallID: "call-overflow", content: "HEAD full output TAIL" },
])
expect(JSON.stringify(settled)).not.toContain(tmp.path + path.sep + "tool-output")
}),
),
)
-1
View File
@@ -41,7 +41,6 @@ const filesystem = Layer.succeed(
readResolved: () => Effect.die("unused"),
readSampleResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
readToolResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.sync(() => {
-1
View File
@@ -36,7 +36,6 @@ const filesystem = Layer.succeed(
readResolved: () => Effect.die("unused"),
readSampleResolved: () => Effect.die("unused"),
readTextPageResolved: () => Effect.die("unused"),
readToolResolved: () => Effect.die("unused"),
list: () => Effect.die("unused"),
resolveRoot: (input = {}) =>
Effect.succeed(
+167 -58
View File
@@ -11,6 +11,7 @@ import { testEffect } from "./lib/effect"
import { tmpdir } from "./fixture/tmpdir"
const sessionID = SessionV2.ID.make("ses_tool_output_store")
const otherSessionID = SessionV2.ID.make("ses_tool_output_store_other")
const withStore = <A, E, R>(
body: (input: { root: string; store: ToolOutputStore.Interface; fs: FSUtil.Interface }) => Effect.Effect<A, E, R>,
@@ -43,88 +44,140 @@ const withStore = <A, E, R>(
const it = testEffect(Layer.empty)
describe("ToolOutputStore", () => {
it.live("returns under-limit text unchanged without writing a file", () =>
it.live("returns under-limit text unchanged without writing a resource", () =>
withStore(({ store }) =>
Effect.gen(function* () {
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "one\ntwo" })).toEqual({
content: "one\ntwo",
expect(yield* store.truncate({ sessionID, toolCallID: "call-short", content: "line one\nline two" })).toEqual({
content: "line one\nline two",
truncated: false,
})
}),
),
)
it.live("stores full output at an absolute managed path", () =>
withStore(({ root, store, fs }) =>
it.live("stores byte-truncated output and returns an opaque head-tail preview", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const content = "HEAD-" + "x".repeat(500) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-large", content, maxBytes: 300 })
const content = "HEAD-" + "x".repeat(100) + "-TAIL"
const result = yield* store.truncate({ sessionID, toolCallID: "call-bytes", content, maxBytes: 20 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(path.isAbsolute(result.outputPath)).toBe(true)
expect(result.outputPath).toStartWith(path.join(root, "tool-output", "tool_"))
expect(result.content).toContain(result.outputPath)
expect(result.content).toContain("HEAD-")
expect(result.content).toContain("-TAIL")
expect(yield* fs.readFileString(result.outputPath)).toBe(content)
expect(result.content).toContain("output truncated")
expect(result.resource.uri).toMatch(/^tool-output:\/\/[0-9A-Za-z]+$/)
expect(result.resource.uri.slice("tool-output://".length)).not.toContain("/")
expect(result.resource.uri).not.toContain("\\")
expect(result.resource).toMatchObject({ mime: "text/plain", size: Buffer.byteLength(content) })
expect((yield* store.read({ sessionID, uri: result.resource.uri })).content).toBe(content)
}),
),
)
it.live("bounds aggregate text blocks with one managed file", () =>
withStore(({ store, fs }) =>
it.live("stores line-truncated output and keeps both ends in the preview", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const first = "HEAD-" + "x".repeat(30_000)
const second = "y".repeat(30_000) + "-TAIL"
const result = yield* store.bound({
const content = Array.from({ length: 10 }, (_, index) => `line-${index}`).join("\n")
const result = yield* store.truncate({ sessionID, toolCallID: "call-lines", content, maxLines: 4 })
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
expect(result.content).toContain("line-0\nline-1")
expect(result.content).toContain("line-8\nline-9")
expect(result.content).not.toContain("line-4")
}),
),
)
it.live("keeps one-line previews bounded", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const result = yield* store.truncate({
sessionID,
toolCallID: "call-aggregate",
output: {
structured: { kind: "report" },
content: [
{ type: "text", text: first },
{ type: "text", text: second },
],
},
toolCallID: "call-one-line",
content: "one\ntwo\nthree",
maxLines: 1,
})
expect(result.output.structured).toEqual({ kind: "report" })
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(`${first}\n\n${second}`)
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(Buffer.byteLength(result.output.content[0].text)).toBeLessThanOrEqual(ToolOutputStore.MAX_BYTES)
expect(result.truncated).toBe(true)
if (!result.truncated) throw new Error("expected truncation")
const preview = result.content.split("\n\n... output truncated")[0]
expect(preview).toBe("one")
}),
),
)
it.live("uses bounded text for oversized structured-only output", () =>
withStore(({ store, fs }) =>
Effect.gen(function* () {
const structured = { text: "x".repeat(ToolOutputStore.MAX_BYTES) }
const result = yield* store.bound({ sessionID, toolCallID: "call-json", output: { structured, content: [] } })
expect(result.output.structured).toBe(structured)
expect(result.outputPaths).toHaveLength(1)
expect(yield* fs.readFileString(result.outputPaths[0]!)).toBe(JSON.stringify(structured))
}),
),
)
it.live("degrades to lossy bounded output when writing fails", () =>
it.live("pages reads within the bounded managed-resource limit", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
yield* fs.writeFileString(path.join(root, "tool-output"), "not a directory")
const result = yield* store.bound({
const resource = yield* store.write({
sessionID,
toolCallID: "call-lossy",
output: { structured: {}, content: [{ type: "text", text: "x".repeat(ToolOutputStore.MAX_BYTES + 1) }] },
toolCallID: "call-page",
content: "0123456789",
name: "out.txt",
})
expect(result.outputPaths).toEqual([])
if (result.output.content[0]?.type !== "text") throw new Error("expected text preview")
expect(result.output.content[0].text).toContain("could not be retained")
const first = yield* store.read({ sessionID, uri: resource.uri, limit: 4 })
const second = yield* store.read({ sessionID, uri: resource.uri, offset: first.next, limit: 4 })
const last = yield* store.read({ sessionID, uri: resource.uri, offset: second.next, limit: 4 })
expect(first).toMatchObject({ content: "0123", offset: 0, truncated: true, next: 4 })
expect(second).toMatchObject({ content: "4567", offset: 4, truncated: true, next: 8 })
expect(last).toMatchObject({ content: "89", offset: 8, truncated: false })
expect(last.resource).toEqual({ uri: resource.uri, mime: "text/plain", name: "out.txt", size: 10 })
expect(
JSON.parse(
yield* fs.readFileString(
path.join(root, "tool-output", "managed", `${resource.uri.slice("tool-output://".length)}.json`),
),
),
).toMatchObject({
sessionID,
toolCallID: "call-page",
})
const bounded = yield* store.read({
sessionID,
uri: (yield* store.write({
sessionID,
toolCallID: "call-bounded",
content: "x".repeat(ToolOutputStore.MAX_READ_BYTES + 10),
})).uri,
limit: ToolOutputStore.MAX_READ_BYTES + 10,
})
expect(Buffer.byteLength(bounded.content)).toBe(ToolOutputStore.MAX_READ_BYTES)
expect(bounded).toMatchObject({ truncated: true, next: ToolOutputStore.MAX_READ_BYTES })
}),
),
)
it.live("honors configured limits", () =>
it.live("allows the owning session and denies cross-session reads", () =>
withStore(({ store }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-owned", content: "owned" })
expect((yield* store.read({ sessionID, uri: resource.uri })).content).toBe("owned")
expect(yield* Effect.flip(store.read({ sessionID: otherSessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.AccessDeniedError,
)
}),
),
)
it.live("rejects resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const id = resource.uri.slice("tool-output://".length)
yield* fs.writeFileString(path.join(root, "tool-output", "managed", `${id}.txt`), "changed payload")
expect(yield* Effect.flip(store.read({ sessionID, uri: resource.uri }))).toBeInstanceOf(
ToolOutputStore.ResourceNotFoundError,
)
}),
),
)
it.live("honors configured truncation limits", () =>
withStore(
({ store }) =>
Effect.gen(function* () {
@@ -137,19 +190,75 @@ describe("ToolOutputStore", () => {
),
)
it.live("cleans expired managed files and preserves unrelated files", () =>
it.live("cleans old managed resources while preserving recent and unrelated files", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const old = yield* store.write({ sessionID, toolCallID: "old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "recent", content: "recent" })
const unrelated = path.join(root, "tool-output", "keep.txt")
const old = yield* store.write({ sessionID, toolCallID: "call-old", content: "old" })
const recent = yield* store.write({ sessionID, toolCallID: "call-recent", content: "recent" })
const directory = path.join(root, "tool-output", "managed")
const oldID = old.uri.slice("tool-output://".length)
const recentID = recent.uri.slice("tool-output://".length)
const oldMetadata = path.join(directory, `${oldID}.json`)
const unrelated = path.join(root, "tool-output", "unrelated.txt")
const unrelatedManaged = path.join(directory, "unrelated.txt")
const record = JSON.parse(yield* fs.readFileString(oldMetadata))
yield* fs.writeFileString(
oldMetadata,
JSON.stringify({ ...record, created: Date.now() - 8 * 24 * 60 * 60 * 1_000 }),
)
yield* fs.writeFileString(unrelated, "keep")
const expired = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* fs.utimes(old, expired, expired)
yield* fs.writeFileString(unrelatedManaged, "keep")
yield* store.cleanup()
expect(yield* fs.exists(old)).toBe(false)
expect(yield* fs.exists(recent)).toBe(true)
expect(yield* fs.exists(path.join(directory, `${oldID}.txt`))).toBe(false)
expect(yield* fs.exists(oldMetadata)).toBe(false)
expect(yield* fs.exists(path.join(directory, `${recentID}.txt`))).toBe(true)
expect(yield* fs.exists(unrelated)).toBe(true)
expect(yield* fs.exists(unrelatedManaged)).toBe(true)
}),
),
)
it.live("cleans stale generated orphan payloads and malformed pairs", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const directory = path.join(root, "tool-output", "managed")
yield* fs.ensureDir(directory)
const orphanID = "00000000000000000000000000"
const malformedID = "00000000000000000000000001"
const orphan = path.join(directory, `${orphanID}.txt`)
const malformedPayload = path.join(directory, `${malformedID}.txt`)
const malformedMetadata = path.join(directory, `${malformedID}.json`)
yield* fs.writeFileString(orphan, "orphan")
yield* fs.writeFileString(malformedPayload, "malformed")
yield* fs.writeFileString(malformedMetadata, "not json")
const old = new Date(Date.now() - 8 * 24 * 60 * 60 * 1_000)
yield* Effect.all([fs.utimes(orphan, old, old), fs.utimes(malformedPayload, old, old)])
yield* store.cleanup()
expect(yield* fs.exists(orphan)).toBe(false)
expect(yield* fs.exists(malformedPayload)).toBe(false)
expect(yield* fs.exists(malformedMetadata)).toBe(false)
}),
),
)
it.live("cleans managed resources whose payload size no longer matches metadata", () =>
withStore(({ root, store, fs }) =>
Effect.gen(function* () {
const resource = yield* store.write({ sessionID, toolCallID: "call-modified", content: "original" })
const directory = path.join(root, "tool-output", "managed")
const id = resource.uri.slice("tool-output://".length)
const payload = path.join(directory, `${id}.txt`)
const metadata = path.join(directory, `${id}.json`)
yield* fs.writeFileString(payload, "changed payload")
yield* store.cleanup()
expect(yield* fs.exists(payload)).toBe(false)
expect(yield* fs.exists(metadata)).toBe(false)
}),
),
)
+160 -343
View File
@@ -7,6 +7,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { ReadTool } from "@opencode-ai/core/tool/read"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { RelativePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
@@ -23,14 +24,15 @@ let listReal = "/project/src"
let size = 5
let real = "/project/README.md"
let afterApproval = () => {}
let readFailure: unknown
let readContent: FileSystem.Content = new FileSystem.TextContent({
type: "text",
content: "hello",
mime: "text/plain",
})
let sample = new TextEncoder().encode("hello")
let readFailure: unknown
let configEntries: Config.Entry[] = []
const resourceReads: ToolOutputStore.ReadInput[] = []
const filesystem = Layer.succeed(
FileSystem.Service,
FileSystem.Service.of({
@@ -101,34 +103,6 @@ const filesystem = Layer.succeed(
})
})
: Effect.die(readFailure),
readToolResolved: (_target, page = {}) => {
samples.push(FileSystem.READ_SAMPLE_BYTES)
if (readFailure !== undefined) return Effect.die(readFailure)
if (sample[0] === 0x89 && sample[1] === 0x50 && sample[2] === 0x4e && sample[3] === 0x47)
return Effect.succeed(
readContent.type === "binary"
? new FileSystem.BinaryContent({ ...readContent, mime: "image/png" })
: readContent,
)
if (FileSystem.isBinary(real.split("/").at(-1) ?? real, sample))
return Effect.die(new FileSystem.BinaryFileError(real.split("/").at(-1) ?? real))
if (size > FileSystem.MAX_READ_BYTES || page.offset !== undefined || page.limit !== undefined)
return Effect.sync(() => {
textPageInputs.push(page)
return new FileSystem.TextPage({
type: "text-page",
content: "hello",
mime: "text/plain",
offset: page.offset ?? 1,
truncated: true,
next: (page.offset ?? 1) + 1,
})
})
return Effect.sync(() => {
reads.push({ path: RelativePath.make("README.md") })
return readContent
})
},
resolveRoot: () => Effect.die("unused"),
revalidateRoot: Effect.succeed,
list: () => Effect.die("unused"),
@@ -174,14 +148,34 @@ const permission = Layer.succeed(
}),
)
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
const resources = Layer.succeed(
ToolOutputStore.Service,
ToolOutputStore.Service.of({
limits: () => Effect.die("unused"),
write: () => Effect.die("unused"),
truncate: () => Effect.die("unused"),
cleanup: () => Effect.die("unused"),
read: (input) =>
Effect.sync(() => {
resourceReads.push(input)
return new ToolOutputStore.Page({
resource: new ToolOutputStore.Resource({ uri: input.uri, mime: "text/plain", size: 5 }),
content: "hello",
offset: input.offset ?? 0,
truncated: false,
})
}),
}),
)
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed(configEntries) }))
const read = ReadTool.layer.pipe(
Layer.provide(registry),
Layer.provide(filesystem),
Layer.provide(permission),
Layer.provide(resources),
Layer.provide(config),
)
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, config, read))
const it = testEffect(Layer.mergeAll(registry, filesystem, permission, resources, config, read))
const sessionID = SessionV2.ID.make("ses_read_tool_test")
describe("ReadTool", () => {
@@ -195,9 +189,9 @@ describe("ReadTool", () => {
size = 5
real = "/project/README.md"
afterApproval = () => {}
readFailure = undefined
readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
sample = new TextEncoder().encode("hello")
readFailure = undefined
configEntries = []
resolvedInput = undefined
const registry = yield* ToolRegistry.Service
@@ -214,273 +208,6 @@ describe("ReadTool", () => {
}),
)
it.effect("returns a small PNG as native media instead of durable base64 text", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
reads.length = 0
samples.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = Buffer.from(png, "base64").length
real = "/project/pixel.png"
afterApproval = () => {}
sample = Buffer.from(png, "base64")
readContent = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "pixel.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: png, filename: "pixel.png" },
],
})
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
expect(reads).toHaveLength(0)
const settled = yield* registry.settle({
sessionID,
call: { type: "tool-call", id: "call-image-settle", name: "read", input: { path: "pixel.png" } },
})
expect(settled.output?.structured).toEqual({ type: "media", mime: "image/png" })
expect(JSON.stringify(settled.output?.structured)).not.toContain(png)
expect(settled.output?.content).toMatchObject([
{ type: "text", text: "Image read successfully" },
{ type: "file", mime: "image/png", source: { type: "data", data: png } },
])
}),
)
it.effect("rejects invalid or truncated image data after signature classification", () =>
Effect.gen(function* () {
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 8
real = "/project/truncated.png"
afterApproval = () => {}
sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
readContent = new FileSystem.BinaryContent({
type: "binary",
content: Buffer.from(sample).toString("base64"),
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-truncated-image", name: "read", input: { path: "truncated.png" } },
}),
).toEqual({ type: "error", value: "Image could not be decoded: truncated.png" })
}),
)
it.effect("rejects oversized images when resizing is disabled", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = Buffer.from(base64, "base64").length
real = "/project/wide.png"
afterApproval = () => {}
sample = Buffer.from(base64, "base64")
readContent = new FileSystem.BinaryContent({
type: "binary",
content: base64,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
}),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-wide-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("exceeding configured limits 4x2000")
}),
)
it.effect("resizes images to configured dimensions before returning media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
const base64 = Buffer.from(source.get_bytes()).toString("base64")
source.free()
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = Buffer.from(base64, "base64").length
real = "/project/wide.png"
afterApproval = () => {}
sample = Buffer.from(base64, "base64")
readContent = new FileSystem.BinaryContent({
type: "binary",
content: base64,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(media?.type).toBe("media")
if (media?.type !== "media") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
expect(resized.get_width()).toBeLessThanOrEqual(4)
expect(resized.get_height()).toBeLessThanOrEqual(2_000)
resized.free()
}),
)
it.effect("enforces max base64 bytes after resize attempts", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = Buffer.from(png, "base64").length
real = "/project/pixel.png"
afterApproval = () => {}
sample = Buffer.from(png, "base64")
readContent = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "image/png",
})
readFailure = undefined
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
}),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-max-bytes", name: "read", input: { path: "pixel.png" } },
})
expect(result.type).toBe("error")
if (result.type === "error") expect(result.value).toContain("/1 bytes")
}),
)
it.effect("classifies supported image contents before a misleading binary extension", () =>
Effect.gen(function* () {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = Buffer.from(png, "base64").length
real = "/project/pixel.bin"
afterApproval = () => {}
sample = Buffer.from(png, "base64")
readContent = new FileSystem.BinaryContent({
type: "binary",
content: png,
encoding: "base64",
mime: "application/octet-stream",
})
readFailure = undefined
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-disguised-image", name: "read", input: { path: "pixel.bin" } },
}),
).toMatchObject({
type: "content",
value: [{ type: "text" }, { type: "media", mediaType: "image/png", filename: "pixel.bin" }],
})
}),
)
it.effect("rejects unsupported binary before direct reads or paging", () =>
Effect.gen(function* () {
reads.length = 0
textPageInputs.length = 0
samples.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/archive.dat"
afterApproval = () => {}
sample = new Uint8Array([0, 1, 2, 3])
readFailure = undefined
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-binary",
name: "read",
input: { path: "archive.dat", offset: 2, limit: 1 },
},
}),
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
expect(reads).toEqual([])
expect(textPageInputs).toEqual([])
}),
)
it.effect("does not read when permission is denied", () =>
Effect.gen(function* () {
assertions.length = 0
@@ -504,6 +231,120 @@ describe("ReadTool", () => {
}),
)
it.effect("reads an opaque managed resource without treating it as a path", () =>
Effect.gen(function* () {
resourceReads.length = 0
assertions.length = 0
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: {
type: "tool-call",
id: "call-read-resource",
name: "read",
input: { resource: "tool-output://opaque", offset: 2, limit: 10 },
},
}),
).toEqual({
type: "json",
value: {
resource: { uri: "tool-output://opaque", mime: "text/plain", size: 5 },
content: "hello",
offset: 2,
truncated: false,
},
})
expect(resourceReads).toEqual([{ sessionID, uri: "tool-output://opaque", offset: 2, limit: 10 }])
expect(assertions).toEqual([])
}),
)
it.effect("returns supported images as model-native media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1)
const content = Buffer.from(source.get_bytes()).toString("base64")
source.free()
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 4
real = "/project/image.png"
afterApproval = () => {}
readFailure = undefined
readContent = new FileSystem.BinaryContent({
type: "binary",
content,
encoding: "base64",
mime: "image/png",
})
sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-image", name: "read", input: { path: "image.png" } },
}),
).toEqual({
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: content, filename: "image.png" },
],
})
expect(samples.at(-1)).toBe(FileSystem.READ_SAMPLE_BYTES)
}),
)
it.effect("applies configured image dimension limits before returning media", () =>
Effect.gen(function* () {
const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 16 * 4 }, () => 255)), 16, 1)
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = source.get_bytes().length
real = "/project/wide.png"
afterApproval = () => {}
readFailure = undefined
readContent = new FileSystem.BinaryContent({
type: "binary",
content: Buffer.from(source.get_bytes()).toString("base64"),
encoding: "base64",
mime: "image/png",
})
source.free()
sample = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
configEntries = [
new Config.Document({
type: "document",
info: new Config.Info({
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_width: 4, max_height: 4 }),
}),
}),
}),
]
const registry = yield* ToolRegistry.Service
const result = yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-resize-image", name: "read", input: { path: "wide.png" } },
})
expect(result.type).toBe("content")
if (result.type !== "content") return
const media = result.value[1]
expect(media?.type).toBe("media")
if (media?.type !== "media") return
const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(media.data, "base64"))
expect(resized.get_width()).toBeLessThanOrEqual(4)
expect(resized.get_height()).toBeLessThanOrEqual(4)
resized.free()
}),
)
it.effect("lists a bounded directory page through read", () =>
Effect.gen(function* () {
assertions.length = 0
@@ -626,8 +467,10 @@ describe("ReadTool", () => {
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/large.txt"
afterApproval = () => {}
sample = new TextEncoder().encode("hello")
readFailure = undefined
readContent = new FileSystem.TextContent({ type: "text", content: "hello", mime: "text/plain" })
sample = new TextEncoder().encode("hello")
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
@@ -648,75 +491,51 @@ describe("ReadTool", () => {
}),
)
it.effect("preserves safe read limit errors", () =>
Effect.gen(function* () {
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/changed.txt"
afterApproval = () => {}
sample = new TextEncoder().encode("hello")
readFailure = new FileSystem.ReadLimitError("changed.txt", FileSystem.MAX_READ_BYTES)
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-read-limit", name: "read", input: { path: "changed.txt" } },
}),
).toEqual({
type: "error",
value: `File exceeds ${FileSystem.MAX_READ_BYTES} byte read limit: changed.txt`,
})
}),
)
it.effect("preserves binary errors discovered after the sample", () =>
it.effect("reports the binary file that cannot be paged", () =>
Effect.gen(function* () {
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = FileSystem.MAX_READ_BYTES + 1
real = "/project/late-binary"
real = "/project/archive.zip"
afterApproval = () => {}
sample = new TextEncoder().encode("text prefix")
readFailure = new FileSystem.BinaryFileError("late-binary")
readFailure = new FileSystem.BinaryFileError("archive.zip")
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-late-binary", name: "read", input: { path: "late-binary" } },
call: { type: "tool-call", id: "call-binary", name: "read", input: { path: "archive.zip" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
).toEqual({ type: "error", value: "Cannot read binary file: archive.zip" })
}),
)
it.effect("rejects unsupported binary discovered by a direct read", () =>
it.effect("rejects unsupported binary files before reading or paging them", () =>
Effect.gen(function* () {
reads.length = 0
textPageInputs.length = 0
samples.length = 0
allow = true
resolveFailure = undefined
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/late-binary"
size = 4
real = "/project/archive.dat"
afterApproval = () => {}
sample = new TextEncoder().encode("text prefix")
readFailure = undefined
readContent = new FileSystem.BinaryContent({
type: "binary",
content: "AAECAw==",
encoding: "base64",
mime: "application/octet-stream",
})
sample = new Uint8Array([0, 1, 2, 3])
configEntries = []
const registry = yield* ToolRegistry.Service
expect(
yield* registry.execute({
sessionID,
call: { type: "tool-call", id: "call-direct-binary", name: "read", input: { path: "late-binary" } },
call: { type: "tool-call", id: "call-small-binary", name: "read", input: { path: "archive.dat" } },
}),
).toEqual({ type: "error", value: "Cannot read binary file: late-binary" })
).toEqual({ type: "error", value: "Cannot read binary file: archive.dat" })
expect(samples).toEqual([FileSystem.READ_SAMPLE_BYTES])
expect(reads).toEqual([])
expect(textPageInputs).toEqual([])
}),
)
@@ -729,8 +548,6 @@ describe("ReadTool", () => {
listResolveFailure = new Error("not a directory")
size = 5
real = "/project/README.md"
sample = new TextEncoder().encode("hello")
readFailure = undefined
afterApproval = () => {
real = "/outside/README.md"
}

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