chore: merge v2

This commit is contained in:
Aiden Cline 2026-07-06 23:04:26 -05:00
commit 34b4fa9543
156 changed files with 10182 additions and 5610 deletions

1
.gitignore vendored
View file

@ -13,6 +13,7 @@ tmp
dist dist
ts-dist ts-dist
.turbo .turbo
.typecheck-profiles
**/.serena **/.serena
.serena/ .serena/
**/.omo **/.omo

View file

@ -1,163 +0,0 @@
---
name: debug-opencode
description: Use when interactively running, debugging, or verifying opencode's own V2 CLI/TUI or server during development in this repo — starting the dev TUI, driving it with termctrl, comparing V2 against the legacy TUI, hitting the V2 server/API directly, reading log files, or attaching Bun's inspector.
---
# Debugging opencode itself
Workflow for interactively exercising the V2 CLI/TUI and server while developing in this repo. All commands below run from `packages/cli` unless noted otherwise.
## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI (see "Comparing V2 against the legacy TUI" below) rather than guessing.
## Server/client model
opencode V2 is a client/server system, not a single monolithic process:
- **Server process** runs the Effect HTTP API (`packages/server`) and owns all domain state: sessions, database, plugins, permissions, Location services. It's started by the `serve` command (`packages/cli/src/commands/handlers/serve.ts`).
- **TUI process** is a separate process that runs no application logic itself — it's an HTTP/SSE client of the server via the generated SDK (`createOpencodeClient` / `sdk.client.v2`).
- **Discovery**: CLI processes find the shared server through a JSON registration file at `~/.local/state/opencode/service.json` (or `service-local.json` for the local/dev channel) containing `{id, version, url, pid}`. A separate password file under `~/.config/opencode/service.json` provides HTTP Basic auth. Before reusing a registration, the client calls `GET /health` to confirm the server is alive, authenticated, and version-compatible.
- **Sharing**: because of this registration/health-check dance, many concurrent `opencode`/TUI invocations converge on one shared background daemon rather than each spawning their own. If no compatible healthy daemon is found, a new one is spawned detached (`serve --service`) and registers itself.
- **`bun dev service start|status|stop|restart`** manages this shared background daemon's lifecycle directly — useful when you need to force a fresh server, confirm one is running, or kill a stuck one.
- **Standalone mode** (`--standalone`) opts a single invocation out of the shared daemon: it spawns a private one-off `serve --stdio --port 0` child tied to that invocation's lifetime, with its own random password. Use this to isolate a debugging session from your other running opencode sessions.
- Every log line is tagged `role=server` or `role=cli` and a per-process `run=<id>`, so you can distinguish server-side and client-side activity in one shared log file (see "Logs" below) even when both roles are interleaved from concurrent processes.
## Starting the dev TUI
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
## Interactive debugging with termctrl
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Comparing V2 against the legacy TUI
Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states:
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Auditing installed `opencode2` sessions
Installed next-channel sessions normally use `~/.local/share/opencode/opencode-next.db` and `~/.local/share/opencode/log/opencode.log`; `OPENCODE_DB` can override the database. Before calling `opencode2 api`, inspect `~/.local/state/opencode/service.json` because the command may start a daemon when none is healthy.
For a supplied `ses_...` ID, compare three sources:
- `opencode2 api get /api/session/active` and the Session/message endpoints for live server state.
- The database's ordered `event` rows for durable history.
- `packages/tui/src/context/data.tsx` and the relevant route for client projection and rendering.
Locate an uncertain database without modifying it:
```bash
SESSION=ses_...
for db in ~/.local/share/opencode/*.db; do
sqlite3 "file:$db?mode=ro" "select 1 from session where id='$SESSION' limit 1" 2>/dev/null | grep -q 1 && printf '%s\n' "$db"
done
```
## Logs
- Log files live under `~/.local/share/opencode/log/`. In a local/dev checkout the active file is `opencode-local.log`; `opencode.log` is used for non-local (released) channel installs. Both are append-only, shared across every CLI and server process on the machine.
- Each line is structured `key=value` text: `timestamp`, `level`, `run=<id>` (per-process run ID), `message`, and a `role=cli` or `role=server` tag. Use `run=` to isolate one process's activity and `role=` to separate client-side from server-side log lines, since a shared daemon interleaves many processes' output in one file.
- Tail the live file while reproducing an issue instead of guessing from stale output:
```bash
tail -f ~/.local/share/opencode/log/opencode-local.log
```
- Filter to one run or role when the file is noisy:
```bash
grep 'run=8fc3b1d5' ~/.local/share/opencode/log/opencode-local.log
grep 'role=server' ~/.local/share/opencode/log/opencode-local.log
```
- `OPENCODE_LOG_LEVEL` controls verbosity (default `INFO`); set it before starting `bun dev` or `serve` to get `DEBUG` output for a specific repro.
- `OPENCODE_PRINT_LOGS=1` additionally tees log output to stderr of the process that emitted it, which is useful when a process fails before you'd think to check the shared log file.
- `termctrl logs <session>` surfaces stdout/stderr for a Terminal Control session specifically (e.g. inspector output or startup failures before the TUI renderer starts) — use the log file above for anything emitted by a separate server/daemon process instead.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.

1000
bun.lock

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,9 @@
"lint": "oxlint", "lint": "oxlint",
"lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src", "lint:effect-patterns": "ast-grep scan -c script/ast-grep/sgconfig.yml packages/core/src packages/server/src packages/protocol/src packages/cli/src",
"test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml", "test:lint-rules": "ast-grep test -c script/ast-grep/sgconfig.yml",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck --concurrency=3",
"typecheck:profile": "bun script/profile-typecheck.ts",
"typecheck:profile:packages": "bun script/profile-typecheck-packages.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts", "upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/core fix-node-pty", "postinstall": "bun run --cwd packages/core fix-node-pty",
"prepare": "husky", "prepare": "husky",

View file

@ -126,9 +126,9 @@ describe("enqueueServerEvent", () => {
enqueue(partUpdated("old")) enqueue(partUpdated("old"))
enqueue({ enqueue({
id: "event", id: "event-delete",
type: "session.deleted", type: "session.deleted",
properties: { sessionID: "session", info: { id: "session" } }, properties: { sessionID: "session" },
} as Event) } as Event)
enqueue(partUpdated("new")) enqueue(partUpdated("new"))

View file

@ -3,96 +3,5 @@
## Migration context ## Migration context
- The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state. - The TUI is being ported from legacy APIs to the new V2 APIs. New and migrated TUI behavior should use `sdk.client.v2` and the location-scoped data in `packages/tui/src/context/data.tsx` instead of adding dependencies on legacy sync state.
- Preserve established TUI behavior unless the task intentionally changes it. When behavior, copy, keyboard interaction, or layout is unclear, compare the local V2 TUI with the latest released legacy TUI. - Preserve established TUI behavior unless the task intentionally changes it.
- Run both versions in separate Terminal Control sessions and save PNG-only captures at equivalent states: - Load the `opencode-dev` skill before interactively running, debugging, or verifying opencode's V2 CLI, TUI, or server.
```bash
# From packages/cli: local V2 TUI
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
# Released legacy TUI behavior reference
termctrl start opencode-legacy --host opentui --cols 112 --rows 34 -- bunx opencode-ai@latest
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2.png
termctrl save opencode-legacy --format png --out /tmp/opencode/legacy.png
```
- Use the same viewport and send equivalent inputs to both sessions before comparing screenshots. The released CLI is a behavioral reference, not a source of V2 API design; keep the local implementation on V2 endpoints.
- Stop both sessions after comparison: `termctrl stop opencode-v2-dev` and `termctrl stop opencode-legacy`.
## Interactive debugging
- This package is the V2 CLI adapter. Run its `dev` script when testing the TUI; do not use the repository-root `bun dev`, which launches the legacy `packages/opencode` CLI.
- Run commands from `packages/cli`. Use `bun dev` for most debugging so the TUI starts with a private V2 server.
- Use `termctrl` for interactive checks instead of starting the TUI as a blocking foreground process. It provides a real PTY, handles OpenTUI's host handshake, and can save reviewable screenshots.
- Use a dedicated session name and do not reuse or kill an unrelated session.
```bash
termctrl start opencode-v2-dev --host opentui --cols 112 --rows 34 -- bun dev
termctrl wait opencode-v2-dev "Ask anything" --timeout 20000
termctrl show opencode-v2-dev
```
- Wait for visible text before interacting instead of relying on fixed sleeps. Use the text expected from the screen under test, such as `Ask anything` or `Connect a provider`.
- Drive the running TUI with `termctrl send`. Prefix typed input with `text:` and send control keys separately so the interaction matches real terminal input.
```bash
termctrl send opencode-v2-dev 'text:example prompt' enter
termctrl send opencode-v2-dev ctrl-c
```
- Use `termctrl show` after each meaningful interaction and inspect the full visible screen for rendering errors, stale state, error toasts, and unexpected exits.
- Save PNG evidence for every user-visible bug and fix. Do not save text captures; inspect the rendered PNG. Write temporary captures outside the repository unless the artifact is intended to be committed.
```bash
termctrl save opencode-v2-dev --format png --out /tmp/opencode/v2-tui.png
```
- For resize-sensitive changes, resize the viewport, wait for the expected content, and capture the screen again:
```bash
termctrl resize opencode-v2-dev --cols 100 --rows 30
termctrl show opencode-v2-dev
```
- Source changes may require restarting the process. Use `termctrl restart opencode-v2-dev` rather than assuming the running TUI reloaded the change.
- To exercise background-service behavior, use `bun dev service start`, `bun dev service status`, and `bun dev service stop`.
- Always clean up the Terminal Control session when the check is complete:
```bash
termctrl stop opencode-v2-dev
```
## Server/API debugging
- Use `bun dev api --help` from `packages/cli` to inspect the API debugging command. It sends one request to the V2 server using the same daemon discovery/auth path as the CLI.
- Use `bun dev api` to introspect the server-side data backing the TUI. This is useful when debugging UI bugs: compare what the screen renders with the raw session, message, event, agent, or health data returned by the API to determine whether the bug is in the server state, the client data layer, or the TUI rendering.
- `bun dev api` accepts either an OpenAPI operation ID or a raw HTTP method plus path:
```bash
bun dev api get /health
bun dev api get /openapi.json
bun dev api <operationId> --param key=value
```
- Pass JSON request bodies with `--data`/`-d`; the command sets `content-type: application/json` automatically unless you provide a header. Add extra headers with `--header`/`-H name:value`.
- If no compatible background server is registered, `bun dev api` starts one through the daemon service. Use `bun dev service status`, `bun dev service restart`, and `bun dev service stop` when you need explicit lifecycle control.
- Prefer raw method/path calls for quick server debugging and operation IDs when exercising documented OpenAPI routes with path or query parameters.
## Debugger
- To debug the V2 CLI or TUI with Bun's inspector, launch the CLI entrypoint through Terminal Control with an inspector URL, then attach a debugger to that URL:
```bash
termctrl start opencode-v2-debug --host opentui --cols 112 --rows 34 -- \
bun run --inspect=ws://localhost:6499/ src/index.ts
```
- Use `--inspect-wait` or `--inspect-brk` when execution must pause until the debugger attaches.
- Use `termctrl logs opencode-v2-debug` for inspector output or startup failures emitted before the TUI renderer starts. Use `termctrl show` for the visible full-screen TUI.
## Verification
- Run `bun typecheck` from `packages/cli` after CLI adapter changes.
- Run `bun typecheck` and `bun test` from `packages/tui` after shared TUI changes. Do not run tests from the repository root.
- Treat automated checks and Terminal Control smoke tests as complementary. For user-visible changes, verify initial render, the changed interaction, Ctrl-C exit behavior, and save a screenshot of the corrected state.

View file

@ -18,7 +18,6 @@ await rm("dist", { recursive: true, force: true })
const singleFlag = process.argv.includes("--single") const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline") const baselineFlag = process.argv.includes("--baseline")
const skipInstall = process.argv.includes("--skip-install") const skipInstall = process.argv.includes("--skip-install")
const sourcemapsFlag = process.argv.includes("--sourcemaps")
const plugin = createSolidTransformPlugin() const plugin = createSolidTransformPlugin()
const allTargets: { const allTargets: {
@ -74,7 +73,7 @@ for (const item of targets) {
external: ["node-gyp"], external: ["node-gyp"],
format: "esm", format: "esm",
minify: true, minify: true,
sourcemap: sourcemapsFlag ? "linked" : "none", sourcemap: "inline",
splitting: true, splitting: true,
compile: { compile: {
autoloadBunfig: false, autoloadBunfig: false,

View file

@ -1,14 +1,5 @@
import type { import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
EventSubscribeOutput, import type { ReasoningPart, StepFinishPart, StepStartPart, TextPart, ToolPart } from "@opencode-ai/sdk/v2"
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type {
ReasoningPart,
StepFinishPart,
StepStartPart,
TextPart,
ToolPart,
} from "@opencode-ai/sdk/v2"
import { SessionMessage } from "@opencode-ai/schema/session-message" import { SessionMessage } from "@opencode-ai/schema/session-message"
import { EOL } from "node:os" import { EOL } from "node:os"
import { UI } from "./ui" import { UI } from "./ui"
@ -169,8 +160,8 @@ export async function runNonInteractivePrompt(input: Input) {
} }
} }
if ( if (
event.type === "session.execution.settled" && event.type === "session.execution.interrupted" &&
event.data.outcome === "interrupted" && event.data.reason === "user" &&
(interrupted || permissionRejected || questionRejected || formCancelled) (interrupted || permissionRejected || questionRejected || formCancelled)
) { ) {
return return
@ -194,11 +185,12 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.text.started") { if (event.type === "session.text.started") {
starts.set(event.data.textID, { id: partID(event.id), timestamp: time }) starts.set("text", { id: partID(event.id), timestamp: time })
continue continue
} }
if (event.type === "session.text.ended") { if (event.type === "session.text.ended") {
const started = starts.get(event.data.textID) const started = starts.get("text")
starts.delete("text")
const part: TextPart = { const part: TextPart = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
@ -212,18 +204,19 @@ export async function runNonInteractivePrompt(input: Input) {
} }
if (event.type === "session.reasoning.started") { if (event.type === "session.reasoning.started") {
starts.set(event.data.reasoningID, { id: partID(event.id), timestamp: time }) starts.set("reasoning", { id: partID(event.id), timestamp: time })
continue continue
} }
if (event.type === "session.reasoning.ended" && input.thinking) { if (event.type === "session.reasoning.ended" && input.thinking) {
const started = starts.get(event.data.reasoningID) const started = starts.get("reasoning")
starts.delete("reasoning")
const part: ReasoningPart = { const part: ReasoningPart = {
id: started?.id ?? partID(event.id), id: started?.id ?? partID(event.id),
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
type: "reasoning", type: "reasoning",
text: event.data.text, text: event.data.text,
metadata: event.data.providerMetadata, metadata: event.data.state,
time: { start: started?.timestamp ?? time, end: time }, time: { start: started?.timestamp ?? time, end: time },
} }
if (emit("reasoning", time, { part })) continue if (emit("reasoning", time, { part })) continue
@ -261,10 +254,10 @@ export async function runNonInteractivePrompt(input: Input) {
id: current?.id ?? partID(event.id), id: current?.id ?? partID(event.id),
timestamp: current?.timestamp ?? time, timestamp: current?.timestamp ?? time,
assistantMessageID: event.data.assistantMessageID, assistantMessageID: event.data.assistantMessageID,
tool: event.data.tool, tool: current?.tool ?? "tool",
input: event.data.input, input: event.data.input,
raw: current?.raw, raw: current?.raw,
provider: event.data.provider, provider: { executed: event.data.executed, state: event.data.state },
}) })
continue continue
} }
@ -291,7 +284,7 @@ export async function runNonInteractivePrompt(input: Input) {
outputPaths: event.data.outputPaths, outputPaths: event.data.outputPaths,
result: event.data.result, result: event.data.result,
providerCall: current.provider, providerCall: current.provider,
providerResult: event.data.provider, providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw, rawInput: current.raw,
}, },
time: { start: current.timestamp, end: time }, time: { start: current.timestamp, end: time },
@ -318,7 +311,7 @@ export async function runNonInteractivePrompt(input: Input) {
metadata: { metadata: {
result: event.data.result, result: event.data.result,
providerCall: current.provider, providerCall: current.provider,
providerResult: event.data.provider, providerResult: { executed: event.data.executed, state: event.data.resultState },
rawInput: current.raw, rawInput: current.raw,
}, },
time: { start: current.timestamp, end: time }, time: { start: current.timestamp, end: time },
@ -353,16 +346,25 @@ export async function runNonInteractivePrompt(input: Input) {
if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message) if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
continue continue
} }
if (event.type === "session.execution.settled") { if (event.type === "session.execution.failed") {
if (event.data.outcome === "failure" && !emittedError && !questionRejected && !formCancelled) { if (!emittedError && !questionRejected && !formCancelled) {
emittedError = true emittedError = true
process.exitCode = 1 process.exitCode = 1
const error = event.data.error ?? { type: "unknown", message: "Session execution failed" } if (!emit("error", time, { error: event.data.error })) UI.error(event.data.error.message)
if (!emit("error", time, { error })) UI.error(error.message)
} }
if (event.data.outcome === "interrupted" && interrupted) process.exitCode = 130
return return
} }
if (event.type === "session.execution.interrupted") {
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
if (!emit("error", time, { error })) UI.error(error.message)
}
return
}
if (event.type === "session.execution.succeeded") return
} }
} }

View file

@ -35,54 +35,60 @@ export function outputText(content: ReadonlyArray<{ type: string; text?: string
export function legacyTool(input: { export function legacyTool(input: {
sessionID: string sessionID: string
messageID: string messageID: string
callID: string tool: SessionMessageAssistantTool
name: string
state: SessionMessageAssistantTool["state"]
time: SessionMessageAssistantTool["time"]
provider?: SessionMessageAssistantTool["provider"]
}): ToolPart { }): ToolPart {
const tool = input.tool
const providerCall =
tool.executed === undefined && tool.providerState === undefined
? undefined
: { executed: tool.executed, state: tool.providerState }
const providerResult =
tool.executed === undefined && tool.providerResultState === undefined
? undefined
: { executed: tool.executed, state: tool.providerResultState }
const base = { const base = {
id: `prt_${input.callID}`, id: `prt_${tool.id}`,
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.messageID, messageID: input.messageID,
type: "tool" as const, type: "tool" as const,
callID: input.callID, callID: tool.id,
tool: input.name, tool: tool.name,
} }
if (input.state.status === "pending") { if (tool.state.status === "pending") {
return { return {
...base, ...base,
state: { status: "pending", input: {}, raw: input.state.input }, state: { status: "pending", input: {}, raw: tool.state.input },
} }
} }
if (input.state.status === "running") { if (tool.state.status === "running") {
return { return {
...base, ...base,
state: { state: {
status: "running", status: "running",
input: input.state.input, input: tool.state.input,
title: input.name, title: tool.name,
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider }, metadata: { structured: tool.state.structured, content: tool.state.content, providerCall },
time: { start: input.time.ran ?? input.time.created }, time: { start: tool.time.ran ?? tool.time.created },
}, },
} }
} }
if (input.state.status === "completed") { if (tool.state.status === "completed") {
return { return {
...base, ...base,
state: { state: {
status: "completed", status: "completed",
input: input.state.input, input: tool.state.input,
output: outputText(input.state.content), output: outputText(tool.state.content),
title: input.name, title: tool.name,
metadata: { metadata: {
structured: input.state.structured, structured: tool.state.structured,
content: input.state.content, content: tool.state.content,
outputPaths: input.state.outputPaths, outputPaths: tool.state.outputPaths,
result: input.state.result, result: tool.state.result,
providerCall: input.provider, providerCall,
providerResult,
}, },
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
}, },
} }
} }
@ -90,15 +96,16 @@ export function legacyTool(input: {
...base, ...base,
state: { state: {
status: "error", status: "error",
input: input.state.input, input: tool.state.input,
error: input.state.error.message, error: tool.state.error.message,
metadata: { metadata: {
structured: input.state.structured, structured: tool.state.structured,
content: input.state.content, content: tool.state.content,
result: input.state.result, result: tool.state.result,
providerCall: input.provider, providerCall,
providerResult,
}, },
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created }, time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created },
}, },
} }
} }
@ -138,6 +145,7 @@ type ToolTrack = {
name: string name: string
input: Record<string, unknown> input: Record<string, unknown>
started: number started: number
providerState?: Record<string, unknown>
} }
type ChildState = { type ChildState = {
@ -225,6 +233,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const hydrationOverflow = new Set<string>() const hydrationOverflow = new Set<string>()
const hydrations = new Map<string, Promise<void>>() const hydrations = new Map<string, Promise<void>>()
let selected: string | undefined let selected: string | undefined
const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}`
const ensureChild = (sessionID: string): ChildState => { const ensureChild = (sessionID: string): ChildState => {
const existing = children.get(sessionID) const existing = children.get(sessionID)
@ -305,11 +314,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
const part = legacyTool({ const part = legacyTool({
sessionID: child.sessionID, sessionID: child.sessionID,
messageID, messageID,
callID: item.id, tool: item,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
}) })
if (item.state.status === "pending") return if (item.state.status === "pending") return
child.callIDs.add(item.id) child.callIDs.add(item.id)
@ -339,31 +344,37 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
} }
if (message.type !== "assistant") continue if (message.type !== "assistant") continue
child.messageIDs.add(message.id) child.messageIDs.add(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) { for (const item of message.content) {
if (item.type === "text") { if (item.type === "text") {
child.text.set(item.id, item.text) const id = `text:${textOrdinal++}`
child.projectedText.set(item.id, item.text) const key = fragmentKey(message.id, id)
setFrame(child, `text:${item.id}`, { child.text.set(key, item.text)
child.projectedText.set(key, item.text)
setFrame(child, key, {
kind: "assistant", kind: "assistant",
source: "assistant", source: "assistant",
text: item.text, text: item.text,
phase: "progress", phase: "progress",
messageID: message.id, messageID: message.id,
partID: item.id, partID: id,
}) })
continue continue
} }
if (item.type === "reasoning") { if (item.type === "reasoning") {
child.reasoning.set(item.id, item.text) const id = `reasoning:${reasoningOrdinal++}`
child.projectedReasoning.set(item.id, item.text) const key = fragmentKey(message.id, id)
child.reasoning.set(key, item.text)
child.projectedReasoning.set(key, item.text)
if (input.thinking) if (input.thinking)
setFrame(child, `reasoning:${item.id}`, { setFrame(child, key, {
kind: "reasoning", kind: "reasoning",
source: "reasoning", source: "reasoning",
text: `Thinking: ${item.text}`, text: `Thinking: ${item.text}`,
phase: "progress", phase: "progress",
messageID: message.id, messageID: message.id,
partID: item.id, partID: id,
}) })
continue continue
} }
@ -467,74 +478,88 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
input.emit() input.emit()
return return
} }
if (event.type === "session.text.started") {
return
}
if (event.type === "session.text.delta") { if (event.type === "session.text.delta") {
const projected = child.projectedText.get(event.data.textID) const id = `text:${event.data.ordinal}`
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length)) child.projectedText.set(key, projected.slice(covered + event.data.delta.length))
return return
} }
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta const next = (child.text.get(key) ?? "") + event.data.delta
child.text.set(event.data.textID, next) child.text.set(key, next)
setFrame(child, `text:${event.data.textID}`, { setFrame(child, key, {
kind: "assistant", kind: "assistant",
source: "assistant", source: "assistant",
text: next, text: next,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: id,
}) })
touch(child, event.created) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.text.ended") { if (event.type === "session.text.ended") {
child.text.set(event.data.textID, event.data.text) const id = `text:${event.data.ordinal}`
child.projectedText.delete(event.data.textID) const key = fragmentKey(event.data.assistantMessageID, id)
setFrame(child, `text:${event.data.textID}`, { child.text.set(key, event.data.text)
child.projectedText.delete(key)
setFrame(child, key, {
kind: "assistant", kind: "assistant",
source: "assistant", source: "assistant",
text: event.data.text, text: event.data.text,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: id,
}) })
touch(child, event.created) touch(child, event.created)
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.reasoning.started") {
return
}
if (event.type === "session.reasoning.delta") { if (event.type === "session.reasoning.delta") {
const projected = child.projectedReasoning.get(event.data.reasoningID) const id = `reasoning:${event.data.ordinal}`
const key = fragmentKey(event.data.assistantMessageID, id)
const projected = child.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length)) child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length))
return return
} }
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta const next = (child.reasoning.get(key) ?? "") + event.data.delta
child.reasoning.set(event.data.reasoningID, next) child.reasoning.set(key, next)
if (!input.thinking) return if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, { setFrame(child, key, {
kind: "reasoning", kind: "reasoning",
source: "reasoning", source: "reasoning",
text: `Thinking: ${next}`, text: `Thinking: ${next}`,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.reasoningID, partID: id,
}) })
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.reasoning.ended") { if (event.type === "session.reasoning.ended") {
child.reasoning.set(event.data.reasoningID, event.data.text) const id = `reasoning:${event.data.ordinal}`
child.projectedReasoning.delete(event.data.reasoningID) const key = fragmentKey(event.data.assistantMessageID, id)
child.reasoning.set(key, event.data.text)
child.projectedReasoning.delete(key)
if (!input.thinking) return if (!input.thinking) return
setFrame(child, `reasoning:${event.data.reasoningID}`, { setFrame(child, key, {
kind: "reasoning", kind: "reasoning",
source: "reasoning", source: "reasoning",
text: `Thinking: ${event.data.text}`, text: `Thinking: ${event.data.text}`,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.reasoningID, partID: id,
}) })
notifyDetail(child) notifyDetail(child)
return return
@ -548,17 +573,19 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
if (child.finishedTools.has(event.data.callID)) return if (child.finishedTools.has(event.data.callID)) return
const current = child.tools.get(event.data.callID) const current = child.tools.get(event.data.callID)
child.tools.set(event.data.callID, { child.tools.set(event.data.callID, {
name: event.data.tool, name: current?.name ?? "tool",
input: event.data.input, input: event.data.input,
started: current?.started ?? event.created, started: current?.started ?? event.created,
providerState: event.data.state,
}) })
childTool( childTool(
child, child,
structuredClone({ structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.tool, name: current?.name ?? "tool",
provider: event.data.provider, executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created }, time: { created: current?.started ?? event.created, ran: event.created },
}) as SessionMessageAssistantTool, }) as SessionMessageAssistantTool,
@ -578,7 +605,9 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: current?.name ?? "tool", name: current?.name ?? "tool",
provider: event.data.provider, executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
state: failed state: failed
? { ? {
status: "error", status: "error",
@ -608,6 +637,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.step.ended") return
if (event.type === "session.step.failed") { if (event.type === "session.step.failed") {
setFrame(child, `error:step:${event.data.assistantMessageID}`, { setFrame(child, `error:step:${event.data.assistantMessageID}`, {
kind: "error", kind: "error",
@ -620,9 +650,23 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
notifyDetail(child) notifyDetail(child)
return return
} }
if (event.type === "session.execution.settled") { if (event.type === "session.execution.started") {
child.status = "running"
touch(child, event.created)
input.emit()
return
}
if (
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
child.status = child.status =
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error" event.type === "session.execution.succeeded"
? "completed"
: event.type === "session.execution.interrupted"
? "cancelled"
: "error"
touch(child, event.created) touch(child, event.created)
input.emit() input.emit()
} }
@ -644,8 +688,12 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
return { return {
main(event) { main(event) {
if (event.type === "session.tool.input.started") {
if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {})
return
}
if (event.type === "session.tool.called") { if (event.type === "session.tool.called") {
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input) if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input)
return return
} }
if (event.type === "session.tool.failed") { if (event.type === "session.tool.failed") {

View file

@ -1,12 +1,8 @@
import type { import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise"
EventSubscribeOutput,
OpenCodeClient,
} from "@opencode-ai/client/promise"
import type { import type {
PermissionRequest, PermissionRequest,
QuestionRequest, QuestionRequest,
SessionMessage, SessionMessage,
SessionMessageAssistant,
SessionMessageAssistantTool, SessionMessageAssistantTool,
} from "@opencode-ai/sdk/v2" } from "@opencode-ai/sdk/v2"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
@ -101,6 +97,7 @@ type ToolState = {
input: Record<string, unknown> input: Record<string, unknown>
started: number started: number
running: boolean running: boolean
providerState?: Record<string, unknown>
} }
type State = { type State = {
@ -264,8 +261,7 @@ function shellTerminal(
: shell.status === "exited" : shell.status === "exited"
? `Shell exited with code ${shell.exit ?? "unknown"}` ? `Shell exited with code ${shell.exit ?? "unknown"}`
: `Shell ${shell.status}` : `Shell ${shell.status}`
if (!error) if (!error) return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
return [shellCommit(callID, command, { text, phase: "progress", toolState: "completed" })]
return [ return [
...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []), ...(text ? [shellCommit(callID, command, { text, phase: "progress", toolState: "running" })] : []),
shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }), shellCommit(callID, command, { text: error, phase: "final", toolState: "error", toolError: error }),
@ -310,9 +306,7 @@ async function resolveSelectedModel(input: StreamInput, next: Pick<SessionTurnIn
.then((response) => response.model) .then((response) => response.model)
if (session) return { ...session, variant: next.variant } if (session) return { ...session, variant: next.variant }
const fallback = await input.sdk.model const fallback = await input.sdk.model.default(undefined, { signal: next.signal }).then((response) => response.data)
.default(undefined, { signal: next.signal })
.then((response) => response.data)
if (!fallback) return if (!fallback) return
return { providerID: fallback.providerID, id: fallback.id, variant: next.variant } return { providerID: fallback.providerID, id: fallback.id, variant: next.variant }
} }
@ -393,11 +387,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const part = legacyTool({ const part = legacyTool({
sessionID: input.sessionID, sessionID: input.sessionID,
messageID, messageID,
callID: item.id, tool: item,
name: item.name,
state: item.state,
time: item.time,
provider: item.provider,
}) })
if (item.state.status === "pending") return if (item.state.status === "pending") return
if (item.state.status === "running") { if (item.state.status === "running") {
@ -408,6 +398,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
input: item.state.input, input: item.state.input,
started: item.time.ran ?? item.time.created, started: item.time.ran ?? item.time.created,
running: true, running: true,
providerState: item.providerState,
}) })
write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` }) write([toolCommit(part, "start")], { phase: "running", status: `running ${item.name}` })
return return
@ -479,9 +470,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
if (message.type !== "assistant") return if (message.type !== "assistant") return
state.messageIDs.add(message.id) state.messageIDs.add(message.id)
let textOrdinal = 0
let reasoningOrdinal = 0
for (const item of message.content) { for (const item of message.content) {
if (item.type === "text") { if (item.type === "text") {
const key = streamPartKey(message.id, item.id) const id = `text:${textOrdinal++}`
const key = streamPartKey(message.id, id)
const sent = state.text.get(key)?.length ?? 0 const sent = state.text.get(key)?.length ?? 0
state.text.set(key, item.text) state.text.set(key, item.text)
if (render) state.projectedText.set(key, item.text) if (render) state.projectedText.set(key, item.text)
@ -493,13 +487,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: item.text.slice(sent), text: item.text.slice(sent),
phase: "progress", phase: "progress",
messageID: message.id, messageID: message.id,
partID: item.id, partID: id,
}, },
]) ])
continue continue
} }
if (item.type === "reasoning") { if (item.type === "reasoning") {
const key = streamPartKey(message.id, item.id) const id = `reasoning:${reasoningOrdinal++}`
const key = streamPartKey(message.id, id)
const sent = state.reasoning.get(key)?.length ?? 0 const sent = state.reasoning.get(key)?.length ?? 0
state.reasoning.set(key, item.text) state.reasoning.set(key, item.text)
if (render) state.projectedReasoning.set(key, item.text) if (render) state.projectedReasoning.set(key, item.text)
@ -511,7 +506,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent), text: sent === 0 ? `Thinking: ${item.text}` : item.text.slice(sent),
phase: "progress", phase: "progress",
messageID: message.id, messageID: message.id,
partID: item.id, partID: id,
}, },
]) ])
continue continue
@ -626,8 +621,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
if (owned) wait.resolve() if (owned) wait.resolve()
return return
} }
if (event.type === "session.text.started") {
return
}
if (event.type === "session.text.delta") { if (event.type === "session.text.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID) const id = `text:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedText.get(key) const projected = state.projectedText.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
@ -643,13 +642,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.delta, text: event.data.delta,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: id,
}, },
]) ])
return return
} }
if (event.type === "session.text.ended") { if (event.type === "session.text.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.textID) const id = `text:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.text.get(key) ?? "" const previous = state.text.get(key) ?? ""
if (event.data.text.length > previous.length) if (event.data.text.length > previous.length)
write([ write([
@ -659,15 +659,19 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: event.data.text.slice(previous.length), text: event.data.text.slice(previous.length),
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.textID, partID: id,
}, },
]) ])
state.text.set(key, event.data.text) state.text.set(key, event.data.text)
state.projectedText.delete(key) state.projectedText.delete(key)
return return
} }
if (event.type === "session.reasoning.started") {
return
}
if (event.type === "session.reasoning.delta") { if (event.type === "session.reasoning.delta") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) const id = `reasoning:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const projected = state.projectedReasoning.get(key) const projected = state.projectedReasoning.get(key)
const covered = projected?.indexOf(event.data.delta) ?? -1 const covered = projected?.indexOf(event.data.delta) ?? -1
if (projected && covered >= 0) { if (projected && covered >= 0) {
@ -684,13 +688,14 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.delta : `Thinking: ${event.data.delta}`, text: previous ? event.data.delta : `Thinking: ${event.data.delta}`,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.reasoningID, partID: id,
}, },
]) ])
return return
} }
if (event.type === "session.reasoning.ended") { if (event.type === "session.reasoning.ended") {
const key = streamPartKey(event.data.assistantMessageID, event.data.reasoningID) const id = `reasoning:${event.data.ordinal}`
const key = streamPartKey(event.data.assistantMessageID, id)
const previous = state.reasoning.get(key) ?? "" const previous = state.reasoning.get(key) ?? ""
if (input.thinking && event.data.text.length > previous.length) if (input.thinking && event.data.text.length > previous.length)
write([ write([
@ -700,7 +705,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`, text: previous ? event.data.text.slice(previous.length) : `Thinking: ${event.data.text}`,
phase: "progress", phase: "progress",
messageID: event.data.assistantMessageID, messageID: event.data.assistantMessageID,
partID: event.data.reasoningID, partID: id,
}, },
]) ])
state.reasoning.set(key, event.data.text) state.reasoning.set(key, event.data.text)
@ -723,8 +728,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
const item = structuredClone({ const item = structuredClone({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.tool, name: current?.name ?? "tool",
provider: event.data.provider, executed: event.data.executed,
providerState: event.data.state,
state: { status: "running", input: event.data.input, structured: {}, content: [] }, state: { status: "running", input: event.data.input, structured: {}, content: [] },
time: { created: current?.started ?? event.created, ran: event.created }, time: { created: current?.started ?? event.created, ran: event.created },
}) as SessionMessageAssistantTool }) as SessionMessageAssistantTool
@ -739,7 +745,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: current?.name ?? "tool", name: current?.name ?? "tool",
provider: event.data.provider, executed: event.data.executed,
providerState: current?.providerState,
providerResultState: event.data.resultState,
state: failed state: failed
? { ? {
status: "error", status: "error",
@ -791,7 +799,6 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
event.data.tokens.cache.write event.data.tokens.cache.write
const usage = total > 0 ? total.toLocaleString() : "" const usage = total > 0 ? total.toLocaleString() : ""
write([], { write([], {
phase: event.data.finish === "tool-calls" ? "running" : "idle",
usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage, usage: event.data.cost ? `${usage} · ${money.format(event.data.cost)}` : usage,
}) })
return return
@ -802,21 +809,33 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }]) write([{ kind: "error", source: "system", text: errorMessage(event.data.error), phase: "start" }])
return return
} }
if (event.type === "session.execution.settled") { if (event.type === "session.execution.started") {
write([], { phase: "running" })
return
}
if (
event.type === "session.execution.succeeded" ||
event.type === "session.execution.failed" ||
event.type === "session.execution.interrupted"
) {
write([], { phase: "idle", status: "" }) write([], { phase: "idle", status: "" })
const current = state.wait const current = state.wait
if (!current || (!current.promoted && !current.interrupted)) return if (!current || (!current.promoted && !current.interrupted)) return
state.wait = undefined state.wait = undefined
if (current.interrupted) { if (current.interrupted && event.type === "session.execution.interrupted" && event.data.reason === "user") {
current.resolve() current.resolve()
return return
} }
if (event.data.outcome === "failure") { if (event.type === "session.execution.failed") {
if (current.failureRendered) { if (current.failureRendered) {
current.resolve() current.resolve()
return return
} }
current.reject(new Error(event.data.error ? errorMessage(event.data.error) : "Session execution failed")) current.reject(new Error(errorMessage(event.data.error)))
return
}
if (event.type === "session.execution.interrupted") {
current.reject(new Error(`Session interrupted: ${event.data.reason}`))
return return
} }
current.resolve() current.resolve()
@ -1014,18 +1033,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
} }
if (next.agent) { if (next.agent) {
await input.sdk.session.switchAgent( await input.sdk.session.switchAgent({ sessionID: input.sessionID, agent: next.agent }, { signal: next.signal })
{ sessionID: input.sessionID, agent: next.agent },
{ signal: next.signal },
)
} }
const selected = await resolveSelectedModel(input, next) const selected = await resolveSelectedModel(input, next)
if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model") if (next.variant && !selected) throw new Error("Cannot select a variant before selecting a model")
if (selected) if (selected)
await input.sdk.session.switchModel( await input.sdk.session.switchModel({ sessionID: input.sessionID, model: selected }, { signal: next.signal })
{ sessionID: input.sessionID, model: selected },
{ signal: next.signal },
)
const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile)) const prepared = await Promise.all((next.includeFiles ? next.files : []).map(prepareFile))
const attachments = [ const attachments = [

View file

@ -74,192 +74,201 @@ export type Endpoint4_3Input = { readonly sessionID: Endpoint4_3Request["params"
export type Endpoint4_3Output = EffectValue<ReturnType<RawClient["server.session"]["session.get"]>>["data"] export type Endpoint4_3Output = EffectValue<ReturnType<RawClient["server.session"]["session.get"]>>["data"]
export type SessionGetOperation<E = never> = (input: Endpoint4_3Input) => Effect.Effect<Endpoint4_3Output, E> export type SessionGetOperation<E = never> = (input: Endpoint4_3Input) => Effect.Effect<Endpoint4_3Output, E>
type Endpoint4_4Request = Parameters<RawClient["server.session"]["session.fork"]>[0] type Endpoint4_4Request = Parameters<RawClient["server.session"]["session.remove"]>[0]
export type Endpoint4_4Input = { export type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_4Request["params"]["sessionID"] export type Endpoint4_4Output = EffectValue<ReturnType<RawClient["server.session"]["session.remove"]>>
readonly messageID?: Endpoint4_4Request["payload"]["messageID"] export type SessionRemoveOperation<E = never> = (input: Endpoint4_4Input) => Effect.Effect<Endpoint4_4Output, E>
}
export type Endpoint4_4Output = EffectValue<ReturnType<RawClient["server.session"]["session.fork"]>>["data"]
export type SessionForkOperation<E = never> = (input: Endpoint4_4Input) => Effect.Effect<Endpoint4_4Output, E>
type Endpoint4_5Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint4_5Request = Parameters<RawClient["server.session"]["session.fork"]>[0]
export type Endpoint4_5Input = { export type Endpoint4_5Input = {
readonly sessionID: Endpoint4_5Request["params"]["sessionID"] readonly sessionID: Endpoint4_5Request["params"]["sessionID"]
readonly agent: Endpoint4_5Request["payload"]["agent"] readonly messageID?: Endpoint4_5Request["payload"]["messageID"]
} }
export type Endpoint4_5Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchAgent"]>> export type Endpoint4_5Output = EffectValue<ReturnType<RawClient["server.session"]["session.fork"]>>["data"]
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint4_5Input) => Effect.Effect<Endpoint4_5Output, E> export type SessionForkOperation<E = never> = (input: Endpoint4_5Input) => Effect.Effect<Endpoint4_5Output, E>
type Endpoint4_6Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint4_6Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
export type Endpoint4_6Input = { export type Endpoint4_6Input = {
readonly sessionID: Endpoint4_6Request["params"]["sessionID"] readonly sessionID: Endpoint4_6Request["params"]["sessionID"]
readonly model: Endpoint4_6Request["payload"]["model"] readonly agent: Endpoint4_6Request["payload"]["agent"]
} }
export type Endpoint4_6Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchModel"]>> export type Endpoint4_6Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchAgent"]>>
export type SessionSwitchModelOperation<E = never> = (input: Endpoint4_6Input) => Effect.Effect<Endpoint4_6Output, E> export type SessionSwitchAgentOperation<E = never> = (input: Endpoint4_6Input) => Effect.Effect<Endpoint4_6Output, E>
type Endpoint4_7Request = Parameters<RawClient["server.session"]["session.rename"]>[0] type Endpoint4_7Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
export type Endpoint4_7Input = { export type Endpoint4_7Input = {
readonly sessionID: Endpoint4_7Request["params"]["sessionID"] readonly sessionID: Endpoint4_7Request["params"]["sessionID"]
readonly title: Endpoint4_7Request["payload"]["title"] readonly model: Endpoint4_7Request["payload"]["model"]
} }
export type Endpoint4_7Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>> export type Endpoint4_7Output = EffectValue<ReturnType<RawClient["server.session"]["session.switchModel"]>>
export type SessionRenameOperation<E = never> = (input: Endpoint4_7Input) => Effect.Effect<Endpoint4_7Output, E> export type SessionSwitchModelOperation<E = never> = (input: Endpoint4_7Input) => Effect.Effect<Endpoint4_7Output, E>
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
export type Endpoint4_8Input = { export type Endpoint4_8Input = {
readonly sessionID: Endpoint4_8Request["params"]["sessionID"] readonly sessionID: Endpoint4_8Request["params"]["sessionID"]
readonly id?: Endpoint4_8Request["payload"]["id"] readonly title: Endpoint4_8Request["payload"]["title"]
readonly prompt: Endpoint4_8Request["payload"]["prompt"]
readonly delivery?: Endpoint4_8Request["payload"]["delivery"]
readonly resume?: Endpoint4_8Request["payload"]["resume"]
} }
export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"] export type Endpoint4_8Output = EffectValue<ReturnType<RawClient["server.session"]["session.rename"]>>
export type SessionPromptOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E> export type SessionRenameOperation<E = never> = (input: Endpoint4_8Input) => Effect.Effect<Endpoint4_8Output, E>
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0] type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
export type Endpoint4_9Input = { export type Endpoint4_9Input = {
readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
readonly id?: Endpoint4_9Request["payload"]["id"] readonly id?: Endpoint4_9Request["payload"]["id"]
readonly command: Endpoint4_9Request["payload"]["command"] readonly prompt: Endpoint4_9Request["payload"]["prompt"]
readonly arguments?: Endpoint4_9Request["payload"]["arguments"]
readonly agent?: Endpoint4_9Request["payload"]["agent"]
readonly model?: Endpoint4_9Request["payload"]["model"]
readonly files?: Endpoint4_9Request["payload"]["files"]
readonly agents?: Endpoint4_9Request["payload"]["agents"]
readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
readonly resume?: Endpoint4_9Request["payload"]["resume"] readonly resume?: Endpoint4_9Request["payload"]["resume"]
} }
export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"] export type Endpoint4_9Output = EffectValue<ReturnType<RawClient["server.session"]["session.prompt"]>>["data"]
export type SessionCommandOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E> export type SessionPromptOperation<E = never> = (input: Endpoint4_9Input) => Effect.Effect<Endpoint4_9Output, E>
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.skill"]>[0] type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0]
export type Endpoint4_10Input = { export type Endpoint4_10Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"] readonly id?: Endpoint4_10Request["payload"]["id"]
readonly skill: Endpoint4_10Request["payload"]["skill"] readonly command: Endpoint4_10Request["payload"]["command"]
readonly arguments?: Endpoint4_10Request["payload"]["arguments"]
readonly agent?: Endpoint4_10Request["payload"]["agent"]
readonly model?: Endpoint4_10Request["payload"]["model"]
readonly files?: Endpoint4_10Request["payload"]["files"]
readonly agents?: Endpoint4_10Request["payload"]["agents"]
readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
readonly resume?: Endpoint4_10Request["payload"]["resume"] readonly resume?: Endpoint4_10Request["payload"]["resume"]
} }
export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>> export type Endpoint4_10Output = EffectValue<ReturnType<RawClient["server.session"]["session.command"]>>["data"]
export type SessionSkillOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E> export type SessionCommandOperation<E = never> = (input: Endpoint4_10Input) => Effect.Effect<Endpoint4_10Output, E>
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0] type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
export type Endpoint4_11Input = { export type Endpoint4_11Input = {
readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
readonly text: Endpoint4_11Request["payload"]["text"] readonly id?: Endpoint4_11Request["payload"]["id"]
readonly description?: Endpoint4_11Request["payload"]["description"] readonly skill: Endpoint4_11Request["payload"]["skill"]
readonly metadata?: Endpoint4_11Request["payload"]["metadata"] readonly resume?: Endpoint4_11Request["payload"]["resume"]
} }
export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>> export type Endpoint4_11Output = EffectValue<ReturnType<RawClient["server.session"]["session.skill"]>>
export type SessionSyntheticOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E> export type SessionSkillOperation<E = never> = (input: Endpoint4_11Input) => Effect.Effect<Endpoint4_11Output, E>
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
export type Endpoint4_12Input = { export type Endpoint4_12Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"] readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly id?: Endpoint4_12Request["payload"]["id"] readonly text: Endpoint4_12Request["payload"]["text"]
readonly command: Endpoint4_12Request["payload"]["command"] readonly description?: Endpoint4_12Request["payload"]["description"]
readonly metadata?: Endpoint4_12Request["payload"]["metadata"]
} }
export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>> export type Endpoint4_12Output = EffectValue<ReturnType<RawClient["server.session"]["session.synthetic"]>>
export type SessionShellOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E> export type SessionSyntheticOperation<E = never> = (input: Endpoint4_12Input) => Effect.Effect<Endpoint4_12Output, E>
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } export type Endpoint4_13Input = {
export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>> readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
export type SessionCompactOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E> readonly id?: Endpoint4_13Request["payload"]["id"]
readonly command: Endpoint4_13Request["payload"]["command"]
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_15Input = {
readonly sessionID: Endpoint4_15Request["params"]["sessionID"]
readonly messageID: Endpoint4_15Request["payload"]["messageID"]
readonly files?: Endpoint4_15Request["payload"]["files"]
} }
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"] export type Endpoint4_13Output = EffectValue<ReturnType<RawClient["server.session"]["session.shell"]>>
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E> export type SessionShellOperation<E = never> = (input: Endpoint4_13Input) => Effect.Effect<Endpoint4_13Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } export type Endpoint4_14Input = {
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>> readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
export type SessionRevertClearOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E> readonly id?: Endpoint4_14Request["payload"]["id"]
}
export type Endpoint4_14Output = EffectValue<ReturnType<RawClient["server.session"]["session.compact"]>>["data"]
export type SessionCompactOperation<E = never> = (input: Endpoint4_14Input) => Effect.Effect<Endpoint4_14Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0] type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
export type Endpoint4_15Output = EffectValue<ReturnType<RawClient["server.session"]["session.wait"]>>
export type SessionWaitOperation<E = never> = (input: Endpoint4_15Input) => Effect.Effect<Endpoint4_15Output, E>
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
export type Endpoint4_16Input = {
readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
readonly messageID: Endpoint4_16Request["payload"]["messageID"]
readonly files?: Endpoint4_16Request["payload"]["files"]
}
export type Endpoint4_16Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.stage"]>>["data"]
export type SessionRevertStageOperation<E = never> = (input: Endpoint4_16Input) => Effect.Effect<Endpoint4_16Output, E>
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>> export type Endpoint4_17Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.clear"]>>
export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E> export type SessionRevertClearOperation<E = never> = (input: Endpoint4_17Input) => Effect.Effect<Endpoint4_17Output, E>
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"] export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.revert.commit"]>>
export type SessionContextOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E> export type SessionRevertCommitOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0] type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0]
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
export type Endpoint4_19Output = EffectValue< export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.context"]>>["data"]
export type SessionContextOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
export type Endpoint4_20Output = EffectValue<
ReturnType<RawClient["server.session"]["session.instructions.entry.list"]> ReturnType<RawClient["server.session"]["session.instructions.entry.list"]>
>["data"] >["data"]
export type SessionInstructionsEntryListOperation<E = never> = ( export type SessionInstructionsEntryListOperation<E = never> = (
input: Endpoint4_19Input,
) => Effect.Effect<Endpoint4_19Output, E>
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint4_20Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"]
}
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
export type SessionInstructionsEntryPutOperation<E = never> = (
input: Endpoint4_20Input, input: Endpoint4_20Input,
) => Effect.Effect<Endpoint4_20Output, E> ) => Effect.Effect<Endpoint4_20Output, E>
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0] type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
export type Endpoint4_21Input = { export type Endpoint4_21Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"] readonly key: Endpoint4_21Request["params"]["key"]
readonly value: Endpoint4_21Request["payload"]["value"]
} }
export type Endpoint4_21Output = EffectValue< export type Endpoint4_21Output = EffectValue<ReturnType<RawClient["server.session"]["session.instructions.entry.put"]>>
ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]> export type SessionInstructionsEntryPutOperation<E = never> = (
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint4_21Input, input: Endpoint4_21Input,
) => Effect.Effect<Endpoint4_21Output, E> ) => Effect.Effect<Endpoint4_21Output, E>
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
export type Endpoint4_22Input = { export type Endpoint4_22Input = {
readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly after?: Endpoint4_22Request["query"]["after"] readonly key: Endpoint4_22Request["params"]["key"]
readonly follow?: Endpoint4_22Request["query"]["follow"]
} }
export type Endpoint4_22Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>> export type Endpoint4_22Output = EffectValue<
export type SessionLogOperation<E = never> = (input: Endpoint4_22Input) => Stream.Stream<Endpoint4_22Output, E> ReturnType<RawClient["server.session"]["session.instructions.entry.remove"]>
>
export type SessionInstructionsEntryRemoveOperation<E = never> = (
input: Endpoint4_22Input,
) => Effect.Effect<Endpoint4_22Output, E>
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0]
export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } export type Endpoint4_23Input = {
export type Endpoint4_23Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>> readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
export type SessionInterruptOperation<E = never> = (input: Endpoint4_23Input) => Effect.Effect<Endpoint4_23Output, E> readonly after?: Endpoint4_23Request["query"]["after"]
readonly follow?: Endpoint4_23Request["query"]["follow"]
}
export type Endpoint4_23Output = StreamValue<EffectValue<ReturnType<RawClient["server.session"]["session.log"]>>>
export type SessionLogOperation<E = never> = (input: Endpoint4_23Input) => Stream.Stream<Endpoint4_23Output, E>
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0] type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>> export type Endpoint4_24Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E> export type SessionInterruptOperation<E = never> = (input: Endpoint4_24Input) => Effect.Effect<Endpoint4_24Output, E>
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0]
export type Endpoint4_25Input = { export type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_25Request["params"]["sessionID"] export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
readonly messageID: Endpoint4_25Request["params"]["messageID"] export type SessionBackgroundOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E>
type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0]
export type Endpoint4_26Input = {
readonly sessionID: Endpoint4_26Request["params"]["sessionID"]
readonly messageID: Endpoint4_26Request["params"]["messageID"]
} }
export type Endpoint4_25Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"] export type Endpoint4_26Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
export type SessionMessageOperation<E = never> = (input: Endpoint4_25Input) => Effect.Effect<Endpoint4_25Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint4_26Input) => Effect.Effect<Endpoint4_26Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E> readonly create: SessionCreateOperation<E>
readonly active: SessionActiveOperation<E> readonly active: SessionActiveOperation<E>
readonly get: SessionGetOperation<E> readonly get: SessionGetOperation<E>
readonly remove: SessionRemoveOperation<E>
readonly fork: SessionForkOperation<E> readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E> readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E> readonly switchModel: SessionSwitchModelOperation<E>
@ -729,7 +738,7 @@ export type Endpoint20_1Input = {
readonly location?: Endpoint20_1Request["query"]["location"] readonly location?: Endpoint20_1Request["query"]["location"]
readonly command: Endpoint20_1Request["payload"]["command"] readonly command: Endpoint20_1Request["payload"]["command"]
readonly cwd?: Endpoint20_1Request["payload"]["cwd"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
readonly timeout?: Endpoint20_1Request["payload"]["timeout"] readonly timeout: Endpoint20_1Request["payload"]["timeout"]
readonly metadata?: Endpoint20_1Request["payload"]["metadata"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
} }
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>> export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
@ -743,28 +752,38 @@ export type Endpoint20_2Input = {
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>> export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
export type ShellGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E> export type ShellGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0] type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.timeout"]>[0]
export type Endpoint20_3Input = { export type Endpoint20_3Input = {
readonly id: Endpoint20_3Request["params"]["id"] readonly id: Endpoint20_3Request["params"]["id"]
readonly location?: Endpoint20_3Request["query"]["location"] readonly location?: Endpoint20_3Request["query"]["location"]
readonly cursor?: Endpoint20_3Request["query"]["cursor"] readonly timeout: Endpoint20_3Request["payload"]["timeout"]
readonly limit?: Endpoint20_3Request["query"]["limit"]
} }
export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>> export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.timeout"]>>
export type ShellOutputOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E> export type ShellTimeoutOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
export type Endpoint20_4Input = { export type Endpoint20_4Input = {
readonly id: Endpoint20_4Request["params"]["id"] readonly id: Endpoint20_4Request["params"]["id"]
readonly location?: Endpoint20_4Request["query"]["location"] readonly location?: Endpoint20_4Request["query"]["location"]
readonly cursor?: Endpoint20_4Request["query"]["cursor"]
readonly limit?: Endpoint20_4Request["query"]["limit"]
} }
export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>> export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
export type ShellRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E> export type ShellOutputOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
type Endpoint20_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
export type Endpoint20_5Input = {
readonly id: Endpoint20_5Request["params"]["id"]
readonly location?: Endpoint20_5Request["query"]["location"]
}
export type Endpoint20_5Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
export type ShellRemoveOperation<E = never> = (input: Endpoint20_5Input) => Effect.Effect<Endpoint20_5Output, E>
export interface ShellApi<E = never> { export interface ShellApi<E = never> {
readonly list: ShellListOperation<E> readonly list: ShellListOperation<E>
readonly create: ShellCreateOperation<E> readonly create: ShellCreateOperation<E>
readonly get: ShellGetOperation<E> readonly get: ShellGetOperation<E>
readonly timeout: ShellTimeoutOperation<E>
readonly output: ShellOutputOperation<E> readonly output: ShellOutputOperation<E>
readonly remove: ShellRemoveOperation<E> readonly remove: ShellRemoveOperation<E>
} }

View file

@ -95,56 +95,61 @@ const Endpoint4_3 = (raw: RawClient["server.session"]) => (input: Endpoint4_3Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_4Request = Parameters<RawClient["server.session"]["session.fork"]>[0] type Endpoint4_4Request = Parameters<RawClient["server.session"]["session.remove"]>[0]
type Endpoint4_4Input = { type Endpoint4_4Input = { readonly sessionID: Endpoint4_4Request["params"]["sessionID"] }
readonly sessionID: Endpoint4_4Request["params"]["sessionID"]
readonly messageID?: Endpoint4_4Request["payload"]["messageID"]
}
const Endpoint4_4 = (raw: RawClient["server.session"]) => (input: Endpoint4_4Input) => const Endpoint4_4 = (raw: RawClient["server.session"]) => (input: Endpoint4_4Input) =>
raw["session.remove"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_5Request = Parameters<RawClient["server.session"]["session.fork"]>[0]
type Endpoint4_5Input = {
readonly sessionID: Endpoint4_5Request["params"]["sessionID"]
readonly messageID?: Endpoint4_5Request["payload"]["messageID"]
}
const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) =>
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe( raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_5Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0] type Endpoint4_6Request = Parameters<RawClient["server.session"]["session.switchAgent"]>[0]
type Endpoint4_5Input = { type Endpoint4_6Input = {
readonly sessionID: Endpoint4_5Request["params"]["sessionID"] readonly sessionID: Endpoint4_6Request["params"]["sessionID"]
readonly agent: Endpoint4_5Request["payload"]["agent"] readonly agent: Endpoint4_6Request["payload"]["agent"]
} }
const Endpoint4_5 = (raw: RawClient["server.session"]) => (input: Endpoint4_5Input) => const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) =>
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe( raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_6Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0] type Endpoint4_7Request = Parameters<RawClient["server.session"]["session.switchModel"]>[0]
type Endpoint4_6Input = { type Endpoint4_7Input = {
readonly sessionID: Endpoint4_6Request["params"]["sessionID"] readonly sessionID: Endpoint4_7Request["params"]["sessionID"]
readonly model: Endpoint4_6Request["payload"]["model"] readonly model: Endpoint4_7Request["payload"]["model"]
} }
const Endpoint4_6 = (raw: RawClient["server.session"]) => (input: Endpoint4_6Input) => const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) =>
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe( raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_7Request = Parameters<RawClient["server.session"]["session.rename"]>[0] type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.rename"]>[0]
type Endpoint4_7Input = { type Endpoint4_8Input = {
readonly sessionID: Endpoint4_7Request["params"]["sessionID"] readonly sessionID: Endpoint4_8Request["params"]["sessionID"]
readonly title: Endpoint4_7Request["payload"]["title"] readonly title: Endpoint4_8Request["payload"]["title"]
} }
const Endpoint4_7 = (raw: RawClient["server.session"]) => (input: Endpoint4_7Input) => const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) =>
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe( raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_8Request = Parameters<RawClient["server.session"]["session.prompt"]>[0] type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.prompt"]>[0]
type Endpoint4_8Input = { type Endpoint4_9Input = {
readonly sessionID: Endpoint4_8Request["params"]["sessionID"] readonly sessionID: Endpoint4_9Request["params"]["sessionID"]
readonly id?: Endpoint4_8Request["payload"]["id"] readonly id?: Endpoint4_9Request["payload"]["id"]
readonly prompt: Endpoint4_8Request["payload"]["prompt"] readonly prompt: Endpoint4_9Request["payload"]["prompt"]
readonly delivery?: Endpoint4_8Request["payload"]["delivery"] readonly delivery?: Endpoint4_9Request["payload"]["delivery"]
readonly resume?: Endpoint4_8Request["payload"]["resume"] readonly resume?: Endpoint4_9Request["payload"]["resume"]
} }
const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Input) => const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) =>
raw["session.prompt"]({ raw["session.prompt"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] }, payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
@ -153,20 +158,20 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_9Request = Parameters<RawClient["server.session"]["session.command"]>[0] type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.command"]>[0]
type Endpoint4_9Input = { type Endpoint4_10Input = {
readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly sessionID: Endpoint4_10Request["params"]["sessionID"]
readonly id?: Endpoint4_9Request["payload"]["id"] readonly id?: Endpoint4_10Request["payload"]["id"]
readonly command: Endpoint4_9Request["payload"]["command"] readonly command: Endpoint4_10Request["payload"]["command"]
readonly arguments?: Endpoint4_9Request["payload"]["arguments"] readonly arguments?: Endpoint4_10Request["payload"]["arguments"]
readonly agent?: Endpoint4_9Request["payload"]["agent"] readonly agent?: Endpoint4_10Request["payload"]["agent"]
readonly model?: Endpoint4_9Request["payload"]["model"] readonly model?: Endpoint4_10Request["payload"]["model"]
readonly files?: Endpoint4_9Request["payload"]["files"] readonly files?: Endpoint4_10Request["payload"]["files"]
readonly agents?: Endpoint4_9Request["payload"]["agents"] readonly agents?: Endpoint4_10Request["payload"]["agents"]
readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly delivery?: Endpoint4_10Request["payload"]["delivery"]
readonly resume?: Endpoint4_9Request["payload"]["resume"] readonly resume?: Endpoint4_10Request["payload"]["resume"]
} }
const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) =>
raw["session.command"]({ raw["session.command"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { payload: {
@ -185,61 +190,67 @@ const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Inp
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_10Request = Parameters<RawClient["server.session"]["session.skill"]>[0] type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.skill"]>[0]
type Endpoint4_10Input = { type Endpoint4_11Input = {
readonly sessionID: Endpoint4_10Request["params"]["sessionID"] readonly sessionID: Endpoint4_11Request["params"]["sessionID"]
readonly id?: Endpoint4_10Request["payload"]["id"] readonly id?: Endpoint4_11Request["payload"]["id"]
readonly skill: Endpoint4_10Request["payload"]["skill"] readonly skill: Endpoint4_11Request["payload"]["skill"]
readonly resume?: Endpoint4_10Request["payload"]["resume"] readonly resume?: Endpoint4_11Request["payload"]["resume"]
} }
const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) =>
raw["session.skill"]({ raw["session.skill"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_11Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0] type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.synthetic"]>[0]
type Endpoint4_11Input = { type Endpoint4_12Input = {
readonly sessionID: Endpoint4_11Request["params"]["sessionID"] readonly sessionID: Endpoint4_12Request["params"]["sessionID"]
readonly text: Endpoint4_11Request["payload"]["text"] readonly text: Endpoint4_12Request["payload"]["text"]
readonly description?: Endpoint4_11Request["payload"]["description"] readonly description?: Endpoint4_12Request["payload"]["description"]
readonly metadata?: Endpoint4_11Request["payload"]["metadata"] readonly metadata?: Endpoint4_12Request["payload"]["metadata"]
} }
const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) =>
raw["session.synthetic"]({ raw["session.synthetic"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, payload: { text: input["text"], description: input["description"], metadata: input["metadata"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_12Request = Parameters<RawClient["server.session"]["session.shell"]>[0] type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.shell"]>[0]
type Endpoint4_12Input = { type Endpoint4_13Input = {
readonly sessionID: Endpoint4_12Request["params"]["sessionID"] readonly sessionID: Endpoint4_13Request["params"]["sessionID"]
readonly id?: Endpoint4_12Request["payload"]["id"] readonly id?: Endpoint4_13Request["payload"]["id"]
readonly command: Endpoint4_12Request["payload"]["command"] readonly command: Endpoint4_13Request["payload"]["command"]
} }
const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) =>
raw["session.shell"]({ raw["session.shell"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { id: input["id"], command: input["command"] }, payload: { id: input["id"], command: input["command"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_13Request = Parameters<RawClient["server.session"]["session.compact"]>[0] type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.compact"]>[0]
type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } type Endpoint4_14Input = {
const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => readonly sessionID: Endpoint4_14Request["params"]["sessionID"]
raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) readonly id?: Endpoint4_14Request["payload"]["id"]
}
type Endpoint4_14Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] }
const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) =>
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
)
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.wait"]>[0]
type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) =>
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_15Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0] type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.stage"]>[0]
type Endpoint4_15Input = { type Endpoint4_16Input = {
readonly sessionID: Endpoint4_15Request["params"]["sessionID"] readonly sessionID: Endpoint4_16Request["params"]["sessionID"]
readonly messageID: Endpoint4_15Request["payload"]["messageID"] readonly messageID: Endpoint4_16Request["payload"]["messageID"]
readonly files?: Endpoint4_15Request["payload"]["files"] readonly files?: Endpoint4_16Request["payload"]["files"]
} }
const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.revert.stage"]({ raw["session.revert.stage"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
payload: { messageID: input["messageID"], files: input["files"] }, payload: { messageID: input["messageID"], files: input["files"] },
@ -248,61 +259,61 @@ const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15I
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_16Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0] type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.clear"]>[0]
type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] }
const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) =>
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_17Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] }
const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.context"]>[0] type Endpoint4_18Request = Parameters<RawClient["server.session"]["session.revert.commit"]>[0]
type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] }
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.context"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0] type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.list"]>[0]
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] } type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] }
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
) )
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0] type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.put"]>[0]
type Endpoint4_20Input = { type Endpoint4_21Input = {
readonly sessionID: Endpoint4_20Request["params"]["sessionID"] readonly sessionID: Endpoint4_21Request["params"]["sessionID"]
readonly key: Endpoint4_20Request["params"]["key"] readonly key: Endpoint4_21Request["params"]["key"]
readonly value: Endpoint4_20Request["payload"]["value"] readonly value: Endpoint4_21Request["payload"]["value"]
} }
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) =>
raw["session.instructions.entry.put"]({ raw["session.instructions.entry.put"]({
params: { sessionID: input["sessionID"], key: input["key"] }, params: { sessionID: input["sessionID"], key: input["key"] },
payload: { value: input["value"] }, payload: { value: input["value"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint4_21Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0] type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.instructions.entry.remove"]>[0]
type Endpoint4_21Input = { type Endpoint4_22Input = {
readonly sessionID: Endpoint4_21Request["params"]["sessionID"] readonly sessionID: Endpoint4_22Request["params"]["sessionID"]
readonly key: Endpoint4_21Request["params"]["key"] readonly key: Endpoint4_22Request["params"]["key"]
} }
const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) =>
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint4_22Request = Parameters<RawClient["server.session"]["session.log"]>[0] type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.log"]>[0]
type Endpoint4_22Input = { type Endpoint4_23Input = {
readonly sessionID: Endpoint4_22Request["params"]["sessionID"] readonly sessionID: Endpoint4_23Request["params"]["sessionID"]
readonly after?: Endpoint4_22Request["query"]["after"] readonly after?: Endpoint4_23Request["query"]["after"]
readonly follow?: Endpoint4_22Request["query"]["follow"] readonly follow?: Endpoint4_23Request["query"]["follow"]
} }
const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
Stream.unwrap( Stream.unwrap(
raw["session.log"]({ raw["session.log"]({
params: { sessionID: input["sessionID"] }, params: { sessionID: input["sessionID"] },
@ -313,22 +324,22 @@ const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22I
), ),
) )
type Endpoint4_23Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0] type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.interrupt"]>[0]
type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] }
const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_24Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] }
const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) =>
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.background"]>[0]
type Endpoint4_25Input = { readonly sessionID: Endpoint4_25Request["params"]["sessionID"] }
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) =>
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
type Endpoint4_25Request = Parameters<RawClient["server.session"]["session.message"]>[0] type Endpoint4_26Request = Parameters<RawClient["server.session"]["session.message"]>[0]
type Endpoint4_25Input = { type Endpoint4_26Input = {
readonly sessionID: Endpoint4_25Request["params"]["sessionID"] readonly sessionID: Endpoint4_26Request["params"]["sessionID"]
readonly messageID: Endpoint4_25Request["params"]["messageID"] readonly messageID: Endpoint4_26Request["params"]["messageID"]
} }
const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => const Endpoint4_26 = (raw: RawClient["server.session"]) => (input: Endpoint4_26Input) =>
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
Effect.map((value) => value.data), Effect.map((value) => value.data),
@ -339,26 +350,27 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
create: Endpoint4_1(raw), create: Endpoint4_1(raw),
active: Endpoint4_2(raw), active: Endpoint4_2(raw),
get: Endpoint4_3(raw), get: Endpoint4_3(raw),
fork: Endpoint4_4(raw), remove: Endpoint4_4(raw),
switchAgent: Endpoint4_5(raw), fork: Endpoint4_5(raw),
switchModel: Endpoint4_6(raw), switchAgent: Endpoint4_6(raw),
rename: Endpoint4_7(raw), switchModel: Endpoint4_7(raw),
prompt: Endpoint4_8(raw), rename: Endpoint4_8(raw),
command: Endpoint4_9(raw), prompt: Endpoint4_9(raw),
skill: Endpoint4_10(raw), command: Endpoint4_10(raw),
synthetic: Endpoint4_11(raw), skill: Endpoint4_11(raw),
shell: Endpoint4_12(raw), synthetic: Endpoint4_12(raw),
compact: Endpoint4_13(raw), shell: Endpoint4_13(raw),
wait: Endpoint4_14(raw), compact: Endpoint4_14(raw),
revertStage: Endpoint4_15(raw), wait: Endpoint4_15(raw),
revertClear: Endpoint4_16(raw), revertStage: Endpoint4_16(raw),
revertCommit: Endpoint4_17(raw), revertClear: Endpoint4_17(raw),
context: Endpoint4_18(raw), revertCommit: Endpoint4_18(raw),
instructions: { entry: { list: Endpoint4_19(raw), put: Endpoint4_20(raw), remove: Endpoint4_21(raw) } }, context: Endpoint4_19(raw),
log: Endpoint4_22(raw), instructions: { entry: { list: Endpoint4_20(raw), put: Endpoint4_21(raw), remove: Endpoint4_22(raw) } },
interrupt: Endpoint4_23(raw), log: Endpoint4_23(raw),
background: Endpoint4_24(raw), interrupt: Endpoint4_24(raw),
message: Endpoint4_25(raw), background: Endpoint4_25(raw),
message: Endpoint4_26(raw),
}) })
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0] type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]
@ -877,7 +889,7 @@ type Endpoint20_1Input = {
readonly location?: Endpoint20_1Request["query"]["location"] readonly location?: Endpoint20_1Request["query"]["location"]
readonly command: Endpoint20_1Request["payload"]["command"] readonly command: Endpoint20_1Request["payload"]["command"]
readonly cwd?: Endpoint20_1Request["payload"]["cwd"] readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
readonly timeout?: Endpoint20_1Request["payload"]["timeout"] readonly timeout: Endpoint20_1Request["payload"]["timeout"]
readonly metadata?: Endpoint20_1Request["payload"]["metadata"] readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
} }
const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) => const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) =>
@ -896,25 +908,38 @@ const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Inp
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0] type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.timeout"]>[0]
type Endpoint20_3Input = { type Endpoint20_3Input = {
readonly id: Endpoint20_3Request["params"]["id"] readonly id: Endpoint20_3Request["params"]["id"]
readonly location?: Endpoint20_3Request["query"]["location"] readonly location?: Endpoint20_3Request["query"]["location"]
readonly cursor?: Endpoint20_3Request["query"]["cursor"] readonly timeout: Endpoint20_3Request["payload"]["timeout"]
readonly limit?: Endpoint20_3Request["query"]["limit"]
} }
const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) => const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) =>
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
payload: { timeout: input["timeout"] },
}).pipe(Effect.mapError(mapClientError))
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
type Endpoint20_4Input = {
readonly id: Endpoint20_4Request["params"]["id"]
readonly location?: Endpoint20_4Request["query"]["location"]
readonly cursor?: Endpoint20_4Request["query"]["cursor"]
readonly limit?: Endpoint20_4Request["query"]["limit"]
}
const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) =>
raw["shell.output"]({ raw["shell.output"]({
params: { id: input["id"] }, params: { id: input["id"] },
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] }, query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
}).pipe(Effect.mapError(mapClientError)) }).pipe(Effect.mapError(mapClientError))
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0] type Endpoint20_5Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
type Endpoint20_4Input = { type Endpoint20_5Input = {
readonly id: Endpoint20_4Request["params"]["id"] readonly id: Endpoint20_5Request["params"]["id"]
readonly location?: Endpoint20_4Request["query"]["location"] readonly location?: Endpoint20_5Request["query"]["location"]
} }
const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) => const Endpoint20_5 = (raw: RawClient["server.shell"]) => (input: Endpoint20_5Input) =>
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe( raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
Effect.mapError(mapClientError), Effect.mapError(mapClientError),
) )
@ -923,8 +948,9 @@ const adaptGroup20 = (raw: RawClient["server.shell"]) => ({
list: Endpoint20_0(raw), list: Endpoint20_0(raw),
create: Endpoint20_1(raw), create: Endpoint20_1(raw),
get: Endpoint20_2(raw), get: Endpoint20_2(raw),
output: Endpoint20_3(raw), timeout: Endpoint20_3(raw),
remove: Endpoint20_4(raw), output: Endpoint20_4(raw),
remove: Endpoint20_5(raw),
}) })
type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0] type Endpoint21_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]

View file

@ -13,6 +13,8 @@ import type {
SessionActiveOutput, SessionActiveOutput,
SessionGetInput, SessionGetInput,
SessionGetOutput, SessionGetOutput,
SessionRemoveInput,
SessionRemoveOutput,
SessionForkInput, SessionForkInput,
SessionForkOutput, SessionForkOutput,
SessionSwitchAgentInput, SessionSwitchAgentInput,
@ -149,6 +151,8 @@ import type {
ShellCreateOutput, ShellCreateOutput,
ShellGetInput, ShellGetInput,
ShellGetOutput, ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput, ShellOutputInput,
ShellOutputOutput, ShellOutputOutput,
ShellRemoveInput, ShellRemoveInput,
@ -422,6 +426,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
remove: (input: SessionRemoveInput, requestOptions?: RequestOptions) =>
request<SessionRemoveOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
},
requestOptions,
),
fork: (input: SessionForkInput, requestOptions?: RequestOptions) => fork: (input: SessionForkInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionForkOutput }>( request<{ readonly data: SessionForkOutput }>(
{ {
@ -541,16 +556,17 @@ export function make(options: ClientOptions) {
requestOptions, requestOptions,
), ),
compact: (input: SessionCompactInput, requestOptions?: RequestOptions) => compact: (input: SessionCompactInput, requestOptions?: RequestOptions) =>
request<SessionCompactOutput>( request<{ readonly data: SessionCompactOutput }>(
{ {
method: "POST", method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
successStatus: 204, body: { id: input["id"] },
declaredStatuses: [404, 409, 503, 500, 400, 401], successStatus: 200,
empty: true, declaredStatuses: [409, 404, 400, 401],
empty: false,
}, },
requestOptions, requestOptions,
), ).then((value) => value.data),
wait: (input: SessionWaitInput, requestOptions?: RequestOptions) => wait: (input: SessionWaitInput, requestOptions?: RequestOptions) =>
request<SessionWaitOutput>( request<SessionWaitOutput>(
{ {
@ -1300,6 +1316,19 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
), ),
timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) =>
request<ShellTimeoutOutput>(
{
method: "PATCH",
path: `/api/shell/${encodeURIComponent(input.id)}/timeout`,
query: { location: input["location"] },
body: { timeout: input["timeout"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
output: (input: ShellOutputInput, requestOptions?: RequestOptions) => output: (input: ShellOutputInput, requestOptions?: RequestOptions) =>
request<ShellOutputOutput>( request<ShellOutputOutput>(
{ {

View file

@ -74,14 +74,6 @@ export type SkillNotFoundError = {
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError => export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type ServiceUnavailableError = { export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError" readonly _tag: "ServiceUnavailableError"
readonly message: string readonly message: string
@ -90,6 +82,14 @@ export type ServiceUnavailableError = {
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError => export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError" typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
export type SessionBusyError = {
readonly _tag: "SessionBusyError"
readonly sessionID: string
readonly message: string
}
export const isSessionBusyError = (value: unknown): value is SessionBusyError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SessionBusyError"
export type UnknownError = { export type UnknownError = {
readonly _tag: "UnknownError" readonly _tag: "UnknownError"
readonly message: string readonly message: string
@ -326,6 +326,7 @@ export type SessionListOutput = {
readonly data: ReadonlyArray<{ readonly data: ReadonlyArray<{
readonly id: string readonly id: string
readonly parentID?: string readonly parentID?: string
readonly fork?: { readonly sessionID: string; readonly messageID?: string }
readonly projectID: string readonly projectID: string
readonly agent?: string readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
@ -388,6 +389,7 @@ export type SessionCreateOutput = {
readonly data: { readonly data: {
readonly id: string readonly id: string
readonly parentID?: string readonly parentID?: string
readonly fork?: { readonly sessionID: string; readonly messageID?: string }
readonly projectID: string readonly projectID: string
readonly agent?: string readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
@ -426,6 +428,7 @@ export type SessionGetOutput = {
readonly data: { readonly data: {
readonly id: string readonly id: string
readonly parentID?: string readonly parentID?: string
readonly fork?: { readonly sessionID: string; readonly messageID?: string }
readonly projectID: string readonly projectID: string
readonly agent?: string readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
@ -456,6 +459,10 @@ export type SessionGetOutput = {
} }
}["data"] }["data"]
export type SessionRemoveInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionRemoveOutput = void
export type SessionForkInput = { export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly messageID?: { readonly messageID?: string | undefined }["messageID"] readonly messageID?: { readonly messageID?: string | undefined }["messageID"]
@ -465,6 +472,7 @@ export type SessionForkOutput = {
readonly data: { readonly data: {
readonly id: string readonly id: string
readonly parentID?: string readonly parentID?: string
readonly fork?: { readonly sessionID: string; readonly messageID?: string }
readonly projectID: string readonly projectID: string
readonly agent?: string readonly agent?: string
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
@ -871,9 +879,21 @@ export type SessionShellInput = {
export type SessionShellOutput = void export type SessionShellOutput = void
export type SessionCompactInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined }["id"]
}
export type SessionCompactOutput = void export type SessionCompactOutput = {
readonly data: {
readonly type: "compaction"
readonly admittedSeq: number
readonly id: string
readonly sessionID: string
readonly timeCreated: number
readonly handledSeq?: number
}
}["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] } export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@ -1006,23 +1026,20 @@ export type SessionContextOutput = {
readonly agent: string readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray< readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string } | { readonly type: "text"; readonly text: string }
| { | {
readonly type: "reasoning" readonly type: "reasoning"
readonly id: string
readonly text: string readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number } readonly time?: { readonly created: number; readonly completed?: number }
} }
| { | {
readonly type: "tool" readonly type: "tool"
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly provider?: { readonly executed?: boolean
readonly executed: boolean readonly providerState?: { readonly [x: string]: JsonValue }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state: readonly state:
| { readonly status: "pending"; readonly input: string } | { readonly status: "pending"; readonly input: string }
| { | {
@ -1061,7 +1078,7 @@ export type SessionContextOutput = {
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
> >
readonly structured: { readonly [x: string]: JsonValue } readonly structured: { readonly [x: string]: JsonValue }
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly result?: JsonValue readonly result?: JsonValue
} }
readonly time: { readonly time: {
@ -1073,7 +1090,7 @@ export type SessionContextOutput = {
} }
> >
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> } readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number readonly cost?: number
readonly tokens?: { readonly tokens?: {
readonly input: number readonly input: number
@ -1081,10 +1098,16 @@ export type SessionContextOutput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly error?: { readonly type: "unknown"; readonly message: string } readonly error?: { readonly type: string; readonly message: string }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string }
}
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@ -1167,6 +1190,15 @@ export type SessionLogOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string } readonly data: { readonly sessionID: string; readonly title: string }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -1213,6 +1245,45 @@ export type SessionLogOutput =
readonly delivery: "steer" | "queue" readonly delivery: "steer" | "queue"
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.succeeded"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: {
readonly sessionID: string
readonly error: { readonly type: string; readonly message: string }
}
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -1322,7 +1393,7 @@ export type SessionLogOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly finish: string readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost: number readonly cost: number
readonly tokens: { readonly tokens: {
readonly input: number readonly input: number
@ -1344,7 +1415,7 @@ export type SessionLogOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
} }
} }
| { | {
@ -1354,7 +1425,7 @@ export type SessionLogOutput =
readonly type: "session.text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number }
} }
| { | {
readonly id: string readonly id: string
@ -1366,7 +1437,7 @@ export type SessionLogOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly textID: string readonly ordinal: number
readonly text: string readonly text: string
} }
} }
@ -1380,8 +1451,8 @@ export type SessionLogOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly reasoningID: string readonly ordinal: number
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } readonly state?: { readonly [x: string]: unknown }
} }
} }
| { | {
@ -1394,9 +1465,9 @@ export type SessionLogOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly reasoningID: string readonly ordinal: number
readonly text: string readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } readonly state?: { readonly [x: string]: unknown }
} }
} }
| { | {
@ -1438,12 +1509,9 @@ export type SessionLogOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly callID: string readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown } readonly input: { readonly [x: string]: unknown }
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly state?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
@ -1482,10 +1550,8 @@ export type SessionLogOutput =
> >
readonly outputPaths?: ReadonlyArray<string> readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown readonly result?: unknown
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
@ -1499,34 +1565,36 @@ export type SessionLogOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly callID: string readonly callID: string
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly result?: unknown readonly result?: unknown
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.retried" readonly type: "session.retry.scheduled"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string
readonly attempt: number readonly attempt: number
readonly error: { readonly at: number
readonly message: string readonly error: { readonly type: string; readonly message: string }
readonly statusCode?: number
readonly isRetryable: boolean
readonly responseHeaders?: { readonly [x: string]: string }
readonly responseBody?: string
readonly metadata?: { readonly [x: string]: string }
}
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -1550,6 +1618,15 @@ export type SessionLogOutput =
readonly recent: string readonly recent: string
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -1590,7 +1667,7 @@ export type SessionLogOutput =
readonly type: "session.revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string } readonly data: { readonly sessionID: string; readonly to: string }
} }
) )
| { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number } | { readonly type: "log.synced"; readonly aggregateID: string; readonly seq?: number }
@ -1703,23 +1780,20 @@ export type SessionMessageOutput = {
readonly agent: string readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray< readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string } | { readonly type: "text"; readonly text: string }
| { | {
readonly type: "reasoning" readonly type: "reasoning"
readonly id: string
readonly text: string readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number } readonly time?: { readonly created: number; readonly completed?: number }
} }
| { | {
readonly type: "tool" readonly type: "tool"
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly provider?: { readonly executed?: boolean
readonly executed: boolean readonly providerState?: { readonly [x: string]: JsonValue }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state: readonly state:
| { readonly status: "pending"; readonly input: string } | { readonly status: "pending"; readonly input: string }
| { | {
@ -1758,7 +1832,7 @@ export type SessionMessageOutput = {
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
> >
readonly structured: { readonly [x: string]: JsonValue } readonly structured: { readonly [x: string]: JsonValue }
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly result?: JsonValue readonly result?: JsonValue
} }
readonly time: { readonly time: {
@ -1770,7 +1844,7 @@ export type SessionMessageOutput = {
} }
> >
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> } readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number readonly cost?: number
readonly tokens?: { readonly tokens?: {
readonly input: number readonly input: number
@ -1778,10 +1852,16 @@ export type SessionMessageOutput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly error?: { readonly type: "unknown"; readonly message: string } readonly error?: { readonly type: string; readonly message: string }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string }
}
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@ -1905,23 +1985,20 @@ export type MessageListOutput = {
readonly agent: string readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string } readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray< readonly content: ReadonlyArray<
| { readonly type: "text"; readonly id: string; readonly text: string } | { readonly type: "text"; readonly text: string }
| { | {
readonly type: "reasoning" readonly type: "reasoning"
readonly id: string
readonly text: string readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly state?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number } readonly time?: { readonly created: number; readonly completed?: number }
} }
| { | {
readonly type: "tool" readonly type: "tool"
readonly id: string readonly id: string
readonly name: string readonly name: string
readonly provider?: { readonly executed?: boolean
readonly executed: boolean readonly providerState?: { readonly [x: string]: JsonValue }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } } readonly providerResultState?: { readonly [x: string]: JsonValue }
readonly resultMetadata?: { readonly [x: string]: { readonly [x: string]: JsonValue } }
}
readonly state: readonly state:
| { readonly status: "pending"; readonly input: string } | { readonly status: "pending"; readonly input: string }
| { | {
@ -1960,7 +2037,7 @@ export type MessageListOutput = {
| { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string } | { readonly type: "file"; readonly uri: string; readonly mime: string; readonly name?: string }
> >
readonly structured: { readonly [x: string]: JsonValue } readonly structured: { readonly [x: string]: JsonValue }
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly result?: JsonValue readonly result?: JsonValue
} }
readonly time: { readonly time: {
@ -1972,7 +2049,7 @@ export type MessageListOutput = {
} }
> >
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> } readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: string readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost?: number readonly cost?: number
readonly tokens?: { readonly tokens?: {
readonly input: number readonly input: number
@ -1980,10 +2057,16 @@ export type MessageListOutput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly error?: { readonly type: "unknown"; readonly message: string } readonly error?: { readonly type: string; readonly message: string }
readonly retry?: {
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string }
}
} }
| { | {
readonly type: "compaction" readonly type: "compaction"
readonly status: "queued" | "running" | "completed" | "failed"
readonly reason: "auto" | "manual" readonly reason: "auto" | "manual"
readonly summary: string readonly summary: string
readonly recent: string readonly recent: string
@ -4406,6 +4489,15 @@ export type EventSubscribeOutput =
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly title: string } readonly data: { readonly sessionID: string; readonly title: string }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -4456,13 +4548,37 @@ export type EventSubscribeOutput =
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.settled" readonly type: "session.execution.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: { readonly sessionID: string }
readonly sessionID: string }
readonly outcome: "success" | "failure" | "interrupted" | {
readonly error?: { readonly type: "unknown"; readonly message: string } readonly id: string
} readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.succeeded"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly error: { readonly type: string; readonly message: string } }
}
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly reason: "user" | "shutdown" | "superseded" }
} }
| { | {
readonly id: string readonly id: string
@ -4573,7 +4689,7 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly finish: string readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly cost: number readonly cost: number
readonly tokens: { readonly tokens: {
readonly input: number readonly input: number
@ -4595,7 +4711,7 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
} }
} }
| { | {
@ -4605,7 +4721,7 @@ export type EventSubscribeOutput =
readonly type: "session.text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly textID: string } readonly data: { readonly sessionID: string; readonly assistantMessageID: string; readonly ordinal: number }
} }
| { | {
readonly id: string readonly id: string
@ -4616,7 +4732,7 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly textID: string readonly ordinal: number
readonly delta: string readonly delta: string
} }
} }
@ -4630,7 +4746,7 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly textID: string readonly ordinal: number
readonly text: string readonly text: string
} }
} }
@ -4644,8 +4760,8 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly reasoningID: string readonly ordinal: number
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } readonly state?: { readonly [x: string]: unknown }
} }
} }
| { | {
@ -4657,7 +4773,7 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly reasoningID: string readonly ordinal: number
readonly delta: string readonly delta: string
} }
} }
@ -4671,9 +4787,9 @@ export type EventSubscribeOutput =
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly reasoningID: string readonly ordinal: number
readonly text: string readonly text: string
readonly providerMetadata?: { readonly [x: string]: { readonly [x: string]: unknown } } readonly state?: { readonly [x: string]: unknown }
} }
} }
| { | {
@ -4728,12 +4844,9 @@ export type EventSubscribeOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly callID: string readonly callID: string
readonly tool: string
readonly input: { readonly [x: string]: unknown } readonly input: { readonly [x: string]: unknown }
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly state?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
@ -4772,10 +4885,8 @@ export type EventSubscribeOutput =
> >
readonly outputPaths?: ReadonlyArray<string> readonly outputPaths?: ReadonlyArray<string>
readonly result?: unknown readonly result?: unknown
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
@ -4789,34 +4900,36 @@ export type EventSubscribeOutput =
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string readonly assistantMessageID: string
readonly callID: string readonly callID: string
readonly error: { readonly type: "unknown"; readonly message: string } readonly error: { readonly type: string; readonly message: string }
readonly result?: unknown readonly result?: unknown
readonly provider: { readonly executed: boolean
readonly executed: boolean readonly resultState?: { readonly [x: string]: unknown }
readonly metadata?: { readonly [x: string]: { readonly [x: string]: unknown } }
}
} }
} }
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
readonly metadata?: { readonly [x: string]: unknown } readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.retried" readonly type: "session.retry.scheduled"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly data: {
readonly sessionID: string readonly sessionID: string
readonly assistantMessageID: string
readonly attempt: number readonly attempt: number
readonly error: { readonly at: number
readonly message: string readonly error: { readonly type: string; readonly message: string }
readonly statusCode?: number
readonly isRetryable: boolean
readonly responseHeaders?: { readonly [x: string]: string }
readonly responseBody?: string
readonly metadata?: { readonly [x: string]: string }
}
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly inputID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -4848,6 +4961,15 @@ export type EventSubscribeOutput =
readonly recent: string readonly recent: string
} }
} }
| {
readonly id: string
readonly created: number
readonly metadata?: { readonly [x: string]: unknown }
readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string }
}
| { | {
readonly id: string readonly id: string
readonly created: number readonly created: number
@ -4888,7 +5010,7 @@ export type EventSubscribeOutput =
readonly type: "session.revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number } readonly durable: { readonly aggregateID: string; readonly seq: number; readonly version: number }
readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly location?: { readonly directory: string; readonly workspaceID?: string }
readonly data: { readonly sessionID: string; readonly messageID: string } readonly data: { readonly sessionID: string; readonly to: string }
} }
| { | {
readonly id: string readonly id: string
@ -5686,25 +5808,25 @@ export type ShellCreateInput = {
readonly command: { readonly command: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
readonly timeout?: number readonly timeout: number
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
}["command"] }["command"]
readonly cwd?: { readonly cwd?: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
readonly timeout?: number readonly timeout: number
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
}["cwd"] }["cwd"]
readonly timeout?: { readonly timeout: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
readonly timeout?: number readonly timeout: number
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
}["timeout"] }["timeout"]
readonly metadata?: { readonly metadata?: {
readonly command: string readonly command: string
readonly cwd?: string readonly cwd?: string
readonly timeout?: number readonly timeout: number
readonly metadata?: { readonly [x: string]: JsonValue } readonly metadata?: { readonly [x: string]: JsonValue }
}["metadata"] }["metadata"]
} }
@ -5762,6 +5884,37 @@ export type ShellGetOutput = {
} }
} }
export type ShellTimeoutInput = {
readonly id: { readonly id: string }["id"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly timeout: { readonly timeout: number }["timeout"]
}
export type ShellTimeoutOutput = {
readonly location: {
readonly directory: string
readonly workspaceID?: string
readonly project: { readonly id: string; readonly directory: string }
}
readonly data: {
readonly id: string
readonly status: "running" | "exited" | "timeout" | "killed"
readonly command: string
readonly cwd: string
readonly shell: string
readonly file: string
readonly pid?: number
readonly exit?: number | "Infinity" | "-Infinity" | "NaN"
readonly metadata: { readonly [x: string]: JsonValue }
readonly time: {
readonly started: number | "Infinity" | "-Infinity" | "NaN"
readonly completed?: number | "Infinity" | "-Infinity" | "NaN"
}
}
}
export type ShellOutputInput = { export type ShellOutputInput = {
readonly id: { readonly id: string }["id"] readonly id: { readonly id: string }["id"]
readonly location?: { readonly location?: {

View file

@ -140,6 +140,9 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
if (url.includes("/prompt")) { if (url.includes("/prompt")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(admission)))
} }
if (url.endsWith("/compact")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(compactionAdmission)))
}
if (url.includes("/context")) { if (url.includes("/context")) {
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] }))) return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: [] })))
} }
@ -148,10 +151,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
} }
if (url.endsWith("/api/session/active")) { if (url.endsWith("/api/session/active")) {
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: { ses_test: { type: "running" } } })),
request,
Response.json({ data: { ses_test: { type: "running" } } }),
),
) )
} }
if (request.method === "POST" && url.endsWith("/api/session")) { if (request.method === "POST" && url.endsWith("/api/session")) {
@ -161,10 +161,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 }))) return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })))
} }
return Effect.succeed( return Effect.succeed(
HttpClientResponse.fromWeb( HttpClientResponse.fromWeb(request, Response.json({ data: [session.data], cursor: { next: "next" } })),
request,
Response.json({ data: [session.data], cursor: { next: "next" } }),
),
) )
}) })
const result = await Effect.gen(function* () { const result = await Effect.gen(function* () {
@ -268,6 +265,16 @@ const admission = {
}, },
} }
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = { const modelSwitchedMessage = {
id: "msg_model", id: "msg_model",
type: "model-switched", type: "model-switched",

View file

@ -46,7 +46,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.file)).toEqual(["read", "list", "find"]) expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["status", "diff"]) expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"]) expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"]) expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"]) expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
}) })
@ -240,10 +240,10 @@ test("session methods use the public HTTP contract", async () => {
}) })
} }
if (url.includes("/prompt")) return Response.json(admission) if (url.includes("/prompt")) return Response.json(admission)
if (url.endsWith("/compact")) return Response.json(compactionAdmission)
if (url.includes("/context")) return Response.json({ data: [] }) if (url.includes("/context")) return Response.json({ data: [] })
if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage }) if (url.includes("/message/")) return Response.json({ data: modelSwitchedMessage })
if (url.endsWith("/api/session/active")) if (url.endsWith("/api/session/active")) return Response.json({ data: { ses_test: { type: "running" } } })
return Response.json({ data: { ses_test: { type: "running" } } })
if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session) if (init?.method === "POST" && url.endsWith("/api/session")) return Response.json(session)
if (init?.method === "POST") return new Response(null, { status: 204 }) if (init?.method === "POST") return new Response(null, { status: 204 })
return Response.json({ data: [session.data], cursor: { next: "next" } }) return Response.json({ data: [session.data], cursor: { next: "next" } })
@ -364,6 +364,16 @@ const admission = {
}, },
} }
const compactionAdmission = {
data: {
type: "compaction",
admittedSeq: 1,
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
},
}
const modelSwitchedMessage = { const modelSwitchedMessage = {
id: "msg_model", id: "msg_model",
type: "model-switched", type: "model-switched",

View file

@ -237,8 +237,8 @@ A host cannot define its own `$codemode` top-level namespace.
CodeMode executes a deliberately bounded JavaScript subset. It supports: CodeMode executes a deliberately bounded JavaScript subset. It supports:
- Plain data literals, property access, assignment, and destructuring. - Plain data literals, property access, assignment, destructuring, and sequence expressions (the comma operator, evaluated left to right with the final value returned).
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets, including assignment-form destructuring such as `for ([key, value] of entries)`), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. - Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.

View file

@ -1135,7 +1135,7 @@ class Interpreter<R> {
} }
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
let assignmentName: string | undefined let assignment: AstNode | undefined
if (left.type === "VariableDeclaration") { if (left.type === "VariableDeclaration") {
const declarations = getArray(left, "declarations") const declarations = getArray(left, "declarations")
@ -1145,8 +1145,13 @@ class Interpreter<R> {
const declarator = asNode(declarations[0], "declarations[0]") const declarator = asNode(declarations[0], "declarations[0]")
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" } declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
} else if (left.type === "Identifier") { } else if (
assignmentName = getString(left, "name") left.type === "Identifier" ||
left.type === "MemberExpression" ||
left.type === "ArrayPattern" ||
left.type === "ObjectPattern"
) {
assignment = left
} else { } else {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left) throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
} }
@ -1155,8 +1160,8 @@ class Interpreter<R> {
if (declaration) { if (declaration) {
self.pushScope() self.pushScope()
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left) yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
} else if (assignmentName) { } else if (assignment) {
self.setIdentifierValue(assignmentName, value, left) yield* self.assignPattern(assignment, value, left)
} }
const result = yield* self.evaluateStatement(body).pipe( const result = yield* self.evaluateStatement(body).pipe(
@ -1554,6 +1559,16 @@ class Interpreter<R> {
return this.evaluateUnaryExpression(node) return this.evaluateUnaryExpression(node)
case "AssignmentExpression": case "AssignmentExpression":
return this.evaluateAssignmentExpression(node) return this.evaluateAssignmentExpression(node)
case "SequenceExpression": {
const self = this
return Effect.gen(function* () {
let result: unknown
for (const expression of getArray(node, "expressions")) {
result = yield* self.evaluateExpression(asNode(expression, "expressions"))
}
return result
})
}
case "CallExpression": case "CallExpression":
return this.evaluateCallExpression(node) return this.evaluateCallExpression(node)
case "ArrowFunctionExpression": case "ArrowFunctionExpression":

View file

@ -464,6 +464,54 @@ describe("H5: builtin coercion functions work as array callbacks", () => {
}) })
}) })
describe("for...of assignment destructuring", () => {
test("assigns entry pairs into predeclared variables", async () => {
expect(
await value(`
let key
let item
const out = []
for ([key, item] of Object.entries({ a: 1, b: 2 })) out.push(key + item)
return { key, item, out }
`),
).toEqual({ key: "b", item: 2, out: ["a1", "b2"] })
})
test("assigns object patterns and defaults", async () => {
expect(
await value(`
let id
let label
const labels = []
for ({ id, label = "unknown" } of [{ id: 1 }, { id: 2, label: "two" }]) labels.push(label)
return { id, label, labels }
`),
).toEqual({ id: 2, label: "two", labels: ["unknown", "two"] })
})
})
describe("sequence expressions", () => {
test("evaluate left to right and return the final value", async () => {
expect(await value(`let x = 0; const result = (x += 1, x *= 3, x + 2); return { x, result }`)).toEqual({
x: 3,
result: 5,
})
})
test("support comma-separated for-loop updates", async () => {
expect(
await value(`
const pairs = []
for (let left = 0, right = 3; left < right; left++, right--) pairs.push([left, right])
return pairs
`),
).toEqual([
[0, 3],
[1, 2],
])
})
})
describe("destructuring assignment", () => { describe("destructuring assignment", () => {
test("assigns object and array patterns to existing bindings", async () => { test("assigns object and array patterns to existing bindings", async () => {
expect( expect(

View file

@ -1,10 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "992b24b9-f3e9-41f5-87a5-4917d1423169", "id": "b0355fd9-bf41-42e3-9dca-76107de27ecd",
"prevIds": [ "prevIds": ["95328a41-789d-44de-9643-6ac6ecd6b4ec"],
"96e9fe64-660f-4a73-9414-b38bb7eac290"
],
"ddl": [ "ddl": [
{ {
"name": "workspace", "name": "workspace",
@ -1012,13 +1010,23 @@
"autoincrement": false, "autoincrement": false,
"default": null, "default": null,
"generated": null, "generated": null,
"name": "type",
"entityType": "columns",
"table": "session_input"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "prompt", "name": "prompt",
"entityType": "columns", "entityType": "columns",
"table": "session_input" "table": "session_input"
}, },
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": false,
"autoincrement": false, "autoincrement": false,
"default": null, "default": null,
"generated": null, "generated": null,
@ -1166,6 +1174,26 @@
"entityType": "columns", "entityType": "columns",
"table": "session" "table": "session"
}, },
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_session_id",
"entityType": "columns",
"table": "session"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "fork_message_id",
"entityType": "columns",
"table": "session"
},
{ {
"type": "text", "type": "text",
"notNull": true, "notNull": true,
@ -1547,13 +1575,9 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1562,13 +1586,9 @@
"table": "workspace" "table": "workspace"
}, },
{ {
"columns": [ "columns": ["active_account_id"],
"active_account_id"
],
"tableTo": "account", "tableTo": "account",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "SET NULL", "onDelete": "SET NULL",
"nameExplicit": false, "nameExplicit": false,
@ -1577,13 +1597,9 @@
"table": "account_state" "table": "account_state"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"tableTo": "event_sequence", "tableTo": "event_sequence",
"columnsTo": [ "columnsTo": ["aggregate_id"],
"aggregate_id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1592,13 +1608,9 @@
"table": "event" "table": "event"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1607,13 +1619,9 @@
"table": "permission" "table": "permission"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1622,13 +1630,9 @@
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1637,13 +1641,9 @@
"table": "instruction_checkpoint" "table": "instruction_checkpoint"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1652,13 +1652,9 @@
"table": "instruction_entry" "table": "instruction_entry"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1667,13 +1663,9 @@
"table": "message" "table": "message"
}, },
{ {
"columns": [ "columns": ["message_id"],
"message_id"
],
"tableTo": "message", "tableTo": "message",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1682,13 +1674,9 @@
"table": "part" "table": "part"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1697,13 +1685,9 @@
"table": "session_input" "table": "session_input"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1712,13 +1696,9 @@
"table": "session_message" "table": "session_message"
}, },
{ {
"columns": [ "columns": ["project_id"],
"project_id"
],
"tableTo": "project", "tableTo": "project",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1727,13 +1707,9 @@
"table": "session" "table": "session"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1742,13 +1718,9 @@
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"tableTo": "session", "tableTo": "session",
"columnsTo": [ "columnsTo": ["id"],
"id"
],
"onUpdate": "NO ACTION", "onUpdate": "NO ACTION",
"onDelete": "CASCADE", "onDelete": "CASCADE",
"nameExplicit": false, "nameExplicit": false,
@ -1757,184 +1729,140 @@
"table": "session_share" "table": "session_share"
}, },
{ {
"columns": [ "columns": ["email", "url"],
"email",
"url"
],
"nameExplicit": false, "nameExplicit": false,
"name": "control_account_pk", "name": "control_account_pk",
"entityType": "pks", "entityType": "pks",
"table": "control_account" "table": "control_account"
}, },
{ {
"columns": [ "columns": ["project_id", "directory"],
"project_id",
"directory"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_directory_pk", "name": "project_directory_pk",
"entityType": "pks", "entityType": "pks",
"table": "project_directory" "table": "project_directory"
}, },
{ {
"columns": [ "columns": ["session_id", "key"],
"session_id",
"key"
],
"nameExplicit": false, "nameExplicit": false,
"name": "instruction_entry_pk", "name": "instruction_entry_pk",
"entityType": "pks", "entityType": "pks",
"table": "instruction_entry" "table": "instruction_entry"
}, },
{ {
"columns": [ "columns": ["session_id", "position"],
"session_id",
"position"
],
"nameExplicit": false, "nameExplicit": false,
"name": "todo_pk", "name": "todo_pk",
"entityType": "pks", "entityType": "pks",
"table": "todo" "table": "todo"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "workspace_pk", "name": "workspace_pk",
"table": "workspace", "table": "workspace",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["name"],
"name"
],
"nameExplicit": false, "nameExplicit": false,
"name": "data_migration_pk", "name": "data_migration_pk",
"table": "data_migration", "table": "data_migration",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_state_pk", "name": "account_state_pk",
"table": "account_state", "table": "account_state",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "account_pk", "name": "account_pk",
"table": "account", "table": "account",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "credential_pk", "name": "credential_pk",
"table": "credential", "table": "credential",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["aggregate_id"],
"aggregate_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_sequence_pk", "name": "event_sequence_pk",
"table": "event_sequence", "table": "event_sequence",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "event_pk", "name": "event_pk",
"table": "event", "table": "event",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "permission_pk", "name": "permission_pk",
"table": "permission", "table": "permission",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "project_pk", "name": "project_pk",
"table": "project", "table": "project",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "instruction_checkpoint_pk", "name": "instruction_checkpoint_pk",
"table": "instruction_checkpoint", "table": "instruction_checkpoint",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "message_pk", "name": "message_pk",
"table": "message", "table": "message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "part_pk", "name": "part_pk",
"table": "part", "table": "part",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_input_pk", "name": "session_input_pk",
"table": "session_input", "table": "session_input",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_message_pk", "name": "session_message_pk",
"table": "session_message", "table": "session_message",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["id"],
"id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_pk", "name": "session_pk",
"table": "session", "table": "session",
"entityType": "pks" "entityType": "pks"
}, },
{ {
"columns": [ "columns": ["session_id"],
"session_id"
],
"nameExplicit": false, "nameExplicit": false,
"name": "session_share_pk", "name": "session_share_pk",
"table": "session_share", "table": "session_share",
@ -2066,6 +1994,10 @@
"value": "promoted_seq", "value": "promoted_seq",
"isExpression": false "isExpression": false
}, },
{
"value": "type",
"isExpression": false
},
{ {
"value": "delivery", "value": "delivery",
"isExpression": false "isExpression": false
@ -2078,7 +2010,21 @@
"isUnique": false, "isUnique": false,
"where": null, "where": null,
"origin": "manual", "origin": "manual",
"name": "session_input_session_pending_delivery_seq_idx", "name": "session_input_session_pending_type_delivery_seq_idx",
"entityType": "indexes",
"table": "session_input"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
}
],
"isUnique": true,
"where": "\"session_input\".\"type\" = 'compaction' and \"session_input\".\"promoted_seq\" is null",
"origin": "manual",
"name": "session_input_session_pending_compaction_idx",
"entityType": "indexes", "entityType": "indexes",
"table": "session_input" "table": "session_input"
}, },

View file

@ -7,8 +7,11 @@ export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
startup: PositiveInt.pipe(Schema.optional).annotate({ startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.", description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}), }),
request: PositiveInt.pipe(Schema.optional).annotate({ catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.", description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}), }),
}) {} }) {}

View file

@ -44,6 +44,9 @@ export const migrations = (
import("./migration/20260703090000_reset_v2_event_rename_sweep"), import("./migration/20260703090000_reset_v2_event_rename_sweep"),
import("./migration/20260703181610_event_created_column"), import("./migration/20260703181610_event_created_column"),
import("./migration/20260703190000_reset_v2_shell_event_payloads"), import("./migration/20260703190000_reset_v2_shell_event_payloads"),
import("./migration/20260703200000_reset_v2_session_events"),
import("./migration/20260705180000_rename_instructions"), import("./migration/20260705180000_rename_instructions"),
import("./migration/20260706223930_add-session-fork"),
import("./migration/20260707010146_durable_session_inbox"),
]) ])
).map((module) => module.default) satisfies DatabaseMigration.Migration[] ).map((module) => module.default) satisfies DatabaseMigration.Migration[]

View file

@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260703200000_reset_v2_session_events",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DELETE FROM \`session_input\`;`)
yield* tx.run(`DELETE FROM \`session_message\`;`)
yield* tx.run(`DELETE FROM \`event\`;`)
yield* tx.run(`DELETE FROM \`event_sequence\`;`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,39 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260706223930_add-session-fork",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_session_id\` text;`)
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_message_id\` text;`)
yield* tx.run(`
UPDATE \`session\`
SET
\`parent_id\` = NULL,
\`fork_session_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.parentID')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
),
\`fork_message_id\` = (
SELECT json_extract(\`event\`.\`data\`, '$.from')
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
ORDER BY \`event\`.\`seq\`
LIMIT 1
)
WHERE EXISTS (
SELECT 1
FROM \`event\`
WHERE \`event\`.\`aggregate_id\` = \`session\`.\`id\`
AND \`event\`.\`type\` = 'session.forked'
);
`)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -0,0 +1,43 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260707010146_durable_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_session_input\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_input_session_id_session_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`INSERT INTO \`__new_session_input\`(\`id\`, \`session_id\`, \`type\`, \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\`) SELECT \`id\`, \`session_id\`, 'prompt', \`prompt\`, \`delivery\`, \`admitted_seq\`, \`promoted_seq\`, \`time_created\` FROM \`session_input\`;`,
)
yield* tx.run(`DROP TABLE \`session_input\`;`)
yield* tx.run(`ALTER TABLE \`__new_session_input\` RENAME TO \`session_input\`;`)
yield* tx.run(`PRAGMA foreign_keys=ON;`)
yield* tx.run(`DROP INDEX IF EXISTS \`session_input_session_pending_delivery_seq_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
)
})
},
} satisfies DatabaseMigration.Migration

View file

@ -170,8 +170,9 @@ export default {
CREATE TABLE \`session_input\` ( CREATE TABLE \`session_input\` (
\`id\` text PRIMARY KEY, \`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL, \`session_id\` text NOT NULL,
\`prompt\` text NOT NULL, \`type\` text NOT NULL,
\`delivery\` text NOT NULL, \`prompt\` text,
\`delivery\` text,
\`admitted_seq\` integer NOT NULL, \`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer, \`promoted_seq\` integer,
\`time_created\` integer NOT NULL, \`time_created\` integer NOT NULL,
@ -196,6 +197,8 @@ export default {
\`project_id\` text NOT NULL, \`project_id\` text NOT NULL,
\`workspace_id\` text, \`workspace_id\` text,
\`parent_id\` text, \`parent_id\` text,
\`fork_session_id\` text,
\`fork_message_id\` text,
\`slug\` text NOT NULL, \`slug\` text NOT NULL,
\`directory\` text NOT NULL, \`directory\` text NOT NULL,
\`path\` text, \`path\` text,
@ -259,7 +262,10 @@ export default {
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`) yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`) yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run( yield* tx.run(
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`, `CREATE INDEX \`session_input_session_pending_type_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`type\`,\`delivery\`,\`admitted_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_pending_compaction_idx\` ON \`session_input\` (\`session_id\`) WHERE "session_input"."type" = 'compaction' and "session_input"."promoted_seq" is null;`,
) )
yield* tx.run( yield* tx.run(
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`, `CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,

View file

@ -31,7 +31,8 @@ import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000 const DEFAULT_STARTUP_TIMEOUT = 30_000
const DEFAULT_REQUEST_TIMEOUT = 30_000 const DEFAULT_CATALOG_TIMEOUT = 30_000
const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours
type Transport = StdioClientTransport | StreamableHTTPClientTransport type Transport = StdioClientTransport | StreamableHTTPClientTransport
@ -206,7 +207,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.ignore, Effect.ignore,
), ),
) )
const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT
const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT
return { return {
instructions: client.getInstructions()?.trim() || undefined, instructions: client.getInstructions()?.trim() || undefined,
tools: () => tools: () =>
@ -218,11 +220,11 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => { async (cursor) => {
const params = cursor === undefined ? undefined : { cursor } const params = cursor === undefined ? undefined : { cursor }
try { try {
return await client.listTools(params, { timeout: requestTimeout }) return await client.listTools(params, { timeout: catalogTimeout })
} catch (error) { } catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, { return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: requestTimeout, timeout: catalogTimeout,
}) })
} }
}, },
@ -248,7 +250,7 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => { async (cursor) => {
const params = cursor === undefined ? undefined : { cursor } const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: requestTimeout, timeout: catalogTimeout,
}) })
}, },
(result) => result.prompts, (result) => result.prompts,
@ -273,7 +275,7 @@ export const connect = Effect.fnUntraced(function* (
client.request( client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema, GetPromptResultSchema,
{ signal }, { signal, timeout: executionTimeout },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
@ -287,8 +289,8 @@ export const connect = Effect.fnUntraced(function* (
client.callTool( client.callTool(
{ name: input.name, arguments: input.args ?? {} }, { name: input.name, arguments: input.args ?? {} },
CallToolResultSchema, CallToolResultSchema,
// Keep progress tokens available without imposing a client timeout on tool execution. // Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, resetTimeoutOnProgress: true, onprogress: () => {} }, { signal, timeout: executionTimeout, onprogress: () => {} },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(

View file

@ -67,7 +67,13 @@ export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("P
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", { export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
rules: Permission.Ruleset, rules: Permission.Ruleset,
}) {} permission: Schema.String,
resources: Schema.Array(Schema.String),
}) {
override get message() {
return `Permission denied: ${this.permission}`
}
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", { export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
requestID: ID, requestID: ID,
@ -201,6 +207,8 @@ const layer = Layer.effect(
if (result.effect === "deny") { if (result.effect === "deny") {
return yield* new BlockedError({ return yield* new BlockedError({
rules: relevant(input, result.rules), rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
}) })
} }
if (result.effect === "allow") return if (result.effect === "allow") return

View file

@ -13,14 +13,14 @@ import { FSUtil } from "../fs-util"
import os from "os" import os from "os"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import customizeOpencodeContent from "./skill/customize-opencode.md" with { type: "text" } import opencodeContent from "./skill/opencode.md" with { type: "text" }
import reportContent from "./skill/report.md" with { type: "text" } import reportContent from "./skill/report.md" with { type: "text" }
export const CustomizeOpencodeContent = customizeOpencodeContent export const OpencodeContent = opencodeContent
export const ReportContent = reportContent export const ReportContent = reportContent
const CUSTOMIZE_OPENCODE_DESCRIPTION = export const OpencodeDescription =
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself." "Use this skill for any question about OpenCode itself, including how OpenCode works, using or configuring it, troubleshooting it, developing plugins or integrations, using the OpenCode SDK, clients, server, or API, and contributing to the OpenCode codebase. Also use it for OpenCode agents, commands, skills, tools, permissions, MCP servers, providers, models, themes, keybinds, formatters, the CLI, TUI, desktop app, and web app."
const REPORT_DESCRIPTION = const REPORT_DESCRIPTION =
"Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI." "Use when the user wants to report an opencode issue or bug. Collect standard diagnostics, add user-specific reproduction context, and publish the issue with GitHub CLI."
@ -33,10 +33,10 @@ export const Plugin = define({
SkillV2.EmbeddedSource.make({ SkillV2.EmbeddedSource.make({
type: "embedded", type: "embedded",
skill: SkillV2.Info.make({ skill: SkillV2.Info.make({
name: "customize-opencode", name: "opencode",
description: CUSTOMIZE_OPENCODE_DESCRIPTION, description: OpencodeDescription,
location: AbsolutePath.make("/builtin/customize-opencode.md"), location: AbsolutePath.make("/builtin/opencode.md"),
content: CustomizeOpencodeContent, content: OpencodeContent,
}), }),
}), }),
) )

View file

@ -1,452 +0,0 @@
<!--
Built-in skill. Name and description are registered in code at
packages/core/src/plugin/skill.ts
and CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION). The body below becomes the
skill's content.
-->
# Customizing opencode
opencode validates its own config strictly and refuses to start when a field
is wrong. The shapes below cover the common surface area, but they are a
**summary, not the source of truth**.
## Full schema reference
The authoritative list of every config option — with field types, enums,
defaults, and descriptions — lives in the published JSON Schema:
**<https://opencode.ai/config.json>**
If a field is not documented in this skill, or you need to confirm an exact
shape before writing config, **fetch that URL and read the schema directly**
rather than guessing. opencode hard-fails on invalid config, so the cost of a
wrong shape is a broken startup.
Independently, every `opencode.json` should declare
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
mistakes as they type.
## Applying changes
Config is loaded once when opencode starts and is not hot-reloaded. After
saving changes to `opencode.json`, an agent file, a skill, a plugin, or any
other config-time file, **tell the user to quit and restart opencode** for
the changes to take effect. The running session will keep using the
already-loaded config until then.
## Where files live
| Scope | Path |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Project config | `./opencode.json`, `./opencode.jsonc`, or `.opencode/opencode.json` (opencode walks up from the cwd to the worktree root) |
| Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) |
| Project agents | `.opencode/agent/<name>.md` or `.opencode/agents/<name>.md` |
| Global agents | `~/.config/opencode/agent(s)/<name>.md` |
| Project commands | `.opencode/command/<name>.md` or `.opencode/commands/<name>.md` |
| Global commands | `~/.config/opencode/command(s)/<name>.md` |
| Project skills | `.opencode/skill(s)/<name>/SKILL.md` |
| Global skills | `~/.config/opencode/skill(s)/<name>/SKILL.md` |
| External skills (auto-loaded) | `~/.claude/skills/<name>/SKILL.md`, `~/.agents/skills/<name>/SKILL.md` |
Configs from each scope are deep-merged. Project overrides global. Unknown
top-level keys in `opencode.json` are rejected with `ConfigInvalidError`.
## opencode.json
Every field is optional.
```json
{
"$schema": "https://opencode.ai/config.json",
"username": "string",
"model": "provider/model-id",
"small_model": "provider/model-id",
"default_agent": "agent-name",
"shell": "/bin/zsh",
"logLevel": "DEBUG" | "INFO" | "WARN" | "ERROR",
"share": "manual" | "auto" | "disabled",
"autoupdate": true | false | "notify",
"snapshot": true,
"instructions": ["AGENTS.md", "docs/style.md"],
"skills": {
"paths": [".opencode/skills", "/abs/path/to/skills"],
"urls": ["https://example.com/.well-known/skills/"]
},
"references": {
"docs": {
"path": "../docs",
"description": "Use for product behavior and documentation conventions"
},
"sdk": {
"repository": "owner/sdk",
"branch": "main",
"description": "Use for SDK implementation details",
"hidden": true
}
},
"agent": {
"my-agent": {
"model": "anthropic/claude-sonnet-4-6",
"mode": "subagent",
"description": "...",
"permission": { "edit": "deny" }
}
},
"command": {
"deploy": { "description": "...", "template": "..." }
},
"provider": {
"anthropic": { "options": { "apiKey": "..." } }
},
"disabled_providers": ["openai"],
"enabled_providers": ["anthropic"],
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": {}
},
"remote-thing": {
"type": "remote",
"url": "https://...",
"headers": { "Authorization": "Bearer ..." }
}
},
"plugin": [
"opencode-gemini-auth",
"opencode-foo@1.2.3",
"./local-plugin.ts",
["opencode-bar", { "option": "value" }]
],
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "*": "ask" }
},
"formatter": false,
"lsp": false,
"experimental": {
"primary_tools": ["edit"],
"mcp_timeout": 30000
},
"tool_output": { "max_lines": 200, "max_bytes": 8192 },
"compaction": { "auto": true, "tail_turns": 15 }
}
```
Shape notes worth being explicit about:
- `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`.
- `skills` is an object with `paths` and/or `urls`, not an array.
- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand.
- `agent` is an object keyed by agent name, not an array.
- `command` is an object keyed by command name, not an array.
- `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name.
## Skills
opencode's skill loader scans for `**/SKILL.md` inside skill directories. The
file is named `SKILL.md` exactly, and lives in its own folder named after the
skill:
```
.opencode/skills/my-skill/SKILL.md
```
Frontmatter:
```markdown
---
name: my-skill
description: One sentence covering what this skill does AND when to trigger it. Front-load the literal keywords or filenames the user is likely to say.
---
# My Skill
(skill body in markdown: instructions, examples, references)
```
- `name` is required, lowercase hyphen-separated, up to 64 chars, and matches the folder name.
- `description` is effectively required: skills without one are filtered out and never surfaced to the model. Cover both _what_ the skill does and _when_ to use it. Write in third person ("Use when...", not "I help with..."). Front-load concrete trigger keywords and filenames; gate with "Use ONLY when..." if the skill should stay quiet on adjacent topics.
- Optional: `license`, `compatibility`, `metadata` (string-string map).
Register skills from non-default locations via `skills.paths` (scanned
recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of
skills).
## References
References make local directories and Git repositories outside the active
project available as supporting context. Configure them under `references`,
keyed by the alias used in `@` autocomplete:
```json
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Use for product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main",
"description": "Use for Effect implementation details"
}
}
}
```
Local `path` values may be relative to the declaring config, absolute, or use
`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub
`owner/repo` shorthand; `branch` is optional. Both forms support optional
`description` and `hidden` fields.
- Only references with a `description` are advertised to agents in system context.
- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path.
- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply.
- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories.
## Agents
Two ways to define an agent. Use the file form for anything non-trivial.
### Inline (in `opencode.json`)
```json
{
"agent": {
"my-reviewer": {
"description": "Reviews PRs for style violations.",
"mode": "subagent",
"model": "anthropic/claude-sonnet-4-6",
"permission": { "edit": "deny", "bash": "ask" },
"prompt": "You are a strict PR reviewer..."
}
}
}
```
### File
```
.opencode/agent/my-reviewer.md OR .opencode/agents/my-reviewer.md
```
```markdown
---
description: Reviews PRs for style violations.
mode: subagent
model: anthropic/claude-sonnet-4-6
permission:
edit: deny
bash: ask
---
You are a strict PR reviewer. Focus on...
```
The file body becomes the agent's `prompt`. Do not also put `prompt:` in the
frontmatter.
`mode` is one of `"primary"`, `"subagent"`, `"all"`.
Allowed top-level frontmatter fields: `name, model, variant, description, mode,
hidden, color, steps, options, permission, disable, temperature, top_p`. Any
unknown field is silently routed into `options`.
To disable a built-in agent: `agent: { build: { disable: true } }`, or in a
file, `disable: true` in frontmatter.
`default_agent` must point to a non-hidden, primary-mode agent.
### Built-in agents
opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agents:
`compaction`, `title`, `summary`. To override a built-in's fields, define the
same key in `agent: { <name>: { ... } }`.
## Commands
opencode's command loader scans for `**/*.md` inside command directories. The
file is named after the command, and lives directly inside the `command` folder:
```
.opencode/command/deploy.md
```
Frontmatter:
```markdown
---
description: One sentence describing what the command does.
agent: build
model: anthropic/claude-sonnet-4-6
---
(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input)
```
- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter.
- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments.
- Optional: `description`, `agent`, `model`, `variant`, `subtask`.
## Plugins
`plugin:` is an array. Each entry is one of:
```json
"plugin": [
"opencode-gemini-auth", // npm spec, latest
"opencode-foo@1.2.3", // npm spec, pinned
"./local-plugin.ts", // file path, relative to the declaring config
"file:///abs/path/plugin.js", // file URL
["opencode-bar", { "key": "val" }] // tuple form with options
]
```
Auto-discovered plugins (no config entry needed): any `*.ts` or `*.js` file in
`.opencode/plugin/` or `.opencode/plugins/`.
A plugin module exports `default` (or any named export) of type
`Plugin = (input: PluginInput, options?) => Promise<Hooks>`. The export is a
function, not a plain object literal, and the function returns an object
(return `{}` if there is nothing to register).
```ts
import type { Plugin } from "@opencode-ai/plugin"
export default (async ({ client, project, directory, $ }) => {
return {
config: (cfg) => {
// cfg is the live merged config; mutate fields here.
},
"tool.execute.before": async (input, output) => {
// mutate output.args before the tool runs
},
}
}) satisfies Plugin
```
Hook surface (mutate `output` in place; return `void`):
- `event(input)`: every bus event
- `config(cfg)`: once on init with the merged config
- `chat.message`, `chat.params`, `chat.headers`
- `tool.execute.before`, `tool.execute.after`
- `tool.definition`
- `command.execute.before`
- `shell.env`
- `permission.ask`
- `experimental.chat.messages.transform`, `experimental.chat.system.transform`,
`experimental.session.compacting`, `experimental.compaction.autocontinue`,
`experimental.text.complete`
Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`,
`auth: { ... }`, `provider: { ... }`.
## MCP servers
`mcp:` is an object keyed by server name. Each server is discriminated by
`type`:
```json
{
"mcp": {
"playwright": {
"type": "local",
"command": ["npx", "-y", "@playwright/mcp"],
"enabled": true,
"env": { "BROWSER": "chromium" }
},
"github": {
"type": "remote",
"url": "https://...",
"enabled": true,
"headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" }
},
"old-server": { "enabled": false }
}
}
```
`command` is an array of strings. `type` is required. Use `enabled: false` to
disable a server inherited from a parent config. String values such as header
tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style
`${VAR}` is not substituted.
## Permissions
```json
"permission": {
"edit": "deny",
"bash": { "git *": "allow", "rm *": "deny", "*": "ask" },
"external_directory": { "~/secrets/**": "deny", "*": "allow" }
}
```
Actions: `"allow"`, `"ask"`, `"deny"`.
Per-tool value forms: `"allow"` shorthand (treated as `{"*": "allow"}`), or an
object `{ pattern: action }`. Within an object, **insertion order matters**.
opencode evaluates the LAST matching rule, so put broad rules first and narrow
rules last.
`permission: "allow"` (a string at the top level) is shorthand for "allow
everything" and is rarely what the user wants.
Known permission keys: `read, edit, glob, grep, list, bash, task,
external_directory, todowrite, question, webfetch, websearch, lsp, doom_loop,
skill`. Some of these (`todowrite,
question, webfetch, websearch, doom_loop`) only accept a flat
action, not a per-pattern object.
`external_directory` patterns are filesystem paths (use `~/`, absolute paths,
or globs like `~/projects/**`).
Per-agent `permission:` overrides top-level `permission:`. Plan Mode lives on
the `plan` agent's permission ruleset (`edit: deny *`).
## Escape hatches
When a user's config is broken and opencode won't start, these env vars help:
- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json`
and start from globals only. Run from the project directory, opencode loads,
the user edits the broken file, then they restart without the flag.
- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config.
- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`:
inject inline JSON as a final local-scope merge.
- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins.
- `OPENCODE_PURE=1`: skip external plugins entirely.
- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`,
`OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under
`~/.claude/` and `~/.agents/`.
## When proposing edits
- Validate against the schema before writing. If you are unsure of a field's
exact shape, or the field is not covered in this skill, fetch
`https://opencode.ai/config.json` and read the schema rather than guessing.
- Preserve `$schema` and any existing fields the user did not ask to change.
- For agent, command, skill, and plugin definitions, prefer creating new files
in the correct location over inlining everything in `opencode.json`.
- If the user's existing config is malformed, point them at the env-var escape
hatches above so they can edit from inside opencode without breaking their
session.
- After saving any config change, remind the user to quit and restart opencode
— running sessions keep using the already-loaded config.

View file

@ -0,0 +1,112 @@
# OpenCode
Use this guide as the starting point for work involving OpenCode itself. It
covers the core concepts needed to configure and customize OpenCode, extend it
with plugins, and build integrations with the OpenCode SDK, clients, and API.
Full documentation is available at <https://opencode.mintlify.site/>. Consult
it when this overview does not contain enough detail for the task.
## Configuration
OpenCode configuration uses JSON or JSONC. Include the published schema so the
user's editor can validate fields and provide autocomplete:
```jsonc
{
"$schema": "https://opencode.ai/config.json"
}
```
Global configuration lives at `~/.config/opencode/opencode.json(c)` and applies
to every project for that user. Project configuration can live in any directory
as `opencode.json(c)` or `.opencode/opencode.json(c)`, including nested packages
in a monorepo.
When OpenCode starts, it searches upward from the current directory for project
configuration and merges the files it finds with the global configuration.
Common configuration fields include `model`, `default_agent`, `permissions`,
`agents`, `commands`, `plugins`, `providers`, `mcp`, `skills`, `instructions`,
`references`, `formatter`, and `lsp`.
Do not guess field names or shapes. Use
<https://opencode.ai/config.json> as the source of truth and preserve unrelated
settings when editing an existing file.
See the [full configuration guide](https://opencode.mintlify.site/config) for
every field, examples, config locations, and links to dedicated feature guides.
## Service
OpenCode uses a client-server architecture. Interfaces such as the TUI connect
to a background OpenCode service, which owns sessions, configuration, plugins,
permissions, and tool execution.
Configuration and related files are typically watched and reloaded while the
service is running. If a change does not appear, restart the service:
```sh
opencode2 service restart
```
Check its status after restarting:
```sh
opencode2 service status
```
## API
OpenCode exposes an HTTP API from its server. The API is described by an
OpenAPI document available from the running server at `/openapi.json`.
Use OpenCode's built-in `api` command for local requests. It discovers the same
background server used by the TUI, starts it when necessary, and applies the
server's authentication headers automatically.
Call an endpoint with an HTTP method and path:
```sh
opencode2 api get /api/health
```
Pass a request body with `--data` or `-d`, and additional headers with
`--header` or `-H`:
```sh
opencode2 api post /api/example --data '{"key":"value"}'
opencode2 api get /api/example --header 'X-Example:value'
```
Request bodies default to `Content-Type: application/json`. When OpenCode is
connected to an explicit server instead of its managed background service, use
the same configured server and authentication context rather than constructing
an unauthenticated request separately.
See the [full API reference](https://opencode.mintlify.site/api) for available
endpoints, parameters, request bodies, and response schemas. The
raw [OpenAPI specification](https://opencode.mintlify.site/openapi.json) is also
available for code generation and other tooling.
## Troubleshooting
OpenCode runs a client and a background server. Start by determining whether a
problem belongs to the client, the shared server, or one project.
- Check the service with `opencode2 service status` and verify the API with
`opencode2 api get /api/health`.
- Inspect `~/.local/share/opencode/log/opencode.log`. Filter `role=cli` for
client startup and `role=server` for sessions, providers, plugins,
permissions, and tools.
- Run one reproduction with `OPENCODE_LOG_LEVEL=DEBUG` when normal logs are not
sufficient.
- Do not delete or edit the database, service registration, or service config
while diagnosing a problem. Back up persistent data before inspecting it
with external tools.
- Redact API keys, authorization headers, prompts, file contents, and other
sensitive data before sharing diagnostics.
See the [full troubleshooting guide](https://opencode.mintlify.site/troubleshooting)
for service lifecycle commands, API inspection, log locations, explicit server
connections, issue-reporting details, and local development paths.

View file

@ -33,7 +33,6 @@ import { MessageDecodeError } from "./session/error"
import { SessionEvent } from "./session/event" import { SessionEvent } from "./session/event"
import { SessionInput } from "./session/input" import { SessionInput } from "./session/input"
import { Snapshot } from "./snapshot" import { Snapshot } from "./snapshot"
import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert" import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert" import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util" import { FSUtil } from "./fs-util"
@ -96,6 +95,7 @@ type CreateInput = CreateBaseInput &
({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never }) ({ location: Location.Ref; parentID?: never } | { parentID: SessionSchema.ID; location?: never })
type CompactInput = { type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
} }
@ -125,6 +125,13 @@ export class AttachmentError extends Schema.TaggedErrorClass<AttachmentError>()(
uri: Schema.String, uri: Schema.String,
message: Schema.String, message: Schema.String,
}) {} }) {}
export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionConflictError>()(
"Session.CompactionConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", { export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
}) {} }) {}
@ -140,6 +147,7 @@ export type Error =
| OperationUnavailableError | OperationUnavailableError
| PromptConflictError | PromptConflictError
| AttachmentError | AttachmentError
| CompactionConflictError
| BusyError | BusyError
| SkillNotFoundError | SkillNotFoundError
| CommandV2.NotFoundError | CommandV2.NotFoundError
@ -153,6 +161,7 @@ export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError> readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError> readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError> readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: { readonly messages: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
limit?: number limit?: number
@ -223,7 +232,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError> }) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: ( readonly compact: (
input: CompactInput, input: CompactInput,
) => Effect.Effect<void, NotFoundError | BusyError | MessageDecodeError | OperationUnavailableError> ) => Effect.Effect<SessionInput.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>> readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@ -363,6 +372,15 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID }) if (!session) return yield* new NotFoundError({ sessionID })
return session return session
}), }),
remove: Effect.fn("V2Session.remove")(function* (sessionID) {
yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* events.publish(SessionEvent.Deleted, { sessionID })
yield* events.remove(sessionID)
}),
list: Effect.fn("V2Session.list")(function* (input = {}) { list: Effect.fn("V2Session.list")(function* (input = {}) {
const direction = input.anchor?.direction ?? "next" const direction = input.anchor?.direction ?? "next"
const requestedOrder = input.order ?? "desc" const requestedOrder = input.order ?? "desc"
@ -529,7 +547,7 @@ const layer = Layer.effect(
if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID) if ((yield* execution.active).has(input.sessionID)) yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () { const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service const shell = yield* Shell.Service
return yield* shell.create({ command: input.command, cwd: session.location.directory }) return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
}).pipe(Effect.provide(locations.get(session.location))) }).pipe(Effect.provide(locations.get(session.location)))
yield* events.publish( yield* events.publish(
SessionEvent.Shell.Started, SessionEvent.Shell.Started,
@ -616,19 +634,20 @@ const layer = Layer.effect(
}) })
}), }),
compact: Effect.fn("V2Session.compact")(function* (input) { compact: Effect.fn("V2Session.compact")(function* (input) {
const session = yield* result.get(input.sessionID) yield* result.get(input.sessionID)
// TODO: admit manual compaction as durable pending work, like prompt input, instead of rejecting active sessions. const inputID = input.id ?? SessionMessage.ID.create()
if ((yield* execution.active).has(input.sessionID)) return yield* new BusyError({ sessionID: input.sessionID }) const admitted = yield* SessionInput.admitCompaction(db, events, {
const context = yield* store.context(input.sessionID) id: inputID,
const compacted = yield* Effect.gen(function* () { sessionID: input.sessionID,
const compaction = yield* SessionCompaction.Service
return yield* compaction.compactManual({ session, messages: context })
}).pipe( }).pipe(
Effect.provide(locations.get(session.location)), Effect.catchDefect((defect) =>
Effect.catch(() => Effect.succeed(false)), defect instanceof SessionInput.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
) )
if (!compacted) return yield* new OperationUnavailableError({ operation: "compact" }) yield* execution.wake(input.sessionID)
return undefined return admitted
}), }),
wait: Effect.fn("V2Session.wait")(function* (sessionID) { wait: Effect.fn("V2Session.wait")(function* (sessionID) {
yield* result.get(sessionID) yield* result.get(sessionID)
@ -724,11 +743,7 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) { const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const files = input.files const files = input.files
? yield* Effect.forEach( ? yield* Effect.forEach(input.files, (file) => materializeAttachment(fs, file), { concurrency: 8 })
input.files,
(file) => materializeAttachment(fs, file),
{ concurrency: 8 },
)
: undefined : undefined
return Prompt.make({ text: input.text, agents: input.agents, files }) return Prompt.make({ text: input.text, agents: input.agents, files })
}) })
@ -746,6 +761,7 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
start: undefined, start: undefined,
end: undefined, end: undefined,
name: undefined, name: undefined,
mime: undefined,
} }
: yield* readFileAttachment(fs, input.uri) : yield* readFileAttachment(fs, input.uri)
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES) if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
@ -754,11 +770,15 @@ const materializeAttachment = Effect.fn("V2Session.materializeAttachment")(funct
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`, message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
}) })
const mime = Mime.detect(resolved.bytes) const mime = resolved.mime ?? Mime.detect(resolved.bytes)
const content = const content =
mime === "text/plain" && resolved.start !== undefined mime === "text/plain" && resolved.start !== undefined
? Buffer.from( ? Buffer.from(
Buffer.from(resolved.bytes).toString("utf8").split("\n").slice(resolved.start - 1, resolved.end).join("\n"), Buffer.from(resolved.bytes)
.toString("utf8")
.split("\n")
.slice(resolved.start - 1, resolved.end)
.join("\n"),
) )
: resolved.bytes : resolved.bytes
return FileAttachment.create({ return FileAttachment.create({
@ -788,19 +808,38 @@ const readFileAttachment = Effect.fn("V2Session.readFileAttachment")(function* (
}, },
catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }), catch: () => new AttachmentError({ uri, message: `Invalid file URI: ${uri}` }),
}) })
const info = yield* fs.stat(target).pipe( const info = yield* fs
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), .stat(target)
) .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
if (info.type === "Directory") {
const entries = yield* fs
.readDirectoryEntries(target)
.pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return {
bytes: Buffer.from(
entries
.filter((entry) => entry.type === "file" || entry.type === "directory")
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "directory" ? -1 : 1))
.map((entry) => entry.name + (entry.type === "directory" ? path.sep : ""))
.join("\n"),
),
source: { type: "uri" as const, uri },
start: undefined,
end: undefined,
name: path.basename(target),
mime: "application/x-directory",
}
}
if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` }) if (info.type !== "File") return yield* new AttachmentError({ uri, message: `Attachment is not a file: ${uri}` })
if (Number(info.size) > MAX_ATTACHMENT_BYTES) if (Number(info.size) > MAX_ATTACHMENT_BYTES)
return yield* new AttachmentError({ return yield* new AttachmentError({
uri, uri,
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`, message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${uri}`,
}) })
const bytes = yield* fs.readFile(target).pipe( const bytes = yield* fs
Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })), .readFile(target)
) .pipe(Effect.mapError(() => new AttachmentError({ uri, message: `Unable to read attachment: ${uri}` })))
return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target) } return { bytes, source: { type: "uri" as const, uri }, start, end, name: path.basename(target), mime: undefined }
}) })
function decodeDataURL(uri: string) { function decodeDataURL(uri: string) {

View file

@ -217,7 +217,13 @@ const make = (dependencies: Dependencies) => {
.pipe( .pipe(
Stream.runForEach((event) => { Stream.runForEach((event) => {
if (LLMEvent.is.providerError(event)) failed = true if (LLMEvent.is.providerError(event)) failed = true
if (LLMEvent.is.textDelta(event)) chunks.push(event.text) if (LLMEvent.is.textDelta(event)) {
chunks.push(event.text)
return dependencies.events.publish(SessionEvent.Compaction.Delta, {
sessionID: input.sessionID,
text: event.text,
})
}
return Effect.void return Effect.void
}), }),
Effect.as(true), Effect.as(true),
@ -254,7 +260,9 @@ const make = (dependencies: Dependencies) => {
if (context === undefined || context <= 0) return false if (context === undefined || context <= 0) return false
const selected = select(input.messages, config.tokens) const selected = select(input.messages, config.tokens)
if (!selected) return false if (!selected) return false
const previousSummary = input.messages.find((message) => message.type === "compaction") const previousSummary = input.messages.find(
(message) => message.type === "compaction" && message.status === "completed",
)
const hasHead = selected.head.length > 0 const hasHead = selected.head.length > 0
if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false if (!hasHead && previousSummary?.type !== "compaction" && !input.force) return false
const forcedShortContext = input.force && !hasHead const forcedShortContext = input.force && !hasHead

View file

@ -1,6 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { SessionSchema } from "./schema" import { SessionSchema } from "./schema"
import { SessionError } from "@opencode-ai/schema/session-error"
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", { export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
@ -10,3 +11,20 @@ export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeErr
return `Failed to decode message ${this.messageID} in session ${this.sessionID}` return `Failed to decode message ${this.messageID} in session ${this.sessionID}`
} }
} }
export class StepFailedError extends Schema.TaggedErrorClass<StepFailedError>()("Session.StepFailedError", {
error: SessionError.Error,
}) {
override get message() {
return this.error.message
}
}
export class UserInterruptedError extends Schema.TaggedErrorClass<UserInterruptedError>()(
"Session.UserInterruptedError",
{},
) {
override get message() {
return "Session interrupted by user"
}
}

View file

@ -1,4 +1,4 @@
import { Cause, DateTime, Effect, Exit, Layer } from "effect" import { Cause, Effect, Exit, Layer } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { LocationServiceMap } from "../../location-service-map" import { LocationServiceMap } from "../../location-service-map"
import { makeGlobalNode } from "../../effect/app-node" import { makeGlobalNode } from "../../effect/app-node"
@ -8,6 +8,16 @@ import { SessionRunner } from "../runner"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionExecution } from "../execution" import { SessionExecution } from "../execution"
import { toSessionError } from "../to-session-error"
import { UserInterruptedError } from "../error"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: "user" | "shutdown" | "superseded") {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
const failure = Cause.squash(exit.cause)
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
return { type: "failed" as const, error: toSessionError(failure) }
}
/** Current-process routing for implicit-local Locations. Future remote placement belongs here. */ /** Current-process routing for implicit-local Locations. Future remote placement belongs here. */
const layer = Layer.effect( const layer = Layer.effect(
@ -16,7 +26,23 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError>({ const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.void
: Effect.logError("Failed to publish Session execution lifecycle", cause).pipe(
Effect.annotateLogs({ sessionID }),
),
),
Effect.asVoid,
)
const coordinator = yield* SessionRunCoordinator.make<
SessionSchema.ID,
SessionRunner.RunError,
"user" | "shutdown" | "superseded"
>({
started: (sessionID) => reportLifecycle(sessionID, events.publish(SessionEvent.Execution.Started, { sessionID })),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) { drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
@ -29,28 +55,31 @@ const layer = Layer.effect(
), ),
) )
}), }),
// One ExecutionSettled per execution (busy period), covering every coalesced drain. // One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit) => settled: (sessionID, exit, reason) =>
Effect.gen(function* () { reportLifecycle(
const failure = sessionID,
Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause) ? Cause.squash(exit.cause) : undefined Effect.gen(function* () {
yield* events.publish(SessionEvent.ExecutionSettled, { const outcome = terminal(exit, reason)
sessionID, if (outcome.type === "succeeded") {
outcome: Exit.isSuccess(exit) ? "success" : Cause.hasInterrupts(exit.cause) ? "interrupted" : "failure", yield* events.publish(SessionEvent.Execution.Succeeded, { sessionID })
error: return
failure !== undefined }
? { type: "unknown", message: failure instanceof Error ? failure.message : String(failure) } if (outcome.type === "interrupted") {
: undefined, yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: outcome.reason })
}) return
}).pipe( }
Effect.catchCause(() => Effect.void), yield* events.publish(SessionEvent.Execution.Failed, {
Effect.asVoid, sessionID,
error: outcome.error,
})
}),
), ),
}) })
return SessionExecution.Service.of({ return SessionExecution.Service.of({
active: coordinator.active, active: coordinator.active,
interrupt: coordinator.interrupt, interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,

View file

@ -1,4 +1,4 @@
import { and, asc, desc, eq, gt, gte, ne, or } from "drizzle-orm" import { and, asc, desc, eq, gt, gte, ne, or, sql } from "drizzle-orm"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Database } from "../database/database" import { Database } from "../database/database"
import { MessageDecodeError } from "./error" import { MessageDecodeError } from "./error"
@ -14,7 +14,13 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
return yield* db return yield* db
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "compaction"))) .where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
)
.orderBy(desc(SessionMessageTable.seq)) .orderBy(desc(SessionMessageTable.seq))
.limit(1) .limit(1)
.get() .get()

View file

@ -17,6 +17,12 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
projectID: ProjectV2.ID.make(row.project_id), projectID: ProjectV2.ID.make(row.project_id),
title: row.title, title: row.title,
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined, parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
fork: row.fork_session_id
? {
sessionID: SessionSchema.ID.make(row.fork_session_id),
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
}
: undefined,
agent: row.agent ? AgentV2.ID.make(row.agent) : undefined, agent: row.agent ? AgentV2.ID.make(row.agent) : undefined,
model: row.model model: row.model
? { ? {

View file

@ -2,9 +2,10 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull } from "drizzle-orm" import { and, asc, eq, isNull } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect" import { DateTime, Effect, Schema } from "effect"
import { Admitted, Delivery } from "@opencode-ai/schema/session-input" import { Admitted, Compaction, Delivery, Entry, PromptEntry } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database" import type { Database } from "../database/database"
import type { EventV2 } from "../event" import type { EventV2 } from "../event"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
import { Prompt } from "@opencode-ai/schema/prompt" import { Prompt } from "@opencode-ai/schema/prompt"
@ -13,30 +14,77 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
export { Admitted, Delivery } export { Admitted, Compaction, Delivery, Entry, PromptEntry }
const decodePrompt = Schema.decodeUnknownSync(Prompt) const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt) const encodePrompt = Schema.encodeSync(Prompt)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted => export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", {
Admitted.make({ id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInputTable.$inferSelect): Entry => {
const base = {
admittedSeq: row.admitted_seq, admittedSeq: row.admitted_seq,
id: SessionMessage.ID.make(row.id), id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id), sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
...(row.promoted_seq === null ? {} : { handledSeq: row.promoted_seq }),
})
if (!row.prompt || !row.delivery) throw new LifecycleConflict({ id: base.id })
return PromptEntry.make({
...base,
type: "prompt",
prompt: decodePrompt(row.prompt), prompt: decodePrompt(row.prompt),
delivery: row.delivery, delivery: row.delivery,
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }), ...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
}) })
}
const toAdmitted = (entry: PromptEntry): Admitted =>
Admitted.make({
admittedSeq: entry.admittedSeq,
id: entry.id,
sessionID: entry.sessionID,
prompt: entry.prompt,
delivery: entry.delivery,
timeCreated: entry.timeCreated,
...(entry.promotedSeq === undefined ? {} : { promotedSeq: entry.promotedSeq }),
})
export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) { export const find = Effect.fn("SessionInput.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie) const row = yield* db.select().from(SessionInputTable).where(eq(SessionInputTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row) return row === undefined ? undefined : fromRow(row)
}) })
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInput.LifecycleConflict", { export const pendingCompaction = Effect.fn("SessionInput.pendingCompaction")(function* (
id: SessionMessage.ID, db: DatabaseService,
}) {} sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInputTable)
.where(
and(
eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.orderBy(asc(SessionInputTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
export const admit = Effect.fn("SessionInput.admit")(function* ( export const admit = Effect.fn("SessionInput.admit")(function* (
db: DatabaseService, db: DatabaseService,
@ -49,7 +97,10 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
}, },
) { ) {
const existing = yield* find(db, input.id) const existing = yield* find(db, input.id)
if (existing !== undefined) return existing if (existing !== undefined) {
if (existing.type !== "prompt") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return toAdmitted(existing)
}
return yield* events return yield* events
.publish(SessionEvent.PromptAdmitted, { .publish(SessionEvent.PromptAdmitted, {
inputID: input.id, inputID: input.id,
@ -73,11 +124,54 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
), ),
), ),
Effect.catchDefect((defect) => Effect.catchDefect((defect) =>
find(db, input.id).pipe(Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect)))), find(db, input.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" ? Effect.succeed(toAdmitted(stored)) : Effect.die(defect),
),
),
), ),
) )
}) })
export const admitCompaction = Effect.fn("SessionInput.admitCompaction")(function* (
db: DatabaseService,
events: EventV2.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
) {
return yield* inboxLocks.withLock(input.sessionID)(
Effect.gen(function* () {
const exact = yield* find(db, input.id)
if (exact) {
if (exact.type === "compaction" && exact.sessionID === input.sessionID) return exact
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* events
.publish(SessionEvent.Compaction.Admitted, {
inputID: input.id,
sessionID: input.sessionID,
})
.pipe(
Effect.flatMap((event) => {
if (event.durable === undefined)
return Effect.die(new Error("Compaction admission event is missing aggregate sequence"))
return pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
pendingCompaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* ( export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
@ -101,6 +195,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
.values({ .values({
id: input.id, id: input.id,
session_id: input.sessionID, session_id: input.sessionID,
type: "prompt",
admitted_seq: input.admittedSeq, admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt), prompt: encodePrompt(input.prompt),
delivery: input.delivery, delivery: input.delivery,
@ -113,6 +208,44 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (!stored) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
}) })
export const projectCompactionAdmitted = Effect.fn("SessionInput.projectCompactionAdmitted")(function* (
db: DatabaseService,
input: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, input.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = yield* db
.insert(SessionInputTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
admitted_seq: input.admittedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
.returning()
.get()
.pipe(Effect.orDie)
if (stored) {
const entry = fromRow(stored)
return entry.type === "compaction" ? entry : yield* Effect.die(new LifecycleConflict({ id: entry.id }))
}
const pending = yield* pendingCompaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* ( export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromoted")(function* (
db: DatabaseService, db: DatabaseService,
input: { input: {
@ -121,6 +254,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
readonly promotedSeq: number readonly promotedSeq: number
}, },
) { ) {
if (yield* pendingCompaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const updated = yield* db const updated = yield* db
.update(SessionInputTable) .update(SessionInputTable)
.set({ promoted_seq: input.promotedSeq }) .set({ promoted_seq: input.promotedSeq })
@ -128,6 +262,7 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
and( and(
eq(SessionInputTable.id, input.id), eq(SessionInputTable.id, input.id),
eq(SessionInputTable.session_id, input.sessionID), eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
), ),
) )
@ -136,29 +271,58 @@ export const projectPromptPromoted = Effect.fn("SessionInput.projectPromptPromot
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (updated) { if (updated) {
const stored = fromRow(updated) const stored = fromRow(updated)
if (stored.sessionID !== input.sessionID) return yield* Effect.die(new LifecycleConflict({ id: input.id })) if (stored.type !== "prompt" || stored.sessionID !== input.sessionID)
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored return stored
} }
// Every PromptPromoted event is published from an admitted inbox row, so a missing or
// divergent row on replay is an invariant violation.
const stored = yield* find(db, input.id) const stored = yield* find(db, input.id)
if (!stored || stored.sessionID !== input.sessionID || stored.promotedSeq !== input.promotedSeq) if (
!stored ||
stored.type !== "prompt" ||
stored.sessionID !== input.sessionID ||
stored.promotedSeq !== input.promotedSeq
)
return yield* Effect.die(new LifecycleConflict({ id: input.id })) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored return stored
}) })
export const settleCompaction = Effect.fn("SessionInput.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID; readonly handledSeq: number },
) {
const updated = yield* db
.update(SessionInputTable)
.set({ promoted_seq: input.handledSeq })
.where(
and(
eq(SessionInputTable.session_id, input.sessionID),
eq(SessionInputTable.type, "compaction"),
isNull(SessionInputTable.promoted_seq),
),
)
.returning()
.get()
.pipe(Effect.orDie)
if (updated) {
const stored = fromRow(updated)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
export const hasPending = Effect.fn("SessionInput.hasPending")(function* ( export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
db: DatabaseService, db: DatabaseService,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
delivery: Delivery, delivery: Delivery,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db const row = yield* db
.select({ id: SessionInputTable.id }) .select({ id: SessionInputTable.id })
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, delivery), eq(SessionInputTable.delivery, delivery),
), ),
@ -181,42 +345,44 @@ export const equivalent = (
input.sessionID === expected.sessionID && input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt)) JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
const matchesProjection = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
readonly timeCreated: DateTime.Utc
},
) =>
equivalent(input, expected) &&
DateTime.toEpochMillis(input.timeCreated) === DateTime.toEpochMillis(expected.timeCreated)
const publish = Effect.fn("SessionInput.publish")(function* ( const publish = Effect.fn("SessionInput.publish")(function* (
db: DatabaseService, db: DatabaseService,
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>, rows: ReadonlyArray<typeof SessionInputTable.$inferSelect>,
) { ) {
for (const row of rows) { return yield* inboxLocks.withLock(sessionID)(
const id = SessionMessage.ID.make(row.id) Effect.gen(function* () {
yield* events if (yield* pendingCompaction(db, sessionID)) return 0
.publish(SessionEvent.PromptPromoted, { yield* Effect.forEach(
sessionID, rows,
inputID: id, (row) => {
}) const entry = fromRow(row)
.pipe( if (entry.type !== "prompt") return Effect.die(new LifecycleConflict({ id: entry.id }))
Effect.catchDefect((defect) => return events
defect instanceof LifecycleConflict .publish(SessionEvent.PromptPromoted, {
? find(db, id).pipe( sessionID,
Effect.flatMap((stored) => (stored?.promotedSeq === undefined ? Effect.die(defect) : Effect.void)), inputID: entry.id,
) })
: Effect.die(defect), .pipe(
), Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? find(db, entry.id).pipe(
Effect.flatMap((stored) =>
stored?.type === "prompt" && stored.promotedSeq !== undefined
? Effect.void
: Effect.die(defect),
),
)
: Effect.die(defect),
),
)
},
{ discard: true },
) )
} return rows.length
return rows.length }),
)
}) })
export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* ( export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
@ -224,12 +390,14 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return 0
const rows = yield* db const rows = yield* db
.select() .select()
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "steer"), eq(SessionInputTable.delivery, "steer"),
), ),
@ -245,12 +413,14 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
events: EventV2.Interface, events: EventV2.Interface,
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
) { ) {
if (yield* pendingCompaction(db, sessionID)) return false
const row = yield* db const row = yield* db
.select() .select()
.from(SessionInputTable) .from(SessionInputTable)
.where( .where(
and( and(
eq(SessionInputTable.session_id, sessionID), eq(SessionInputTable.session_id, sessionID),
eq(SessionInputTable.type, "prompt"),
isNull(SessionInputTable.promoted_seq), isNull(SessionInputTable.promoted_seq),
eq(SessionInputTable.delivery, "queue"), eq(SessionInputTable.delivery, "queue"),
), ),

View file

@ -1,5 +1,5 @@
import { castDraft, produce, type WritableDraft } from "immer" import { castDraft, produce, type WritableDraft } from "immer"
import { Effect } from "effect" import { DateTime, Effect } from "effect"
import { SessionEvent } from "./event" import { SessionEvent } from "./event"
import { SessionMessage } from "./message" import { SessionMessage } from "./message"
@ -16,8 +16,10 @@ export interface Adapter {
readonly getShell: ( readonly getShell: (
shellID: SessionMessage.Shell["shell"]["id"], shellID: SessionMessage.Shell["shell"]["id"],
) => Effect.Effect<SessionMessage.Shell | undefined, never, never> ) => Effect.Effect<SessionMessage.Shell | undefined, never, never>
readonly getCompaction: () => Effect.Effect<SessionMessage.Compaction | undefined, never, never>
readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never> readonly updateAssistant: (assistant: SessionMessage.Assistant) => Effect.Effect<void, never, never>
readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never> readonly updateShell: (shell: SessionMessage.Shell) => Effect.Effect<void, never, never>
readonly updateCompaction: (compaction: SessionMessage.Compaction) => Effect.Effect<void, never, never>
readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never> readonly appendMessage: (message: SessionMessage.Message) => Effect.Effect<void, never, never>
} }
@ -26,6 +28,10 @@ export function memory(state: MemoryState): Adapter {
state.messages.findLastIndex((message) => message.id === messageID) state.messages.findLastIndex((message) => message.id === messageID)
const shellIndex = (messageID: SessionMessage.ID) => const shellIndex = (messageID: SessionMessage.ID) =>
state.messages.findLastIndex((message) => message.id === messageID) state.messages.findLastIndex((message) => message.id === messageID)
const compactionIndex = () =>
state.messages.findLastIndex(
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
)
// A newer step supersedes stale incomplete rows; never resume an older assistant projection. // A newer step supersedes stale incomplete rows; never resume an older assistant projection.
const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant") const latestAssistantIndex = () => state.messages.findLastIndex((message) => message.type === "assistant")
@ -62,6 +68,13 @@ export function memory(state: MemoryState): Adapter {
}) })
}) })
}, },
getCompaction() {
return Effect.sync(() => {
const index = compactionIndex()
const message = state.messages[index]
return message?.type === "compaction" ? message : undefined
})
},
updateAssistant(assistant) { updateAssistant(assistant) {
return Effect.sync(() => { return Effect.sync(() => {
const index = assistantIndex(assistant.id) const index = assistantIndex(assistant.id)
@ -80,6 +93,12 @@ export function memory(state: MemoryState): Adapter {
state.messages[index] = shell state.messages[index] = shell
}) })
}, },
updateCompaction(compaction) {
return Effect.sync(() => {
const index = state.messages.findLastIndex((message) => message.id === compaction.id)
if (index >= 0) state.messages[index] = compaction
})
},
appendMessage(message) { appendMessage(message) {
return Effect.sync(() => { return Effect.sync(() => {
state.messages.push(message) state.messages.push(message)
@ -99,11 +118,11 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID), (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.id === callID),
) )
const latestText = (assistant: DraftAssistant | undefined, textID: string) => const latestText = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftText => item.type === "text" && item.id === textID) assistant?.content.findLast((item): item is DraftText => item.type === "text")
const latestReasoning = (assistant: DraftAssistant | undefined, reasoningID: string) => const latestReasoning = (assistant: DraftAssistant | undefined) =>
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && item.id === reasoningID) assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning" && !item.time?.completed)
const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) => const updateOwnedAssistant = (messageID: SessionMessage.ID, recipe: (draft: DraftAssistant) => void) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -111,6 +130,17 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe)) if (assistant) yield* adapter.updateAssistant(produce(assistant, recipe))
}) })
const clearCurrentRetry = Effect.gen(function* () {
const assistant = yield* adapter.getCurrentAssistant()
if (assistant?.retry) {
yield* adapter.updateAssistant(
produce(assistant, (draft) => {
draft.retry = undefined
}),
)
}
})
return Effect.gen(function* () { return Effect.gen(function* () {
yield* SessionEvent.All.match(event, { yield* SessionEvent.All.match(event, {
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
@ -141,10 +171,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.moved": () => Effect.void, "session.moved": () => Effect.void,
"session.renamed": () => Effect.void, "session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void, "session.forked": () => Effect.void,
"session.prompt.promoted": () => Effect.void, "session.prompt.promoted": () => Effect.void,
"session.prompt.admitted": () => Effect.void, "session.prompt.admitted": () => Effect.void,
"session.execution.settled": () => Effect.void, "session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.instructions.updated": (event) => "session.instructions.updated": (event) =>
adapter.appendMessage( adapter.appendMessage(
SessionMessage.System.make({ SessionMessage.System.make({
@ -154,7 +188,6 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
time: { created: event.created }, time: { created: event.created },
}), }),
), ),
"session.instructions.discovered": () => Effect.void,
"session.synthetic": (event) => { "session.synthetic": (event) => {
return adapter.appendMessage( return adapter.appendMessage(
SessionMessage.Synthetic.make({ SessionMessage.Synthetic.make({
@ -206,10 +239,26 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.step.started": (event) => { "session.step.started": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
const existing = yield* adapter.getAssistant(event.data.assistantMessageID)
if (existing) {
yield* adapter.updateAssistant(
produce(existing, (draft) => {
draft.agent = event.data.agent
draft.model = castDraft(event.data.model)
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
)
return
}
const currentAssistant = yield* adapter.getCurrentAssistant() const currentAssistant = yield* adapter.getCurrentAssistant()
if (currentAssistant) { if (currentAssistant) {
yield* adapter.updateAssistant( yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => { produce(currentAssistant, (draft) => {
draft.retry = undefined
draft.time.completed = event.created draft.time.completed = event.created
}), }),
) )
@ -245,25 +294,24 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = event.created
draft.finish = "error" draft.finish = "error"
draft.error = event.data.error draft.error = castDraft(event.data.error)
draft.retry = undefined
}) })
}, },
"session.text.started": (event) => { "session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.content.push( draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
castDraft(SessionMessage.AssistantText.make({ type: "text", id: event.data.textID, text: "" })),
)
}) })
}, },
"session.text.delta": (event) => { "session.text.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"session.text.ended": (event) => { "session.text.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestText(draft, event.data.textID) const match = latestText(draft)
if (match) match.text = event.data.text if (match) match.text = event.data.text
}) })
}, },
@ -293,7 +341,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match) { if (match) {
match.provider = event.data.provider match.executed = event.data.executed
match.providerState = event.data.state
match.time.ran = event.created match.time.ran = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateRunning.make({ SessionMessage.ToolStateRunning.make({
@ -319,11 +368,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
match.provider = { match.executed = event.data.executed || match.executed === true
executed: event.data.provider.executed || match.provider?.executed === true, match.providerResultState = event.data.resultState
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created match.time.completed = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateCompleted.make({ SessionMessage.ToolStateCompleted.make({
@ -342,11 +388,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestTool(draft, event.data.callID) const match = latestTool(draft, event.data.callID)
if (match && (match.state.status === "pending" || match.state.status === "running")) { if (match && (match.state.status === "pending" || match.state.status === "running")) {
match.provider = { match.executed = event.data.executed || match.executed === true
executed: event.data.provider.executed || match.provider?.executed === true, match.providerResultState = event.data.resultState
metadata: match.provider?.metadata,
resultMetadata: event.data.provider.metadata,
}
match.time.completed = event.created match.time.completed = event.created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateError.make({ SessionMessage.ToolStateError.make({
@ -367,9 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
castDraft( castDraft(
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: event.data.reasoningID,
text: "", text: "",
providerMetadata: event.data.providerMetadata, state: event.data.state,
time: { created: event.created }, time: { created: event.created },
}), }),
), ),
@ -378,36 +420,83 @@ export function update(adapter: Adapter, event: SessionEvent.Event) {
}, },
"session.reasoning.delta": (event) => { "session.reasoning.delta": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft)
if (match) match.text += event.data.delta if (match) match.text += event.data.delta
}) })
}, },
"session.reasoning.ended": (event) => { "session.reasoning.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
const match = latestReasoning(draft, event.data.reasoningID) const match = latestReasoning(draft)
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created } match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.providerMetadata !== undefined) match.providerMetadata = event.data.providerMetadata if (event.data.state !== undefined) match.state = event.data.state
} }
}) })
}, },
"session.retried": () => Effect.void, "session.retry.scheduled": (event) => {
"session.compaction.started": () => Effect.void, return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
"session.compaction.delta": () => Effect.void, draft.retry = {
"session.compaction.ended": (event) => { attempt: event.data.attempt,
return adapter.appendMessage( at: DateTime.makeUnsafe(event.data.at),
error: castDraft(event.data.error),
}
})
},
"session.compaction.admitted": (event) =>
adapter.appendMessage(
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id), id: event.data.inputID,
type: "compaction", type: "compaction",
status: "queued",
metadata: event.metadata, metadata: event.metadata,
reason: event.data.reason, reason: "manual",
summary: event.data.text, summary: "",
recent: event.data.recent, recent: "",
time: { created: event.created }, time: { created: event.created },
}), }),
) ),
"session.compaction.started": (event) =>
Effect.gen(function* () {
if (event.data.reason !== "manual") return
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "running" })
}),
"session.compaction.delta": () => Effect.void,
"session.compaction.ended": (event) => {
return Effect.gen(function* () {
const current = event.data.reason === "manual" ? yield* adapter.getCompaction() : undefined
if (current) {
yield* adapter.updateCompaction({
...current,
status: "completed",
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
})
return
}
yield* adapter.appendMessage(
SessionMessage.Compaction.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "compaction",
status: "completed",
metadata: event.metadata,
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
}),
)
})
}, },
"session.compaction.failed": () =>
Effect.gen(function* () {
const current = yield* adapter.getCompaction()
if (!current) return
yield* adapter.updateCompaction({ ...current, status: "failed" })
}),
"session.revert.staged": () => Effect.void, "session.revert.staged": () => Effect.void,
"session.revert.cleared": () => Effect.void, "session.revert.cleared": () => Effect.void,
"session.revert.committed": () => Effect.void, "session.revert.committed": () => Effect.void,

View file

@ -26,7 +26,10 @@ import type { DeepMutable } from "../schema"
import { Slug } from "../util/slug" import { Slug } from "../util/slug"
type DatabaseService = Database.Interface["db"] type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<SessionEvent.DurableEvent, typeof SessionEvent.Forked.Type> type MessageEvent = Exclude<
SessionEvent.DurableEvent,
typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type
>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message) const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
const encodeMessage = Schema.encodeSync(SessionMessage.Message) const encodeMessage = Schema.encodeSync(SessionMessage.Message)
@ -190,7 +193,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.insert(SessionTable) .insert(SessionTable)
.values({ .values({
id: event.data.sessionID, id: event.data.sessionID,
parent_id: event.data.parentID, parent_id: null,
fork_session_id: event.data.parentID,
fork_message_id: event.data.from,
project_id: parent.project_id, project_id: parent.project_id,
workspace_id: parent.workspace_id, workspace_id: parent.workspace_id,
slug: Slug.create(), slug: Slug.create(),
@ -243,6 +248,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor), gt(SessionMessageTable.seq, cursor),
copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1), copiedSeq === 0 ? undefined : lt(SessionMessageTable.seq, copiedSeq + 1),
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') not in ('queued', 'running')`,
), ),
) )
.orderBy(asc(SessionMessageTable.seq)) .orderBy(asc(SessionMessageTable.seq))
@ -292,11 +298,12 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.values( .values(
inputRows.flatMap((row) => { inputRows.flatMap((row) => {
const id = idMap.get(row.id) const id = idMap.get(row.id)
return id return id && row.type === "prompt"
? [ ? [
{ {
id, id,
session_id: event.data.sessionID, session_id: event.data.sessionID,
type: "prompt" as const,
prompt: row.prompt, prompt: row.prompt,
delivery: row.delivery, delivery: row.delivery,
admitted_seq: row.admitted_seq, admitted_seq: row.admitted_seq,
@ -426,8 +433,30 @@ function run(db: DatabaseService, event: MessageEvent) {
return message.type === "shell" ? message : undefined return message.type === "shell" ? message : undefined
}) })
}, },
getCompaction() {
return Effect.gen(function* () {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') in ('queued', 'running')`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const message = decodeRow(row)
return message.type === "compaction" ? message : undefined
})
},
updateAssistant: updateMessage, updateAssistant: updateMessage,
updateShell: updateMessage, updateShell: updateMessage,
updateCompaction: updateMessage,
appendMessage, appendMessage,
} }
yield* SessionMessageUpdater.update(adapter, event) yield* SessionMessageUpdater.update(adapter, event)
@ -503,6 +532,9 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionV1.Event.Deleted, (event) => yield* events.project(SessionV1.Event.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie), db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
) )
yield* events.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
yield* events.project(SessionV1.Event.MessageUpdated, (event) => yield* events.project(SessionV1.Event.MessageUpdated, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
const time_created = event.data.info.time.created const time_created = event.data.info.time.created
@ -634,6 +666,23 @@ const layer = Layer.effectDiscard(
}) })
}), }),
) )
yield* events.project(SessionEvent.Compaction.Admitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const admitted = yield* SessionInput.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
if (admitted.id !== event.data.inputID) return
yield* run(db, event)
}),
)
yield* events.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event)) yield* events.project(SessionEvent.InstructionsUpdated, (event) => run(db, event))
yield* events.project(SessionEvent.Synthetic, (event) => run(db, event)) yield* events.project(SessionEvent.Synthetic, (event) => run(db, event))
yield* events.project(SessionEvent.Skill.Activated, (event) => yield* events.project(SessionEvent.Skill.Activated, (event) =>
@ -660,8 +709,31 @@ const layer = Layer.effectDiscard(
yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event)) yield* events.project(SessionEvent.Tool.Failed, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event)) yield* events.project(SessionEvent.Reasoning.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Reasoning.Ended, (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.RetryScheduled, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) => run(db, event)) yield* events.project(SessionEvent.Compaction.Started, (event) => run(db, event))
yield* events.project(SessionEvent.Compaction.Ended, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.Compaction.Failed, (event) =>
Effect.gen(function* () {
yield* run(db, event)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInput.settleCompaction(db, {
sessionID: event.data.sessionID,
handledSeq: event.durable.seq,
})
}),
)
yield* events.project(SessionEvent.RevertEvent.Staged, (event) => yield* events.project(SessionEvent.RevertEvent.Staged, (event) =>
db db
.update(SessionTable) .update(SessionTable)
@ -687,14 +759,11 @@ const layer = Layer.effectDiscard(
.select({ seq: SessionMessageTable.seq }) .select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable) .from(SessionMessageTable)
.where( .where(
and( and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.id, event.data.messageID),
),
) )
.get() .get()
.pipe(Effect.orDie) .pipe(Effect.orDie)
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.messageID}`)) if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
yield* db yield* db
.delete(SessionMessageTable) .delete(SessionMessageTable)
.where( .where(

View file

@ -113,6 +113,6 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (session: Sess
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* events.publish(SessionEvent.RevertEvent.Committed, { yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID: session.id, sessionID: session.id,
messageID: session.revert.messageID, to: session.revert.messageID,
}) })
}) })

View file

@ -3,7 +3,7 @@ export * as SessionRunCoordinator from "./run-coordinator"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
/** Serializes execution for each key while allowing different keys to run concurrently. */ /** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E> { export interface Coordinator<Key, E, Reason = never> {
/** Snapshots keys with an execution owned by this coordinator. */ /** Snapshots keys with an execution owned by this coordinator. */
readonly active: Effect.Effect<ReadonlySet<Key>> readonly active: Effect.Effect<ReadonlySet<Key>>
/** Starts an execution while idle, or joins the active execution and returns its exit. */ /** Starts an execution while idle, or joins the active execution and returns its exit. */
@ -11,7 +11,7 @@ export interface Coordinator<Key, E> {
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void> readonly wake: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key) => Effect.Effect<void> readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void> readonly awaitIdle: (key: Key) => Effect.Effect<void>
} }
@ -23,11 +23,13 @@ export interface Coordinator<Key, E> {
* closes the gap between a drain's last eligibility check and the idle transition, since * closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit. * those cannot be one atomic step. `done` resolves joiners with this execution's exit.
*/ */
type Execution<E> = { type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E> readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void> owner?: Fiber.Fiber<void>
pendingWake: boolean pendingWake: boolean
stopping: boolean stopping: boolean
settling: boolean
interruptionReason?: Reason
} }
/** /**
@ -41,19 +43,21 @@ type Execution<E> = {
* waiters get this exit * waiters get this exit
* ``` * ```
*/ */
export const make = <Key, E>(options: { export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E> readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/** /**
* Runs in the execution fiber for every exit, including interruption, after the final * Runs in the execution fiber for every exit, including interruption, after the final
* drain and before the execution settles (waiters resolve after it completes). * drain and before the execution settles (waiters resolve after it completes).
*/ */
readonly settled?: (key: Key, exit: Exit.Exit<void, E>) => Effect.Effect<void> readonly settled?: (key: Key, exit: Exit.Exit<void, E>, reason?: Reason) => Effect.Effect<void>
}): Effect.Effect<Coordinator<Key, E>, never, Scope.Scope> => }): Effect.Effect<Coordinator<Key, E, Reason>, never, Scope.Scope> =>
Effect.gen(function* () { Effect.gen(function* () {
const executions = new Map<Key, Execution<E>>() const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E>, force: boolean): Effect.Effect<void, E> => const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe( Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
Effect.suspend(() => { Effect.suspend(() => {
@ -66,15 +70,25 @@ export const make = <Key, E>(options: {
) )
const start = (key: Key, force: boolean) => { const start = (key: Key, force: boolean) => {
const execution: Execution<E> = { done: Deferred.makeUnsafe<void, E>(), pendingWake: false, stopping: false } const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
stopping: false,
settling: false,
}
executions.set(key, execution) executions.set(key, execution)
// The leading yield lets `owner` be assigned before the drain can settle, and keeps // The leading yield lets `owner` be assigned before the drain can settle, and keeps
// failing self-waking executions from growing the stack across successor starts. // failing self-waking executions from growing the stack across successor starts.
// Drains start one tick after wake; callers observe progress through events or run. // Drains start one tick after wake; callers observe progress through events or run.
execution.owner = fork( execution.owner = fork(
Effect.yieldNow.pipe( Effect.yieldNow.pipe(
Effect.andThen(Effect.uninterruptible(options.started?.(key) ?? Effect.void)),
Effect.andThen(loop(key, execution, force)), Effect.andThen(loop(key, execution, force)),
Effect.onExit((exit) => options.settled?.(key, exit) ?? Effect.void), Effect.onExit((exit) =>
Effect.sync(() => {
execution.settling = true
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
@ -85,7 +99,7 @@ export const make = <Key, E>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or // A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work. // during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false) if (execution.pendingWake) start(key, false)
else executions.delete(key) else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit) Deferred.doneUnsafe(execution.done, exit)
@ -112,12 +126,13 @@ export const make = <Key, E>(options: {
start(key, false) start(key, false)
}) })
const interrupt = (key: Key): Effect.Effect<void> => const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution?.owner === undefined) return Effect.void if (execution?.owner === undefined || execution.stopping || execution.settling) return Effect.void
execution.stopping = true execution.stopping = true
execution.pendingWake = false execution.pendingWake = false
execution.interruptionReason = reason
return Fiber.interrupt(execution.owner) return Fiber.interrupt(execution.owner)
}) })

View file

@ -3,13 +3,19 @@ export * as SessionRunner from "./index"
import type { LLMError } from "@opencode-ai/llm" import type { LLMError } from "@opencode-ai/llm"
import { Context, Effect } from "effect" import { Context, Effect } from "effect"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import type { MessageDecodeError } from "../error" import type { MessageDecodeError, StepFailedError, UserInterruptedError } from "../error"
import { SessionRunnerModel } from "./model" import { SessionRunnerModel } from "./model"
import type { Instructions } from "../../instructions/index" import type { Instructions } from "../../instructions/index"
import type { ToolOutputStore } from "../../tool-output-store" import type { ToolOutputStore } from "../../tool-output-store"
export type RunError = export type RunError =
LLMError | SessionRunnerModel.Error | MessageDecodeError | Instructions.InitializationBlocked | ToolOutputStore.Error | LLMError
| SessionRunnerModel.Error
| MessageDecodeError
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
| ToolOutputStore.Error
/** Runs one local continuation from already-recorded Session history. */ /** Runs one local continuation from already-recorded Session history. */
export interface Interface { export interface Interface {

View file

@ -10,7 +10,8 @@ import {
isContextOverflowFailure, isContextOverflowFailure,
type ProviderErrorEvent, type ProviderErrorEvent,
} from "@opencode-ai/llm" } from "@opencode-ai/llm"
import { Cause, Effect, Exit, FiberSet, Layer, Option, Semaphore, Stream } from "effect" import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream } from "effect"
import { AgentV2 } from "../../agent" import { AgentV2 } from "../../agent"
import { Config } from "../../config" import { Config } from "../../config"
import { Database } from "../../database/database" import { Database } from "../../database/database"
@ -32,6 +33,7 @@ import { SessionCompaction } from "../compaction"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
import { SessionHistory } from "../history" import { SessionHistory } from "../history"
import { SessionInput } from "../input" import { SessionInput } from "../input"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionStore } from "../store" import { SessionStore } from "../store"
import { SessionTitle } from "../title" import { SessionTitle } from "../title"
@ -44,6 +46,9 @@ import { SessionRunnerSystemPrompt } from "./system-prompt"
import { Snapshot } from "../../snapshot" import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node" import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform" import { llmClient } from "../../effect/app-node-platform"
import { StepFailedError, UserInterruptedError } from "../error"
import { toSessionError } from "../to-session-error"
import { SessionRunnerRetry } from "./retry"
/** /**
* Runs one durable coding-agent Session until it settles. * Runs one durable coding-agent Session until it settles.
@ -54,10 +59,10 @@ import { llmClient } from "../../effect/app-node-platform"
* - Session ownership and controls * - Session ownership and controls
* - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce. * - [x] Coordinate one local active drain per Session; explicit resumes join and prompt wakeups coalesce.
* - [ ] Replace local ownership with durable multi-node ownership when clustered. * - [ ] Replace local ownership with durable multi-node ownership when clustered.
* - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [x] Publish durable historical execution lifecycle and bounded retry observations.
* - [ ] Honor interruption and reject stale work after runtime attachment replacement. * - [ ] Honor interruption and reject stale work after runtime attachment replacement.
* - [x] Honor optional agent step limits. * - [x] Honor optional agent step limits.
* - [ ] Bound provider retries and repeated identical tool calls. * - [ ] Bound repeated identical tool calls (provider retries are bounded).
* *
* - Runtime context assembly * - Runtime context assembly
* - Track V1 runtime-context parity canonically in `specs/v2/session.md`. * - Track V1 runtime-context parity canonically in `specs/v2/session.md`.
@ -66,7 +71,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [x] Translate every projected V2 Session message variant into canonical * - [x] Translate every projected V2 Session message variant into canonical
* `@opencode-ai/llm` messages. * `@opencode-ai/llm` messages.
* - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions. * - [ ] Resolve policy-filtered built-in, MCP, plugin, and structured-output tool definitions.
* - [x] Stream exactly one `llm.stream(request)` physical attempt. * - [x] Stream exactly one `llm.stream(request)` call per attempt.
* - [x] Persist assistant text and usage events incrementally as they arrive. * - [x] Persist assistant text and usage events incrementally as they arrive.
* - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive. * - [ ] Persist snapshots, patches, and retry notices incrementally as they arrive.
* - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive. * - [x] Persist reasoning, provider errors, and tool-call events incrementally as they arrive.
@ -87,7 +92,7 @@ import { llmClient } from "../../effect/app-node-platform"
* - [ ] Coalesce streamed deltas and add covering projected-history indexes. * - [ ] Coalesce streamed deltas and add covering projected-history indexes.
* - [ ] Update title, summaries, compaction state, and cleanup in bounded background work. * - [ ] Update title, summaries, compaction state, and cleanup in bounded background work.
* *
* Use `llm.stream(request)` for each physical attempt. Keep tool execution and continuation here. * Use `llm.stream(request)` for each attempt. Keep tool execution and continuation here.
* Durable continuation recovery remains a separate future slice with an explicit retry policy. * Durable continuation recovery remains a separate future slice with an explicit retry policy.
* *
* The current slice loads V2 history, translates it, resolves a model through a core service, and persists one * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one
@ -137,19 +142,13 @@ const layer = Layer.effect(
sessionID, sessionID,
assistantMessageID: message.id, assistantMessageID: message.id,
callID: tool.id, callID: tool.id,
error: { type: "unknown", message: "Tool execution interrupted" }, error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
provider: { executed: tool.executed === true,
executed: tool.provider?.executed === true,
...(tool.provider?.metadata === undefined ? {} : { metadata: tool.provider.metadata }),
},
}) })
} }
} }
}) })
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
// Declining an interactive prompt halts the drain instead of becoming model-facing tool output. // Declining an interactive prompt halts the drain instead of becoming model-facing tool output.
const isUserDeclined = (cause: Cause.Cause<unknown>) => const isUserDeclined = (cause: Cause.Cause<unknown>) =>
cause.reasons.some( cause.reasons.some(
@ -176,6 +175,7 @@ const layer = Layer.effect(
promotion: SessionInput.Delivery | undefined, promotion: SessionInput.Delivery | undefined,
step: number, step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow, recoverOverflow?: typeof compaction.compactAfterOverflow,
assistantMessageID?: SessionMessage.ID,
) { ) {
const session = yield* getSession(sessionID) const session = yield* getSession(sessionID)
if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
@ -189,7 +189,8 @@ const layer = Layer.effect(
loadInstructions(agent, session.id), loadInstructions(agent, session.id),
session.id, session.id,
) )
const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error>() const toolFibers = yield* FiberSet.make<void, ToolOutputStore.Error | UserInterruptedError>()
const ownedToolFibers: Array<Fiber.Fiber<void, ToolOutputStore.Error | UserInterruptedError>> = []
let needsContinuation = false let needsContinuation = false
let currentStep = step let currentStep = step
if (promotion) { if (promotion) {
@ -227,7 +228,10 @@ const layer = Layer.effect(
toolChoice: isLastStep ? "none" : undefined, toolChoice: isLastStep ? "none" : undefined,
}) })
// Automatic compaction completed; rebuild the request from compacted history. // Automatic compaction completed; rebuild the request from compacted history.
if (yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request })) if (
!(yield* SessionInput.pendingCompaction(db, session.id)) &&
(yield* compaction.compactIfNeeded({ sessionID: session.id, messages: context, request }))
)
return { _tag: "RestartAfterCompaction", step: currentStep } as const return { _tag: "RestartAfterCompaction", step: currentStep } as const
const startSnapshot = yield* snapshots.capture() const startSnapshot = yield* snapshots.capture()
const publisher = createLLMEventPublisher(events, { const publisher = createLLMEventPublisher(events, {
@ -236,21 +240,23 @@ const layer = Layer.effect(
// The selected catalog identity, not model.id: route-level ids are provider API // The selected catalog identity, not model.id: route-level ids are provider API
// model ids (for example gpt-5.5-fast resolves to api id gpt-5.5). // model ids (for example gpt-5.5-fast resolves to api id gpt-5.5).
model: resolved.ref, model: resolved.ref,
provider: model.provider,
snapshot: startSnapshot, snapshot: startSnapshot,
assistantMessageID,
}) })
const publication = Semaphore.makeUnsafe(1) const publication = Semaphore.makeUnsafe(1)
// Durable publishes are serialized so tool fibers and step settlement never interleave // Durable publishes are serialized so tool fibers and step settlement never interleave
// mid-event. // mid-event.
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect) const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = []) => const publish = (event: LLMEvent, outputPaths: ReadonlyArray<string> = [], error?: SessionError.Error) =>
serialized(publisher.publish(event, outputPaths)) serialized(publisher.publish(event, outputPaths, error))
let overflowFailure: ProviderErrorEvent | undefined let overflowFailure: ProviderErrorEvent | undefined
const providerStream = llm.stream(request).pipe( const providerStream = llm.stream(request).pipe(
Stream.runForEach((event) => Stream.runForEach((event) =>
Effect.gen(function* () { Effect.gen(function* () {
if (overflowFailure || publisher.hasProviderError()) return if (overflowFailure || publisher.hasProviderError()) return
if (LLMEvent.is.providerError(event)) { if (LLMEvent.is.providerError(event)) {
if (isContextOverflowFailure(event) && !publisher.hasAssistantStarted()) { if (isContextOverflowFailure(event) && !publisher.hasRetryEvidence()) {
overflowFailure = event overflowFailure = event
return return
} }
@ -258,33 +264,49 @@ const layer = Layer.effect(
yield* publish(event) yield* publish(event)
if (event.type !== "tool-call" || event.providerExecuted) return if (event.type !== "tool-call" || event.providerExecuted) return
if (!toolMaterialization) { if (!toolMaterialization) {
yield* serialized(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) yield* serialized(
publisher.failUnsettledTools({
type: "tool.execution",
message: "Tools are disabled after the maximum agent steps",
}),
)
return return
} }
needsContinuation = true needsContinuation = true
const assistantMessageID = yield* publisher.assistantMessageID(event.id) const assistantMessageID = yield* publisher.assistantMessageID(event.id)
yield* Effect.uninterruptibleMask((restore) => ownedToolFibers.push(
restore( yield* Effect.uninterruptibleMask((restore) =>
toolMaterialization.settle({ restore(
sessionID: session.id, toolMaterialization.settle({
agent: agent.id, sessionID: session.id,
assistantMessageID, agent: agent.id,
call: event, assistantMessageID,
}), call: event,
).pipe( }),
Effect.flatMap((settlement) => ).pipe(
publish( Effect.flatMap((settlement) =>
LLMEvent.toolResult({ publish(
id: event.id, LLMEvent.toolResult({
name: event.name, id: event.id,
result: settlement.result, name: event.name,
output: settlement.output, result: settlement.result,
}), output: settlement.output,
settlement.outputPaths ?? [], }),
settlement.outputPaths ?? [],
settlement.error,
).pipe(
Effect.andThen(
settlement.error?.type === "permission.rejected"
? serialized(publisher.failAssistant(settlement.error)).pipe(
Effect.andThen(Effect.fail(new UserInterruptedError())),
)
: Effect.void,
),
),
), ),
), ),
), ).pipe(FiberSet.run(toolFibers)),
).pipe(FiberSet.run(toolFibers)) )
}), }),
), ),
Effect.ensuring(serialized(publisher.flush())), Effect.ensuring(serialized(publisher.flush())),
@ -327,64 +349,118 @@ const layer = Layer.effect(
// restart the step instead of surfacing the provider error. // restart the step instead of surfacing the provider error.
if ( if (
recoverOverflow && recoverOverflow &&
!publisher.hasAssistantStarted() && !publisher.hasRetryEvidence() &&
isContextOverflowFailure(overflowFailure ?? streamFailure) && isContextOverflowFailure(overflowFailure ?? streamFailure) &&
(yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request }))) (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, request })))
) )
return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const
// An unrecovered held-back overflow becomes the step's durable provider error. A // An unrecovered held-back overflow becomes the step's durable provider error. A
// thrown LLM failure fails hosted tool calls and the assistant unless a provider // thrown LLM failure records the assistant failure unless a provider error was
// error was already recorded from the stream. // already recorded from the stream. Terminal publication waits for owned tools.
if (overflowFailure) yield* publish(overflowFailure) if (overflowFailure) yield* publish(overflowFailure)
const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined const llmFailure = streamFailure instanceof LLMError ? streamFailure : undefined
if (llmFailure && !publisher.hasProviderError()) { if (llmFailure && !publisher.hasProviderError()) {
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true)) const error = toSessionError(llmFailure)
yield* serialized(publisher.failAssistant(llmFailure.reason.message)) if (
SessionRunnerRetry.isRetryable(llmFailure) &&
!publisher.hasRetryEvidence() &&
(agent.info?.steps === undefined || currentStep < agent.info.steps)
) {
return yield* new SessionRunnerRetry.RetryableFailure({
cause: llmFailure,
assistantMessageID: yield* publisher.startAssistant(),
error,
step: currentStep,
})
}
yield* serialized(publisher.failAssistant(error))
} }
// Provider error events only arrive from the stream, so the flag is final here. // Provider error events only arrive from the stream, so the flag is final here.
const providerFailed = publisher.hasProviderError() const providerFailed = publisher.hasProviderError()
// Settle tool fibers: an interrupted stream abandons unstarted tool work first. // Settle every owned tool fiber. FiberSet.join returns on the first failure, so retain
// the individual fibers and await all exits before publishing the terminal step event.
if (streamInterrupted) yield* FiberSet.clear(toolFibers) if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit) const settled = yield* restore(
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause) Effect.forEach(ownedToolFibers, Fiber.await, { concurrency: "unbounded" }),
const userDeclined = settled._tag === "Failure" && isUserDeclined(settled.cause) ).pipe(Effect.exit)
const settledCauses =
settled._tag === "Failure"
? [settled.cause]
: settled.value.flatMap((exit) => (exit._tag === "Failure" ? [exit.cause] : []))
const toolsInterrupted = settledCauses.some(Cause.hasInterrupts)
const userDeclined = settledCauses.some(isUserDeclined)
const permissionRejected = settledCauses.some(
(cause) => Option.getOrUndefined(Cause.findErrorOption(cause)) instanceof UserInterruptedError,
)
if (userDeclined || streamInterrupted || toolsInterrupted) { if (userDeclined || permissionRejected || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers) yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted")) yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
yield* serialized(publisher.failAssistant("Step interrupted")) yield* serialized(publisher.failAssistant({ type: "aborted", message: "Step interrupted" }))
if (userDeclined) return yield* Effect.interrupt
} }
// A settled tool fiber failure is one of two things. A defect from a tool // A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still // implementation becomes a failed tool call the model can read, and the step still
// settles so the model may recover. A typed infrastructure failure (tool output // settles so the model may recover. A typed infrastructure failure (tool output
// could not be persisted) also fails the assistant and then fails the drain. // could not be persisted) also fails the assistant and then fails the drain.
const settledFailure = settled._tag === "Failure" && !toolsInterrupted ? settled.cause : undefined const settledFailure = settledCauses.find(
(cause) => !Cause.hasInterrupts(cause) && !isUserDeclined(cause) && !permissionRejected,
)
const infraError = const infraError =
settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure)) settledFailure === undefined ? undefined : Option.getOrUndefined(Cause.findErrorOption(settledFailure))
if (settledFailure !== undefined) { if (settledFailure !== undefined) {
const failure = infraError ?? Cause.squash(settledFailure) const failure = infraError ?? Cause.squash(settledFailure)
const message = failure instanceof Error ? failure.message : String(failure) const error = toSessionError(failure)
yield* serialized(publisher.failUnsettledTools(`Tool execution failed: ${message}`)) yield* serialized(publisher.failUnsettledTools(error))
if (infraError !== undefined) if (infraError !== undefined) yield* serialized(publisher.failAssistant(error))
yield* serialized(publisher.failAssistant(`Tool execution failed: ${message}`))
} }
// Fail unresolved calls before the terminal step event. Local calls have joined, so
// these sweeps only close calls that could not produce a truthful settlement.
if (providerFailed)
yield* serialized(publisher.failUnsettledTools({ type: "aborted", message: "Tool execution interrupted" }))
if (llmFailure && !providerFailed)
yield* serialized(
publisher.failUnsettledTools(
{
type: "tool.result-missing",
message: "Provider did not return a tool result",
},
true,
),
)
const hostedResultMissing =
stream._tag === "Success" && !providerFailed
? yield* serialized(
publisher.failUnsettledTools(
{ type: "tool.result-missing", message: "Provider did not return a tool result" },
true,
),
)
: false
if (hostedResultMissing && !publisher.stepSettlement())
yield* serialized(
publisher.failAssistant({
type: "tool.result-missing",
message: "Provider did not return a tool result",
}),
)
const stepFailure = publisher.stepFailure()
const stepSettlement = publisher.stepSettlement() const stepSettlement = publisher.stepSettlement()
const stepEndedCleanly = const stepEndedCleanly =
!streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed !streamInterrupted && !toolsInterrupted && infraError === undefined && !providerFailed && !stepFailure
if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement) if (stepSettlement && stepEndedCleanly) yield* publishStepEnd(stepSettlement)
// A provider error orphans recorded local calls; a clean stream can still leave if (stepFailure) yield* serialized(publisher.publishStepFailure())
// hosted calls without results.
if (providerFailed) yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
if (stream._tag === "Success" && !providerFailed)
yield* serialized(publisher.failUnsettledTools("Provider did not return a tool result", true))
if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause) if (stream._tag === "Failure") return yield* Effect.failCause(stream.cause)
if (settled._tag === "Failure" && (toolsInterrupted || infraError !== undefined)) if (userDeclined) return yield* Effect.interrupt
return yield* Effect.failCause(settled.cause) if (permissionRejected) return yield* new UserInterruptedError()
if ((toolsInterrupted || infraError !== undefined) && settledFailure)
return yield* Effect.failCause(settledFailure)
if (toolsInterrupted && settled._tag === "Failure") return yield* Effect.failCause(settled.cause)
if (stepFailure) return yield* new StepFailedError({ error: stepFailure })
return { return {
_tag: "Completed", _tag: "Completed",
needsContinuation: !providerFailed && needsContinuation, needsContinuation: !providerFailed && needsContinuation,
@ -405,8 +481,31 @@ const layer = Layer.effect(
let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow let recoverOverflow: typeof compaction.compactAfterOverflow | undefined = compaction.compactAfterOverflow
let currentPromotion = promotion let currentPromotion = promotion
let currentStep = step let currentStep = step
let assistantMessageID: SessionMessage.ID | undefined
while (true) { while (true) {
const attempt = yield* attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow) const attempt = yield* Effect.suspend(() =>
attemptStep(sessionID, currentPromotion, currentStep, recoverOverflow, assistantMessageID),
).pipe(
Effect.tapError((error) =>
error instanceof SessionRunnerRetry.RetryableFailure
? Effect.sync(() => {
currentStep = error.step + 1
assistantMessageID = error.assistantMessageID
currentPromotion = undefined
})
: Effect.void,
),
Effect.retryOrElse(SessionRunnerRetry.schedule(events, sessionID), (error) => {
if (!(error instanceof SessionRunnerRetry.RetryableFailure)) return Effect.fail(error)
return events
.publish(SessionEvent.Step.Failed, {
sessionID,
assistantMessageID: error.assistantMessageID,
error: error.error,
})
.pipe(Effect.andThen(Effect.fail(error.cause)))
}),
)
if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step } if (attempt._tag === "Completed") return { needsContinuation: attempt.needsContinuation, step: attempt.step }
if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined if (attempt._tag === "RestartAfterOverflowCompaction") recoverOverflow = undefined
yield* Effect.yieldNow yield* Effect.yieldNow
@ -415,12 +514,36 @@ const layer = Layer.effect(
} }
}) })
// ExecutionSettled is published per execution (busy period) by SessionExecution, not per const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
// drain here. sessionID: SessionSchema.ID,
) {
const pending = yield* SessionInput.pendingCompaction(db, sessionID)
if (!pending) return false
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted) && compacted.value) return true
yield* events.publish(SessionEvent.Compaction.Failed, { sessionID })
if (Exit.isFailure(compacted)) return yield* Effect.failCause(compacted.cause)
return true
}),
)
})
// Execution lifecycle is published per busy period by SessionExecution, not per drain here.
const drain = Effect.fn("SessionRunner.drain")(function* (input: { const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
}) { }) {
yield* runPendingCompaction(input.sessionID)
const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer") const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue") const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
if (!input.force && !hasSteer && !hasQueue) return if (!input.force && !hasSteer && !hasQueue) return
@ -444,11 +567,19 @@ const layer = Layer.effect(
} }
needsContinuation = result.needsContinuation needsContinuation = result.needsContinuation
step = result.step + 1 step = result.step + 1
if (needsContinuation) {
promotion = (yield* SessionInput.pendingCompaction(db, input.sessionID)) ? undefined : "steer"
continue
}
yield* runPendingCompaction(input.sessionID)
promotion = "steer" promotion = "steer"
if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer")
} }
shouldRun = yield* SessionInput.hasPending(db, input.sessionID, "queue") yield* runPendingCompaction(input.sessionID)
promotion = shouldRun ? "queue" : undefined const hasSteer = yield* SessionInput.hasPending(db, input.sessionID, "steer")
const hasQueue = hasSteer ? false : yield* SessionInput.hasPending(db, input.sessionID, "queue")
shouldRun = hasSteer || hasQueue
promotion = hasSteer ? "steer" : hasQueue ? "queue" : undefined
} }
}) })

View file

@ -1,16 +1,19 @@
import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm" import { ToolOutput, type LLMEvent, type ProviderMetadata, type ToolResultValue, type Usage } from "@opencode-ai/llm"
import { DateTime, Effect } from "effect" import { Effect } from "effect"
import { EventV2 } from "../../event" import { EventV2 } from "../../event"
import { ModelV2 } from "../../model" import { ModelV2 } from "../../model"
import { SessionEvent } from "../event" import { SessionEvent } from "../event"
import { SessionMessage } from "../message" import { SessionMessage } from "../message"
import { SessionSchema } from "../schema" import { SessionSchema } from "../schema"
import { SessionError } from "@opencode-ai/schema/session-error"
type Input = { type Input = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly agent: string readonly agent: string
readonly model: ModelV2.Ref readonly model: ModelV2.Ref
readonly provider: string
readonly snapshot?: string readonly snapshot?: string
readonly assistantMessageID?: SessionMessage.ID
} }
const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0) const safe = (value: number | undefined) => Math.max(0, Number.isFinite(value) ? (value ?? 0) : 0)
@ -41,10 +44,10 @@ const message = (value: unknown) => {
type SettledOutput = type SettledOutput =
| { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] } | { readonly structured: Record<string, unknown>; readonly content: ToolOutput["content"] }
| { readonly error: { readonly type: "unknown"; readonly message: string } } | { readonly error: SessionError.Error }
const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => { const settledOutput = (value: ToolOutput | undefined, result: ToolResultValue): SettledOutput => {
if (result.type === "error") return { error: { type: "unknown", message: message(result.value) } } if (result.type === "error") return { error: { type: "tool.execution", message: message(result.value) } }
const settled = value ?? ToolOutput.fromResultValue(result) const settled = value ?? ToolOutput.fromResultValue(result)
if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`) if (!settled) throw new Error(`Unsupported tool result: ${message(result)}`)
return { structured: record(settled.structured), content: settled.content } return { structured: record(settled.structured), content: settled.content }
@ -61,20 +64,25 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
called: boolean called: boolean
settled: boolean settled: boolean
providerExecuted: boolean providerExecuted: boolean
providerMetadata?: ProviderMetadata
} }
>() >()
const timestamp = DateTime.now let assistantMessageID = input.assistantMessageID
let assistantMessageID: SessionMessage.ID | undefined let stepStarted = false
let assistantActive = false let stepFailed = false
let assistantFailed = false
let providerFailed = false let providerFailed = false
let stepSettlement: { readonly finish: string; readonly tokens: ReturnType<typeof tokens> } | undefined let retryEvidence = false
let stepFailure: SessionError.Error | undefined
let stepSettlement:
| {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]
readonly tokens: ReturnType<typeof tokens>
}
| undefined
const startAssistant = Effect.fnUntraced(function* () { const startAssistant = Effect.fnUntraced(function* () {
if (assistantMessageID !== undefined) return assistantMessageID if (stepStarted && assistantMessageID !== undefined) return assistantMessageID
assistantMessageID = SessionMessage.ID.create() assistantMessageID ??= SessionMessage.ID.create()
assistantActive = true stepStarted = true
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
...input, ...input,
assistantMessageID, assistantMessageID,
@ -86,29 +94,34 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
assistantMessageID === undefined assistantMessageID === undefined
? Effect.die(new Error("Tool event before assistant step start")) ? Effect.die(new Error("Tool event before assistant step start"))
: Effect.succeed(assistantMessageID) : Effect.succeed(assistantMessageID)
const providerState = (metadata: ProviderMetadata | undefined) => metadata?.[input.provider]
const fragments = ( const fragments = (
name: string, name: string,
ended: (id: string, value: string, providerMetadata?: ProviderMetadata) => Effect.Effect<void>, ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
single = false,
) => { ) => {
const chunks = new Map<string, string[]>() const chunks = new Map<string, { readonly ordinal: number; readonly values: string[] }>()
let nextOrdinal = 0
const start = (id: string) => const start = (id: string) =>
Effect.suspend(() => { Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
chunks.set(id, []) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
return Effect.void const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [] })
return Effect.succeed(ordinal)
}) })
const append = (id: string, value: string) => const append = (id: string, value: string) =>
Effect.suspend(() => { Effect.suspend(() => {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.push(value) current.values.push(value)
return Effect.void return Effect.succeed(current.ordinal)
}) })
const end = Effect.fnUntraced(function* (id: string, providerMetadata?: ProviderMetadata) { const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>) {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* ended(id, current.join(""), providerMetadata) yield* ended(id, current.values.join(""), current.ordinal, state)
chunks.delete(id) chunks.delete(id)
}) })
const flush = Effect.fnUntraced(function* () { const flush = Effect.fnUntraced(function* () {
@ -117,26 +130,32 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return { start, append, end, flush } return { start, append, end, flush }
} }
const text = fragments("text", (textID, value) => const text = fragments(
Effect.gen(function* () { "text",
yield* events.publish(SessionEvent.Text.Ended, { (_textID, value, ordinal) =>
sessionID: input.sessionID, Effect.gen(function* () {
assistantMessageID: yield* currentAssistantMessageID(), yield* events.publish(SessionEvent.Text.Ended, {
textID, sessionID: input.sessionID,
text: value, assistantMessageID: yield* currentAssistantMessageID(),
}) ordinal,
}), text: value,
})
}),
true,
) )
const reasoning = fragments("reasoning", (reasoningID, value, providerMetadata) => const reasoning = fragments(
Effect.gen(function* () { "reasoning",
yield* events.publish(SessionEvent.Reasoning.Ended, { (_reasoningID, value, ordinal, state) =>
sessionID: input.sessionID, Effect.gen(function* () {
assistantMessageID: yield* currentAssistantMessageID(), yield* events.publish(SessionEvent.Reasoning.Ended, {
reasoningID, sessionID: input.sessionID,
text: value, assistantMessageID: yield* currentAssistantMessageID(),
providerMetadata, ordinal,
}) text: value,
}), state,
})
}),
true,
) )
const toolInput = fragments("tool input", (callID, value) => const toolInput = fragments("tool input", (callID, value) =>
Effect.gen(function* () { Effect.gen(function* () {
@ -191,37 +210,41 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* flushFragments() yield* flushFragments()
}) })
const failAssistant = Effect.fnUntraced(function* (message: string) { const failAssistant = Effect.fnUntraced(function* (error: SessionError.Error, replace = false) {
if (assistantFailed) return
yield* flush() yield* flush()
yield* startAssistant()
if (replace || stepFailure === undefined) stepFailure = error
})
const publishStepFailure = Effect.fnUntraced(function* () {
if (stepFailed || stepFailure === undefined) return
const assistantMessageID = yield* startAssistant() const assistantMessageID = yield* startAssistant()
assistantActive = false stepFailed = true
assistantFailed = true
yield* events.publish(SessionEvent.Step.Failed, { yield* events.publish(SessionEvent.Step.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID, assistantMessageID,
error: { type: "unknown", message }, error: stepFailure,
}) })
}) })
const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* ( const failUnsettledTools = Effect.fn("SessionRunner.failUnsettledTools")(function* (
message: string, error: SessionError.Error,
hostedOnly = false, hostedOnly = false,
) { ) {
let failed = false
for (const [callID, tool] of tools) { for (const [callID, tool] of tools) {
if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue if (tool.settled || (hostedOnly && !tool.providerExecuted)) continue
tool.settled = true tool.settled = true
failed = true
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID, callID,
error: { type: "unknown", message }, error,
provider: { executed: tool.providerExecuted,
executed: tool.providerExecuted,
...(tool.providerMetadata === undefined ? {} : { metadata: tool.providerMetadata }),
},
}) })
} }
return failed
}) })
const assistantMessageIDForTool = (callID: string) => { const assistantMessageIDForTool = (callID: string) => {
@ -232,24 +255,27 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* ( const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (
event: LLMEvent, event: LLMEvent,
outputPaths: ReadonlyArray<string> = [], outputPaths: ReadonlyArray<string> = [],
error?: SessionError.Error,
) { ) {
switch (event.type) { switch (event.type) {
case "step-start": case "step-start":
yield* startAssistant()
return return
case "text-start": case "text-start":
yield* text.start(event.id) retryEvidence = true
const startedTextOrdinal = yield* text.start(event.id)
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
textID: event.id, ordinal: startedTextOrdinal,
}) })
return return
case "text-delta": case "text-delta":
yield* text.append(event.id, event.text) const deltaTextOrdinal = yield* text.append(event.id, event.text)
yield* events.publish(SessionEvent.Text.Delta, { yield* events.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
textID: event.id, ordinal: deltaTextOrdinal,
delta: event.text, delta: event.text,
}) })
return return
@ -257,27 +283,29 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* text.end(event.id) yield* text.end(event.id)
return return
case "reasoning-start": case "reasoning-start":
yield* reasoning.start(event.id) retryEvidence = true
const startedReasoningOrdinal = yield* reasoning.start(event.id)
yield* events.publish(SessionEvent.Reasoning.Started, { yield* events.publish(SessionEvent.Reasoning.Started, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* startAssistant(), assistantMessageID: yield* startAssistant(),
reasoningID: event.id, ordinal: startedReasoningOrdinal,
providerMetadata: event.providerMetadata, state: providerState(event.providerMetadata),
}) })
return return
case "reasoning-delta": case "reasoning-delta":
yield* reasoning.append(event.id, event.text) const deltaReasoningOrdinal = yield* reasoning.append(event.id, event.text)
yield* events.publish(SessionEvent.Reasoning.Delta, { yield* events.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(), assistantMessageID: yield* currentAssistantMessageID(),
reasoningID: event.id, ordinal: deltaReasoningOrdinal,
delta: event.text, delta: event.text,
}) })
return return
case "reasoning-end": case "reasoning-end":
yield* reasoning.end(event.id, event.providerMetadata) yield* reasoning.end(event.id, providerState(event.providerMetadata))
return return
case "tool-input-start": case "tool-input-start":
retryEvidence = true
yield* startToolInput(event) yield* startToolInput(event)
return return
case "tool-input-delta": { case "tool-input-delta": {
@ -299,6 +327,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
yield* endToolInput(event) yield* endToolInput(event)
return return
case "tool-call": { case "tool-call": {
retryEvidence = true
if (!tools.has(event.id)) yield* startToolInput(event) if (!tools.has(event.id)) yield* startToolInput(event)
const tool = tools.get(event.id)! const tool = tools.get(event.id)!
if (!tool.inputEnded) yield* endToolInput(event) if (!tool.inputEnded) yield* endToolInput(event)
@ -307,21 +336,19 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`)) if (tool.called) return yield* Effect.die(new Error(`Duplicate tool call: ${event.id}`))
tool.called = true tool.called = true
tool.providerExecuted = event.providerExecuted === true tool.providerExecuted = event.providerExecuted === true
tool.providerMetadata = event.providerMetadata const state = providerState(event.providerMetadata)
yield* events.publish(SessionEvent.Tool.Called, { yield* events.publish(SessionEvent.Tool.Called, {
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
tool: event.name,
input: record(event.input), input: record(event.input),
provider: { executed: tool.providerExecuted,
executed: tool.providerExecuted, state,
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
},
}) })
return return
} }
case "tool-result": { case "tool-result": {
retryEvidence = true
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`)) if (!tool?.called) return yield* Effect.die(new Error(`Tool result before call: ${event.id}`))
if (tool.name !== event.name) if (tool.name !== event.name)
@ -331,11 +358,9 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`)) return yield* Effect.die(new Error(`Duplicate tool result: ${event.id}`))
} }
tool.settled = true tool.settled = true
const result = settledOutput(event.output, event.result) const result = error ? { error } : settledOutput(event.output, event.result)
const provider = { const executed = event.providerExecuted === true || tool.providerExecuted
executed: event.providerExecuted === true || tool.providerExecuted, const resultState = providerState(event.providerMetadata)
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }),
}
if ("error" in result) { if ("error" in result) {
yield* events.publish(SessionEvent.Tool.Failed, { yield* events.publish(SessionEvent.Tool.Failed, {
sessionID: input.sessionID, sessionID: input.sessionID,
@ -343,7 +368,8 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id, callID: event.id,
error: result.error, error: result.error,
result: event.result, result: event.result,
provider, executed,
resultState,
}) })
return return
} }
@ -353,12 +379,14 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
callID: event.id, callID: event.id,
...result, ...result,
outputPaths, outputPaths,
...(provider.executed ? { result: event.result } : {}), ...(executed ? { result: event.result } : {}),
provider, executed,
resultState,
}) })
return return
} }
case "tool-error": { case "tool-error": {
retryEvidence = true
const tool = tools.get(event.id) const tool = tools.get(event.id)
if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`)) if (!tool?.called) return yield* Effect.die(new Error(`Tool error before call: ${event.id}`))
if (tool.name !== event.name) if (tool.name !== event.name)
@ -369,25 +397,30 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
sessionID: input.sessionID, sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID, assistantMessageID: tool.assistantMessageID,
callID: event.id, callID: event.id,
error: { type: "unknown", message: event.message }, error:
provider: { event.message === `Unknown tool: ${event.name}`
executed: tool.providerExecuted, ? { type: "tool.unknown", message: event.message }
...(event.providerMetadata === undefined ? {} : { metadata: event.providerMetadata }), : { type: "tool.execution", message: event.message },
}, executed: tool.providerExecuted,
resultState: providerState(event.providerMetadata),
}) })
return return
} }
case "step-finish": case "step-finish":
yield* flush() yield* flush()
assistantActive = false
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish")) if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
if (event.reason === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" }, true)
return
}
stepSettlement = { finish: event.reason, tokens: tokens(event.usage) } stepSettlement = { finish: event.reason, tokens: tokens(event.usage) }
return return
case "finish": case "finish":
return return
case "provider-error": case "provider-error":
providerFailed = true providerFailed = true
yield* failAssistant(event.message) yield* failAssistant({ type: "provider.unknown", message: event.message }, true)
return return
} }
}) })
@ -396,10 +429,11 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
publish, publish,
flush, flush,
failAssistant, failAssistant,
publishStepFailure,
failUnsettledTools, failUnsettledTools,
hasActiveAssistant: () => assistantActive,
hasAssistantStarted: () => assistantMessageID !== undefined,
hasProviderError: () => providerFailed, hasProviderError: () => providerFailed,
hasRetryEvidence: () => retryEvidence,
stepFailure: () => stepFailure,
stepSettlement: () => stepSettlement, stepSettlement: () => stepSettlement,
startAssistant, startAssistant,
assistantMessageID: assistantMessageIDForTool, assistantMessageID: assistantMessageIDForTool,

View file

@ -0,0 +1,67 @@
export * as SessionRunnerRetry from "./retry"
import { LLMError } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Data, Duration, Effect, Schedule } from "effect"
import { EventV2 } from "../../event"
import { SessionEvent } from "../event"
import { SessionMessage } from "../message"
import { SessionSchema } from "../schema"
import type { SessionRunner } from "./index"
export class RetryableFailure extends Data.TaggedError("SessionRunner.RetryableFailure")<{
readonly cause: LLMError
readonly assistantMessageID: SessionMessage.ID
readonly error: SessionError.Error
readonly step: number
}> {}
export function isRetryable(error: LLMError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
const retryAfter = (failure: RetryableFailure) => {
if (failure.cause.reason._tag === "RateLimit" || failure.cause.reason._tag === "ProviderInternal")
return failure.cause.reason.retryAfterMs
return undefined
}
export const schedule = (events: EventV2.Interface, sessionID: SessionSchema.ID) =>
Schedule.exponential("2 seconds").pipe(
Schedule.take(4),
Schedule.setInputType<RetryableFailure | SessionRunner.RunError>(),
Schedule.passthrough,
Schedule.while(({ input }) => input instanceof RetryableFailure),
Schedule.modifyDelay((failure, delay) => {
const minimum = failure instanceof RetryableFailure ? retryAfter(failure) : undefined
return Effect.succeed(minimum === undefined ? delay : Duration.max(delay, Duration.millis(minimum)))
}),
Schedule.tap((metadata) =>
metadata.input instanceof RetryableFailure
? events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: metadata.input.assistantMessageID,
attempt: metadata.attempt + 1,
at: metadata.now + Duration.toMillis(metadata.duration),
error: metadata.input.error,
})
: Effect.void,
),
)

View file

@ -41,8 +41,33 @@ const textAttachment = (file: FileAttachment) =>
}, },
}) })
const directoryAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
content: [
`Attached directory: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "directory")}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
file.data.length === 0 ? undefined : "",
file.data.length === 0 ? undefined : Buffer.from(file.data, "base64").toString("utf8"),
]
.filter((line): line is string => line !== undefined)
.join("\n"),
metadata: {
attachment: {
source: file.source,
name: file.name,
description: file.description,
},
},
})
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const providerMetadata = (
provider: string,
state: Record<string, unknown> | undefined,
): ProviderMetadata | undefined => (state === undefined ? undefined : { [provider]: state })
const toolInput = (tool: SessionMessage.AssistantTool) => const toolInput = (tool: SessionMessage.AssistantTool) =>
tool.state.status === "pending" tool.state.status === "pending"
? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input) ? Option.getOrElse(decodeToolInput(tool.state.input), () => tool.state.input)
@ -53,7 +78,7 @@ const toolCall = (tool: SessionMessage.AssistantTool, providerMetadata: Provider
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
input: toolInput(tool), input: toolInput(tool),
providerExecuted: tool.provider?.executed, providerExecuted: tool.executed,
providerMetadata, providerMetadata,
}) })
@ -62,14 +87,14 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
// TODO: Materialize remote and managed URIs before provider-history lowering. // TODO: Materialize remote and managed URIs before provider-history lowering.
// ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes. // ToolOutput.toResultValue rejects unresolved URIs rather than treating them as media bytes.
const result = const result =
tool.provider?.executed === true && tool.state.result !== undefined tool.executed === true && tool.state.result !== undefined
? tool.state.result ? tool.state.result
: ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content }) : ToolOutput.toResultValue({ structured: tool.state.structured, content: tool.state.content })
return ToolResultPart.make({ return ToolResultPart.make({
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
result, result,
providerExecuted: tool.provider?.executed, providerExecuted: tool.executed,
providerMetadata, providerMetadata,
}) })
} }
@ -78,11 +103,11 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid
id: tool.id, id: tool.id,
name: tool.name, name: tool.name,
result: result:
tool.provider?.executed === true && tool.state.result !== undefined tool.executed === true && tool.state.result !== undefined
? tool.state.result ? tool.state.result
: { error: tool.state.error, content: tool.state.content, structured: tool.state.structured }, : { error: tool.state.error, content: tool.state.content, structured: tool.state.structured },
resultType: "error", resultType: "error",
providerExecuted: tool.provider?.executed, providerExecuted: tool.executed,
providerMetadata, providerMetadata,
}) })
} }
@ -100,17 +125,22 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
{ {
type: "reasoning", type: "reasoning",
text: item.text, text: item.text,
providerMetadata: reuseProviderMetadata ? item.providerMetadata : undefined, providerMetadata: reuseProviderMetadata ? providerMetadata(model.providerID, item.state) : undefined,
}, },
] ]
: item.text.length > 0 : item.text.length > 0
? [{ type: "text", text: item.text }] ? [{ type: "text", text: item.text }]
: [] : []
const call = toolCall(item, reuseProviderMetadata ? item.provider?.metadata : undefined) const call = toolCall(
if (item.provider?.executed !== true) return [call] item,
reuseProviderMetadata ? providerMetadata(model.providerID, item.providerState) : undefined,
)
if (item.executed !== true) return [call]
const result = toolResult( const result = toolResult(
item, item,
reuseProviderMetadata ? (item.provider.resultMetadata ?? item.provider.metadata) : undefined, reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
) )
return result ? [call, result] : [call] return result ? [call, result] : [call]
}) })
@ -120,9 +150,14 @@ const assistant = (message: SessionMessage.Assistant, model: ModelV2.Ref) => {
return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0) return part.text !== "" || (part.providerMetadata !== undefined && Object.keys(part.providerMetadata).length > 0)
}) })
const results = message.content const results = message.content
.filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.provider?.executed !== true) .filter((item): item is SessionMessage.AssistantTool => item.type === "tool" && item.executed !== true)
.map((item) => .map((item) =>
toolResult(item, reuseProviderMetadata ? (item.provider?.resultMetadata ?? item.provider?.metadata) : undefined), toolResult(
item,
reuseProviderMetadata
? providerMetadata(model.providerID, item.providerResultState ?? item.providerState)
: undefined,
),
) )
.filter((message) => message !== undefined) .filter((message) => message !== undefined)
.map(Message.tool) .map(Message.tool)
@ -141,9 +176,8 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "user": case "user":
const files = message.files ?? [] const files = message.files ?? []
return [ return [
...files ...files.filter((file) => file.mime === "text/plain").map(textAttachment),
.filter((file) => file.mime === "text/plain") ...files.filter((file) => file.mime === "application/x-directory").map(directoryAttachment),
.map(textAttachment),
Message.make({ Message.make({
id: message.id, id: message.id,
role: "user", role: "user",
@ -175,6 +209,7 @@ function toLLMMessage(message: SessionMessage.Message, model: ModelV2.Ref): Mess
case "assistant": case "assistant":
return assistant(message, model) return assistant(message, model)
case "compaction": case "compaction":
if (message.status !== "completed") return []
return [ return [
Message.make({ Message.make({
id: message.id, id: message.id,

View file

@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core" import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path" import { directoryColumn, pathColumn } from "../database/path"
import { ProjectTable } from "../project/sql" import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message" import type { SessionMessage } from "./message"
@ -29,6 +30,8 @@ export const SessionTable = sqliteTable(
.references(() => ProjectTable.id, { onDelete: "cascade" }), .references(() => ProjectTable.id, { onDelete: "cascade" }),
workspace_id: text().$type<WorkspaceV2.ID>(), workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(), parent_id: text().$type<SessionSchema.ID>(),
fork_session_id: text().$type<SessionSchema.ID>(),
fork_message_id: text().$type<SessionMessage.ID>(),
slug: text().notNull(), slug: text().notNull(),
directory: directoryColumn().notNull(), directory: directoryColumn().notNull(),
path: pathColumn(), path: pathColumn(),
@ -145,8 +148,9 @@ export const SessionInputTable = sqliteTable(
.$type<SessionSchema.ID>() .$type<SessionSchema.ID>()
.notNull() .notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }), .references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type<Prompt>(), type: text().$type<SessionInput.Entry["type"]>().notNull(),
delivery: text().$type<SessionInput.Delivery>().notNull(), prompt: text({ mode: "json" }).$type<Prompt>(),
delivery: text().$type<SessionInput.Delivery>(),
admitted_seq: integer().notNull(), admitted_seq: integer().notNull(),
promoted_seq: integer(), promoted_seq: integer(),
time_created: integer() time_created: integer()
@ -154,12 +158,16 @@ export const SessionInputTable = sqliteTable(
.$default(() => Date.now()), .$default(() => Date.now()),
}, },
(table) => [ (table) => [
index("session_input_session_pending_delivery_seq_idx").on( index("session_input_session_pending_type_delivery_seq_idx").on(
table.session_id, table.session_id,
table.promoted_seq, table.promoted_seq,
table.type,
table.delivery, table.delivery,
table.admitted_seq, table.admitted_seq,
), ),
uniqueIndex("session_input_session_pending_compaction_idx")
.on(table.session_id)
.where(sql`${table.type} = 'compaction' and ${table.promoted_seq} is null`),
uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq), uniqueIndex("session_input_session_admitted_seq_idx").on(table.session_id, table.admitted_seq),
uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq), uniqueIndex("session_input_session_promoted_seq_idx").on(table.session_id, table.promoted_seq),
], ],

View file

@ -0,0 +1,55 @@
import { LLMError, ToolFailure } from "@opencode-ai/llm"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Integration } from "../integration"
import { ToolOutputStore } from "../tool-output-store"
import { StepFailedError, UserInterruptedError } from "./error"
import { SessionRunnerModel } from "./runner/model"
export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return { type: "provider.unknown", message: cause.reason.message }
default: {
const exhaustive: never = cause.reason
return exhaustive
}
}
}
if (cause instanceof PermissionV2.BlockedError) return { type: "permission.rejected", message: cause.message }
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure)
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
if (cause instanceof ToolOutputStore.StorageError) return { type: "unknown", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}

View file

@ -32,6 +32,7 @@ type Active = {
// started after termination resolves immediately from the already-completed deferred. // started after termination resolves immediately from the already-completed deferred.
done: Deferred.Deferred<Info, NotFoundError> done: Deferred.Deferred<Info, NotFoundError>
timeoutFiber?: Fiber.Fiber<void> timeoutFiber?: Fiber.Fiber<void>
timeout?: (duration: number) => Effect.Effect<void>
} }
/** /**
@ -50,6 +51,8 @@ export interface Interface {
// Resolves once the command reaches a terminal status, returning its final Info. Fails with // Resolves once the command reaches a terminal status, returning its final Info. Fails with
// NotFoundError if the command is unknown or is removed before it terminates. // NotFoundError if the command is unknown or is removed before it terminates.
readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError> readonly wait: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
// Replaces the running command's timeout from now; zero clears it.
readonly timeout: (id: Shell.ID, duration: number) => Effect.Effect<Shell.Info, NotFoundError>
readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError> readonly output: (id: Shell.ID, input?: Shell.OutputInput) => Effect.Effect<Shell.Output, NotFoundError>
readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError> readonly remove: (id: Shell.ID) => Effect.Effect<void, NotFoundError>
} }
@ -124,6 +127,13 @@ export const layer = Layer.effect(
return yield* Deferred.await((yield* require(id)).done) return yield* Deferred.await((yield* require(id)).done)
}) })
const timeout = Effect.fn("Shell.timeout")(function* (id: Shell.ID, duration: number) {
const session = yield* require(id)
if (session.info.status !== "running" || !session.timeout) return session.info
yield* session.timeout(duration)
return session.info
})
const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) { const output = Effect.fn("Shell.output")(function* (id: Shell.ID, input?: Shell.OutputInput) {
const session = yield* require(id) const session = yield* require(id)
const cursor = input?.cursor ?? 0 const cursor = input?.cursor ?? 0
@ -265,16 +275,22 @@ export const layer = Layer.effect(
if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber) if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
}) })
if (input.timeout) { session.timeout = (duration) =>
session.timeoutFiber = runFork( Effect.gen(function* () {
Effect.sleep(Duration.millis(input.timeout)).pipe( if (session.timeoutFiber) yield* Fiber.interrupt(session.timeoutFiber)
Effect.flatMap(() => session.timeoutFiber = undefined
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))), if (duration === 0 || session.info.status !== "running") return
session.timeoutFiber = runFork(
Effect.sleep(Duration.millis(duration)).pipe(
Effect.flatMap(() =>
finish("timeout", undefined, handle.kill().pipe(Effect.catch(() => Effect.void))),
),
Effect.catch(() => Effect.void),
), ),
Effect.catch(() => Effect.void), )
), })
)
} yield* session.timeout(input.timeout)
runFork( runFork(
handle.exitCode.pipe( handle.exitCode.pipe(
@ -296,7 +312,7 @@ export const layer = Layer.effect(
return session.info return session.info
}) })
return Service.of({ create, list, get, wait, output, remove }) return Service.of({ create, list, get, wait, timeout, output, remove })
}), }),
) )

View file

@ -75,12 +75,12 @@ export const Plugin = {
toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }],
execute: (input, context) => { execute: (input, context) => {
const applied: Array<typeof Applied.Type> = [] const applied: Array<typeof Applied.Type> = []
const fail = (path: string) => { const fail = (path: string, error?: unknown) => {
const prefix = const prefix =
applied.length === 0 applied.length === 0
? `Unable to apply patch at ${path}` ? `Unable to apply patch at ${path}`
: `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}` : `Patch partially applied before failing at ${path}. Applied: ${applied.map((item) => item.resource).join(", ")}`
return new ToolFailure({ message: prefix }) return new ToolFailure({ message: prefix, error })
} }
return Effect.gen(function* () { return Effect.gen(function* () {
const source = { const source = {
@ -152,7 +152,7 @@ export const Plugin = {
before, before,
after: update.content, after: update.content,
}) })
}).pipe(Effect.mapError(() => fail(hunk.path))) }).pipe(Effect.mapError((error) => fail(hunk.path, error)))
} }
const patchFiles = prepared.map(patchFile) const patchFiles = prepared.map(patchFile)
@ -182,11 +182,11 @@ export const Plugin = {
content: change.content, content: change.content,
}) })
applied.push({ type: change.type, resource: result.resource, target: result.target }) applied.push({ type: change.type, resource: result.resource, target: result.target })
}).pipe(Effect.mapError(() => fail(change.path))), }).pipe(Effect.mapError((error) => fail(change.path, error))),
{ discard: true }, { discard: true },
) )
return { applied, files: patchFiles } return { applied, files: patchFiles }
}).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch")))) }).pipe(Effect.mapError((error) => (error instanceof ToolFailure ? error : fail("patch", error))))
}, },
}), }),
"edit", "edit",

View file

@ -113,8 +113,9 @@ export const Plugin = {
error instanceof FileMutation.StaleContentError error instanceof FileMutation.StaleContentError
? new ToolFailure({ ? new ToolFailure({
message: "File changed after permission approval. Read it again before editing.", message: "File changed after permission approval. Read it again before editing.",
error,
}) })
: new ToolFailure({ message: `Unable to edit ${input.path}` }), : new ToolFailure({ message: `Unable to edit ${input.path}`, error }),
), ),
) )

View file

@ -102,7 +102,7 @@ export const Plugin = {
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ToolFailure error instanceof ToolFailure
? error ? error
: new ToolFailure({ message: `Unable to find files matching ${input.pattern}` }), : new ToolFailure({ message: `Unable to find files matching ${input.pattern}`, error }),
), ),
), ),
}), }),

View file

@ -133,7 +133,7 @@ export const Plugin = {
Effect.mapError((error) => Effect.mapError((error) =>
error instanceof ToolFailure error instanceof ToolFailure
? error ? error
: new ToolFailure({ message: `Unable to grep for ${input.pattern}` }), : new ToolFailure({ message: `Unable to grep for ${input.pattern}`, error }),
), ),
), ),
}), }),

View file

@ -76,7 +76,7 @@ export const Plugin = {
source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID }, source: { type: "tool", messageID: context.assistantMessageID, callID: context.toolCallID },
}) })
.pipe( .pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })), Effect.mapError((error) => new ToolFailure({ message: "Permission denied: question", error })),
Effect.andThen( Effect.andThen(
forms forms
.ask({ .ask({

View file

@ -134,7 +134,7 @@ export const Plugin = {
error instanceof Image.SizeError error instanceof Image.SizeError
? error.message ? error.message
: `Unable to read ${input.path}` : `Unable to read ${input.path}`
return new ToolFailure({ message }) return new ToolFailure({ message, error })
}), }),
) )
}, },

View file

@ -14,6 +14,8 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
import { Tools } from "./tools" import { Tools } from "./tools"
import { ToolHooks } from "./hooks" import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error"
export type ExecuteInput = { export type ExecuteInput = {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
@ -45,6 +47,7 @@ export interface Settlement {
readonly result: ToolResultValue readonly result: ToolResultValue
readonly output?: ToolOutput readonly output?: ToolOutput
readonly outputPaths?: ReadonlyArray<string> readonly outputPaths?: ReadonlyArray<string>
readonly error?: SessionError.Error
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
@ -86,7 +89,10 @@ const registryLayer = Layer.effect(
).pipe( ).pipe(
Effect.map((output) => ({ output })), Effect.map((output) => ({ output })),
Effect.catchTag("LLM.ToolFailure", (failure) => Effect.catchTag("LLM.ToolFailure", (failure) =>
Effect.succeed({ result: { type: "error" as const, value: failure.message } }), Effect.succeed({
result: { type: "error" as const, value: failure.message },
error: toSessionError(failure),
}),
), ),
) )
let settlement: Settlement let settlement: Settlement
@ -124,20 +130,19 @@ const registryLayer = Layer.effect(
result: afterEvent.result, result: afterEvent.result,
...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}), ...(afterEvent.output !== undefined ? { output: afterEvent.output } : {}),
...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}), ...(afterEvent.outputPaths !== undefined ? { outputPaths: afterEvent.outputPaths } : {}),
...(settlement.error !== undefined ? { error: settlement.error } : {}),
} }
}) })
const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) { const settleWith = Effect.fn("ToolRegistry.settle")(function* (input: ExecuteInput, advertised: object) {
const registration = local.get(input.call.name)?.at(-1)?.registration const registration = local.get(input.call.name)?.at(-1)?.registration
if (!registration) if (!registration || registration.identity !== advertised) {
const message = `Stale tool call: ${input.call.name}`
return { return {
result: { result: { type: "error" as const, value: message },
type: "error" as const, error: { type: "tool.stale" as const, message },
value: `Stale tool call: ${input.call.name}`,
},
} }
if (registration.identity !== advertised) }
return { result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` } }
return yield* settleTool(input, registration.tool) return yield* settleTool(input, registration.tool)
}) })
@ -215,7 +220,10 @@ const registryLayer = Layer.effect(
if (input.call.name === "execute" && execute) return settleTool(input, execute) if (input.call.name === "execute" && execute) return settleTool(input, execute)
const registration = direct.get(input.call.name) const registration = direct.get(input.call.name)
if (registration) return settleWith(input, registration.identity) if (registration) return settleWith(input, registration.identity)
return Effect.succeed({ result: { type: "error", value: `Unknown tool: ${input.call.name}` } }) return Effect.succeed({
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
})
}, },
} }
}), }),

View file

@ -8,7 +8,7 @@ import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation" import { LocationMutation } from "../location-mutation"
import { PermissionV2 } from "../permission" import { PermissionV2 } from "../permission"
import { PluginRuntime } from "../plugin/runtime" import { PluginRuntime } from "../plugin/runtime"
import { PositiveInt } from "../schema" import { NonNegativeInt } from "../schema"
import { SessionSchema } from "../session/schema" import { SessionSchema } from "../session/schema"
import { Shell } from "../shell" import { Shell } from "../shell"
import { Tool, type Content } from "./tool" import { Tool, type Content } from "./tool"
@ -27,10 +27,10 @@ export const Input = Schema.Struct({
workdir: Schema.String.pipe(Schema.optional).annotate({ workdir: Schema.String.pipe(Schema.optional).annotate({
description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.", description: "Working directory. Defaults to the active Location; relative paths resolve from that Location.",
}), }),
timeout: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS)) timeout: NonNegativeInt.check(Schema.isLessThanOrEqualTo(MAX_TIMEOUT_MS))
.pipe(Schema.optional) .pipe(Schema.optional)
.annotate({ .annotate({
description: `Timeout in milliseconds. Defaults to ${DEFAULT_TIMEOUT_MS} and may not exceed ${MAX_TIMEOUT_MS}.`, description: `Optional timeout in milliseconds. Zero means unlimited. Foreground commands default to ${DEFAULT_TIMEOUT_MS}; background commands default to unlimited. May not exceed ${MAX_TIMEOUT_MS}.`,
}), }),
background: Schema.Boolean.pipe(Schema.optional).annotate({ background: Schema.Boolean.pipe(Schema.optional).annotate({
description: description:
@ -143,7 +143,7 @@ export const Plugin = {
draft.add( draft.add(
name, name,
Tool.make({ Tool.make({
description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. Timeout values are milliseconds (default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`, description: `Execute one shell command string with the host user's filesystem, process, and network authority. The active Location is the default working directory. Relative workdir values resolve from that Location. External workdir values require external_directory approval; best-effort command-argument path warnings are advisory only. An optional timeout may be provided in milliseconds (zero: unlimited; foreground default: ${DEFAULT_TIMEOUT_MS}; maximum: ${MAX_TIMEOUT_MS}). Background commands default to unlimited. Uses the configured shell when set; otherwise uses /bin/sh on POSIX and COMSPEC or cmd.exe on Windows. Background mode (background=true) launches the command asynchronously and returns immediately; you are notified when it finishes.`,
input: Input, input: Input,
output: Output, output: Output,
structured: StructuredOutput, structured: StructuredOutput,
@ -191,7 +191,7 @@ export const Plugin = {
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory") if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`)) return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
const info = yield* shell.create({ const info = yield* shell.create({
command: input.command, command: input.command,
cwd: target.canonical, cwd: target.canonical,
@ -252,6 +252,7 @@ export const Plugin = {
.block({ id: job.id, sessionID: context.sessionID }) .block({ id: job.id, sessionID: context.sessionID })
.pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore))) .pipe(Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)))
if (result?.type === "backgrounded") { if (result?.type === "backgrounded") {
yield* shell.timeout(info.id, 0)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command) yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return { return {
output: BACKGROUND_STARTED, output: BACKGROUND_STARTED,
@ -270,7 +271,9 @@ export const Plugin = {
...(warnings.length ? { warnings } : {}), ...(warnings.length ? { warnings } : {}),
} }
}).pipe( }).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` })), Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
), ),
}), }),
), ),

View file

@ -107,7 +107,7 @@ export const Plugin = {
.get(context.sessionID) .get(context.sessionID)
.pipe( .pipe(
Effect.mapError( Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
), ),
) )
const agent = yield* agents.resolve(input.agent) const agent = yield* agents.resolve(input.agent)
@ -128,7 +128,7 @@ export const Plugin = {
}) })
.pipe( .pipe(
Effect.mapError( Effect.mapError(
() => new ToolFailure({ message: `Parent session not found: ${context.sessionID}` }), (error) => new ToolFailure({ message: `Parent session not found: ${context.sessionID}`, error }),
), ),
) )

View file

@ -48,7 +48,7 @@ export const Plugin = {
}) })
yield* todos.update({ sessionID: context.sessionID, todos: input.todos }) yield* todos.update({ sessionID: context.sessionID, todos: input.todos })
return { todos: input.todos } return { todos: input.todos }
}).pipe(Effect.mapError(() => new ToolFailure({ message: "Unable to update todos" }))), }).pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to update todos", error }))),
}), }),
), ),
) )

View file

@ -172,7 +172,7 @@ export const Plugin = {
format: input.format, format: input.format,
output, output,
} }
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to fetch ${input.url}` }))), }).pipe(Effect.mapError((error) => new ToolFailure({ message: `Unable to fetch ${input.url}`, error }))),
}), }),
), ),
) )

View file

@ -246,7 +246,9 @@ export const Plugin = {
text: text ?? NO_RESULTS, text: text ?? NO_RESULTS,
} }
}).pipe( }).pipe(
Effect.mapError(() => new ToolFailure({ message: `Unable to search the web for ${input.query}` })), Effect.mapError(
(error) => new ToolFailure({ message: `Unable to search the web for ${input.query}`, error }),
),
) )
}, },
}), }),

View file

@ -85,7 +85,9 @@ export const Plugin = {
source, source,
}) })
return yield* files.writeTextPreservingBom({ target, content: input.content }) return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to write ${input.path}` }))), }).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
}), }),
"edit", "edit",
), ),

View file

@ -175,7 +175,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
) )
const timeout = info.experimental?.mcp_timeout const timeout = info.experimental?.mcp_timeout
if (!timeout && !Object.keys(servers).length) return undefined if (!timeout && !Object.keys(servers).length) return undefined
return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
} }
function migrateMcp(info: ConfigMCPV1.Info) { function migrateMcp(info: ConfigMCPV1.Info) {
@ -187,7 +187,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
cwd: info.cwd, cwd: info.cwd,
environment: info.environment, environment: info.environment,
disabled, disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout }, timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
} }
return { return {
type: info.type, type: info.type,
@ -201,7 +201,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
redirect_uri: info.oauth.redirectUri, redirect_uri: info.oauth.redirectUri,
}, },
disabled, disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout }, timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
} }
} }

View file

@ -142,7 +142,7 @@ describe("Config", () => {
// V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false) expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false) expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false) expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}), }),
) )
@ -467,14 +467,14 @@ describe("Config", () => {
}, },
tool_output: { max_lines: 1000, max_bytes: 32768 }, tool_output: { max_lines: 1000, max_bytes: 32768 },
mcp: { mcp: {
timeout: { startup: 5000, request: 60000 }, timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
timeout: { request: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
type: "remote", type: "remote",
@ -552,14 +552,14 @@ describe("Config", () => {
}) })
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
expect(documents[0]?.info.mcp).toEqual({ expect(documents[0]?.info.mcp).toEqual({
timeout: { startup: 5000, request: 60000 }, timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
timeout: { request: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
type: "remote", type: "remote",
@ -792,19 +792,19 @@ describe("Config", () => {
buffer: 10000, buffer: 10000,
}) })
expect(documents[0]?.info.mcp).toMatchObject({ expect(documents[0]?.info.mcp).toMatchObject({
timeout: { request: 5000 }, timeout: { catalog: 5000, execution: 5000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "server.js"], command: ["node", "server.js"],
disabled: true, disabled: true,
timeout: { request: 10000 }, timeout: { catalog: 10000, execution: 10000 },
}, },
remote: { remote: {
type: "remote", type: "remote",
url: "https://mcp.example.com", url: "https://mcp.example.com",
oauth: { client_id: "client", callback_port: 19876 }, oauth: { client_id: "client", callback_port: 19876 },
timeout: { request: 20000 }, timeout: { catalog: 20000, execution: 20000 },
}, },
}, },
}) })

View file

@ -15,7 +15,10 @@ import eventSourcedSessionInputMigration from "@opencode-ai/core/database/migrat
import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent" import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera" import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input" import simplifySessionInputMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions" import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event" import { EventV2 } from "@opencode-ai/core/event"
@ -39,6 +42,29 @@ const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
const makeDb = EffectDrizzleSqlite.makeWithDefaults() const makeDb = EffectDrizzleSqlite.makeWithDefaults()
describe("DatabaseMigration", () => { describe("DatabaseMigration", () => {
test("resets incompatible V2 Session event history", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration])
expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
}),
)
})
test("serializes concurrent embedded initialization for one database path", async () => { test("serializes concurrent embedded initialization for one database path", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
const filename = path.join(tmp.path, "embedded.sqlite") const filename = path.join(tmp.path, "embedded.sqlite")
@ -84,13 +110,14 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
expect( expect(
yield* db.all( yield* db.all(
sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`, sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
), ),
).toEqual([ ).toEqual([
{ name: "event_aggregate_seq_idx" }, { name: "event_aggregate_seq_idx" },
{ name: "event_aggregate_type_seq_idx" }, { name: "event_aggregate_type_seq_idx" },
{ name: "session_input_session_admitted_seq_idx" }, { name: "session_input_session_admitted_seq_idx" },
{ name: "session_input_session_pending_delivery_seq_idx" }, { name: "session_input_session_pending_compaction_idx" },
{ name: "session_input_session_pending_type_delivery_seq_idx" },
{ name: "session_input_session_promoted_seq_idx" }, { name: "session_input_session_promoted_seq_idx" },
{ name: "session_message_session_seq_idx" }, { name: "session_message_session_seq_idx" },
{ name: "session_message_session_time_created_id_idx" }, { name: "session_message_session_time_created_id_idx" },
@ -132,6 +159,39 @@ describe("DatabaseMigration", () => {
) )
}) })
test("separates existing fork provenance from subagent hierarchy", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`)
yield* db.run(
sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
)
yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`)
yield* db.run(
sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`,
)
yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration])
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`),
).toEqual({
parent_id: null,
fork_session_id: "ses_source",
fork_message_id: "msg_boundary",
})
expect(
yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`),
).toEqual({
parent_id: null,
fork_session_id: null,
fork_message_id: null,
})
}),
)
})
test("renames instruction state without losing rows or durable updates", async () => { test("renames instruction state without losing rows or durable updates", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {
@ -295,7 +355,7 @@ describe("DatabaseMigration", () => {
sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`, sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
) )
yield* db.run( yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', '{}', 'steer', 9, 1)`, sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'prompt', '{}', 'steer', 9, 1)`,
) )
yield* db.run( yield* db.run(
sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`, sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
@ -358,6 +418,37 @@ describe("DatabaseMigration", () => {
) )
}) })
test("preserves admitted prompts while generalizing the durable inbox", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(
sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
)
yield* db.run(
sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
)
yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
expect(
yield* db.all(
sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
),
).toEqual([
{
id: "input",
type: "prompt",
prompt: '{"text":"hello"}',
delivery: "steer",
admitted_seq: 4,
promoted_seq: null,
},
])
}),
)
})
test("resets incompatible projected Session messages before adding sequence order", async () => { test("resets incompatible projected Session messages before adding sequence order", async () => {
await run( await run(
Effect.gen(function* () { Effect.gen(function* () {

View file

@ -0,0 +1,26 @@
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
CallToolRequestSchema,
GetPromptRequestSchema,
ListPromptsRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } })
server.setRequestHandler(ListToolsRequestSchema, async () => {
if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100)
return { tools: [{ name: "slow", inputSchema: { type: "object" } }] }
})
server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] }))
server.setRequestHandler(CallToolRequestSchema, async () => {
await Bun.sleep(100)
return { content: [] }
})
server.setRequestHandler(GetPromptRequestSchema, async () => {
await Bun.sleep(100)
return { messages: [] }
})
await server.connect(new StdioServerTransport())

View file

@ -71,9 +71,9 @@ const it = testEffect(
describe("MCP errors", () => { describe("MCP errors", () => {
test("expose useful messages", () => { test("expose useful messages", () => {
expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo") expect(new MCP.NotFoundError({ server: MCP.ServerName.make("demo") }).message).toBe("MCP server not found: demo")
expect(new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message).toBe( expect(
"failed", new MCP.ToolCallError({ server: MCP.ServerName.make("demo"), tool: "search", message: "failed" }).message,
) ).toBe("failed")
expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo") expect(new MCPClient.NeedsAuthError({ server: "demo" }).message).toBe("MCP server requires authentication: demo")
expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline") expect(new MCPClient.ConnectError({ server: "demo", message: "offline" }).message).toBe("offline")
}) })
@ -177,6 +177,70 @@ test("retains output schemas across paginated MCP discovery", async () => {
]) ])
}) })
test("applies the configured MCP catalog timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"catalog-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
environment: { MCP_TIMEOUT_TARGET: "catalog" },
timeout: new ConfigMCP.Timeout({ catalog: 10 }),
}),
import.meta.dir,
)
return yield* connection.tools()
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
test("applies the configured MCP execution timeout", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"execution-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.callTool({ name: "slow" })
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
test("applies the configured MCP execution timeout to prompts", async () => {
const result = Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
const connection = yield* MCPClient.connect(
"prompt-timeout",
new ConfigMCP.Local({
type: "local",
command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")],
timeout: new ConfigMCP.Timeout({ execution: 10 }),
}),
import.meta.dir,
)
return yield* connection.prompt({ name: "slow" })
}),
),
)
await expect(result).rejects.toThrow("Request timed out")
})
it.effect("advertises MCP output schemas to Code Mode", () => it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
@ -232,7 +296,7 @@ it.effect("does not call MCP when permission is blocked", () =>
Effect.gen(function* () { Effect.gen(function* () {
calls = 0 calls = 0
assertion = yield* Deferred.make<PermissionV2.AssertInput>() assertion = yield* Deferred.make<PermissionV2.AssertInput>()
decision = Effect.fail(new PermissionV2.BlockedError({ rules: [] })) decision = Effect.fail(new PermissionV2.BlockedError({ rules: [], permission: "demo_search", resources: ["*"] }))
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service
yield* waitForTool(registry, "execute") yield* waitForTool(registry, "execute")

View file

@ -41,8 +41,8 @@ describe("SkillPlugin.Plugin", () => {
expect(skills).toContainEqual( expect(skills).toContainEqual(
expect.objectContaining({ expect.objectContaining({
name: "customize-opencode", name: "opencode",
description: expect.stringContaining("opencode's own configuration"), description: expect.stringContaining("any question about OpenCode itself"),
}), }),
) )
expect(skills).toContainEqual( expect(skills).toContainEqual(

View file

@ -75,7 +75,7 @@ const it = testEffect(
) )
describe("SessionV2.compact", () => { describe("SessionV2.compact", () => {
it.effect("manually compacts the active session context", () => it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests = [] requests = []
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
@ -95,13 +95,22 @@ describe("SessionV2.compact", () => {
inputID: messageID, inputID: messageID,
}) })
yield* session.compact({ sessionID: created.id }) expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.CompactionConflictError",
inputID: messageID,
})
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(requests).toHaveLength(1) expect(second.id).toBe(first.id)
expect(JSON.stringify(requests[0]?.messages)).toContain("Please compact this session history.") expect(requests).toHaveLength(0)
expect(yield* session.context(created.id)).toMatchObject([ expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toMatchObject({
{ type: "compaction", reason: "manual", summary: "manual session summary", recent: "" }, type: "compaction",
]) status: "queued",
reason: "manual",
summary: "",
recent: "",
})
}), }),
) )
}) })

View file

@ -19,7 +19,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { DateTime, Effect, Layer, Stream } from "effect" import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm" import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
@ -68,11 +68,30 @@ test("compaction describes tool media without embedding base64", () => {
expect(serialized).not.toContain(base64) expect(serialized).not.toContain(base64)
}) })
test("compaction prompt requires the checkpoint headings in order", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
"## Objective",
"## Important Details",
"## Work State",
"## Next Move",
])
expect(prompt).toContain("one or two brief sentences")
expect(prompt).toContain("constraints/preferences, decisions and why")
expect(prompt).toContain("Completed:")
expect(prompt).toContain("Active:")
expect(prompt).toContain("Blocked:")
expect(prompt).toContain("immediate concrete action")
expect(prompt).toContain("next action if known")
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("manual compaction summarizes short context instead of no-op", () => it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () { Effect.gen(function* () {
requests = [] requests = []
const db = (yield* Database.Service).db const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service const compaction = yield* SessionCompaction.Service
const events = yield* EventV2.Service
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const sessionID = SessionV2.ID.make("ses_manual_compaction") const sessionID = SessionV2.ID.make("ses_manual_compaction")
const userMessage = { const userMessage = {
@ -108,7 +127,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
), ),
) )
const delta = yield* events
.subscribe(SessionEvent.Compaction.Delta)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true) expect(yield* compaction.compactManual({ session, messages: [userMessage] })).toBe(true)
expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
expect(requests).toHaveLength(1) expect(requests).toHaveLength(1)
expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")

View file

@ -206,7 +206,8 @@ describe("SessionV2.create", () => {
const forkContext = yield* session.context(forked.id) const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked).toMatchObject({ parentID: parent.id, title: "Parent (fork #1)" }) expect(forked).toMatchObject({ title: "Parent (fork #1)", fork: { sessionID: parent.id } })
expect(forked.parentID).toBeUndefined()
expect(forkContext).toMatchObject([ expect(forkContext).toMatchObject([
{ type: "user", text: "First" }, { type: "user", text: "First" },
{ type: "synthetic", text: "parent note", sessionID: forked.id }, { type: "synthetic", text: "parent note", sessionID: forked.id },
@ -264,6 +265,7 @@ describe("SessionV2.create", () => {
const context = yield* session.context(forked.id) const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id))) const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
expect(forked.fork).toEqual({ sessionID: parent.id, messageID: second.id })
expect(context).toMatchObject([{ text: "First" }]) expect(context).toMatchObject([{ text: "First" }])
expect(context[0]?.id).not.toBe(first.id) expect(context[0]?.id).not.toBe(first.id)
expect(history[0]).toMatchObject({ data: { from: second.id } }) expect(history[0]).toMatchObject({ data: { from: second.id } })

View file

@ -0,0 +1,88 @@
import { describe, expect, test } from "bun:test"
import {
AuthenticationReason,
ContentPolicyReason,
InvalidProviderOutputReason,
InvalidRequestReason,
LLMError,
NoRouteReason,
ModelID,
ProviderID,
ProviderInternalReason,
QuotaExceededReason,
RateLimitReason,
TransportReason,
UnknownProviderReason,
ToolFailure,
} from "@opencode-ai/llm"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
describe("toSessionError", () => {
test("maps every LLM reason to the open wire type", () => {
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
type: "provider.rate-limit",
message: "rate",
})
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
"provider.auth",
)
expect(toSessionError(llm(new QuotaExceededReason({ message: "quota" }))).type).toBe("provider.quota")
expect(toSessionError(llm(new ContentPolicyReason({ message: "blocked" }))).type).toBe("provider.content-filter")
expect(toSessionError(llm(new TransportReason({ message: "transport" }))).type).toBe("provider.transport")
expect(toSessionError(llm(new ProviderInternalReason({ message: "internal", status: 500 }))).type).toBe(
"provider.internal",
)
expect(toSessionError(llm(new InvalidProviderOutputReason({ message: "output" }))).type).toBe(
"provider.invalid-output",
)
expect(toSessionError(llm(new InvalidRequestReason({ message: "request" }))).type).toBe("provider.invalid-request")
expect(
toSessionError(
llm(
new NoRouteReason({
route: "route",
provider: ProviderID.make("provider"),
model: ModelID.make("model"),
}),
),
).type,
).toBe("provider.no-route")
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
})
test("preserves the permission rejection type without exposing internal fields", () => {
const blocked = new PermissionV2.BlockedError({ rules: [], permission: "external_directory", resources: [] })
expect(toSessionError(blocked)).toEqual({
type: "permission.rejected",
message: "Permission denied: external_directory",
})
expect(toSessionError(new ToolFailure({ message: blocked.message, error: blocked }))).toEqual({
type: "permission.rejected",
message: "Permission denied: external_directory",
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
llm(new ProviderInternalReason({ message: "internal", status: 500 })),
llm(new TransportReason({ message: "transport" })),
]
const ineligible = [
llm(new AuthenticationReason({ message: "auth", kind: "invalid" })),
llm(new QuotaExceededReason({ message: "quota" })),
llm(new ContentPolicyReason({ message: "blocked" })),
llm(new InvalidProviderOutputReason({ message: "output" })),
llm(new InvalidRequestReason({ message: "request" })),
llm(new NoRouteReason({ route: "route", provider: ProviderID.make("provider"), model: ModelID.make("model") })),
llm(new UnknownProviderReason({ message: "unknown" })),
]
expect(eligible.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
})
})

View file

@ -0,0 +1,36 @@
import { describe, expect, test } from "bun:test"
import { LLMError, TransportReason } from "@opencode-ai/llm"
import { terminal } from "@opencode-ai/core/session/execution/local"
import { UserInterruptedError } from "@opencode-ai/core/session/error"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { Effect, Exit } from "effect"
describe("SessionExecutionLocal lifecycle", () => {
test("classifies success and typed failure terminals", () => {
expect(terminal(Exit.succeed(undefined))).toEqual({ type: "succeeded" })
expect(
terminal(
Exit.fail(
new LLMError({
module: "test",
method: "stream",
reason: new TransportReason({ message: "Disconnected" }),
}),
),
),
).toEqual({ type: "failed", error: { type: "provider.transport", message: "Disconnected" } })
const storage = new ToolOutputStore.StorageError({ operation: "encode", cause: new Error("invalid output") })
expect(terminal(Exit.fail(storage))).toEqual({
type: "failed",
error: { type: "unknown", message: storage.message },
})
})
test("defaults owner-scope interruption to shutdown and preserves explicit reasons", () => {
const interrupted = Effect.runSyncExit(Effect.interrupt)
expect(terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
expect(terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
expect(terminal(interrupted, "superseded")).toEqual({ type: "interrupted", reason: "superseded" })
expect(terminal(Exit.fail(new UserInterruptedError()))).toEqual({ type: "interrupted", reason: "user" })
})
})

View file

@ -101,7 +101,7 @@ describe("SessionProjector", () => {
}) })
yield* events.publish(SessionEvent.RevertEvent.Committed, { yield* events.publish(SessionEvent.RevertEvent.Committed, {
sessionID, sessionID,
messageID: boundary, to: boundary,
}) })
expect( expect(
(yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id), (yield* db.select({ id: SessionMessageTable.id }).from(SessionMessageTable).all()).map((row) => row.id),
@ -437,6 +437,73 @@ describe("SessionProjector", () => {
}), }),
) )
it.effect("projects retry state and clears it at the next step or execution terminal", () =>
Effect.gen(function* () {
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: "test",
directory: "/project",
title: "test",
version: "test",
})
.run()
.pipe(Effect.orDie)
const events = yield* EventV2.Service
const first = SessionMessage.ID.make("msg_retry_first")
const second = SessionMessage.ID.make("msg_retry_second")
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: first, agent: "build", model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: first,
attempt: 2,
at: 2_000,
error: { type: "provider.transport", message: "Disconnected" },
})
const decode = (row: typeof SessionMessageTable.$inferSelect) =>
Schema.decodeUnknownSync(SessionMessage.Message)({ ...row.data, id: row.id, type: row.type })
const firstRow = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, first))
.get()
.pipe(Effect.orDie)
const projected = firstRow ?? (yield* Effect.die(new Error("Missing retry projection")))
expect(decode(projected)).toMatchObject({
retry: { attempt: 2, at: DateTime.makeUnsafe(2_000), error: { type: "provider.transport" } },
})
yield* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID: second, agent: "build", model })
yield* events.publish(SessionEvent.RetryScheduled, {
sessionID,
assistantMessageID: second,
attempt: 3,
at: 6_000,
error: { type: "provider.internal", message: "Unavailable" },
})
yield* events.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
expect(decode(rows[0])).not.toHaveProperty("retry")
expect(decode(rows[1])).not.toHaveProperty("retry")
}),
)
it.effect("updates only the newest incomplete assistant projection", () => it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () { Effect.gen(function* () {
const { db } = yield* Database.Service const { db } = yield* Database.Service
@ -530,7 +597,7 @@ describe("SessionProjector", () => {
yield* service.publish(SessionEvent.Text.Started, { yield* service.publish(SessionEvent.Text.Started, {
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"), assistantMessageID: SessionMessage.ID.make("msg_assistant_completed"),
textID: "text-stale", ordinal: 0,
}) })
const rows = yield* db const rows = yield* db
@ -549,7 +616,7 @@ describe("SessionProjector", () => {
type: "assistant", type: "assistant",
agent: "build", agent: "build",
model, model,
content: [SessionMessage.AssistantText.make({ type: "text", id: "text-stale", text: "" })], content: [SessionMessage.AssistantText.make({ type: "text", text: "" })],
time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) }, time: { created: DateTime.makeUnsafe(1), completed: DateTime.makeUnsafe(2) },
}), }),
SessionMessage.Assistant.make({ SessionMessage.Assistant.make({

View file

@ -246,7 +246,9 @@ describe("SessionV2.prompt", () => {
mention: { start: 8, end: 17, text: "[Image 1]" }, mention: { start: 8, end: 17, text: "[Image 1]" },
}, },
]) ])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) const stored = yield* admitted(message.id)
expect(stored?.type).toBe("prompt")
if (stored?.type === "prompt") expect(stored.prompt.files).toEqual(message.prompt.files)
}), }),
) )
@ -275,31 +277,35 @@ describe("SessionV2.prompt", () => {
source: { type: "uri", uri: sourceUri.href }, source: { type: "uri", uri: sourceUri.href },
name: "main.ts", name: "main.ts",
}) })
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8").replace(/\r$/, "")).toBe( expect(
'import { describe, expect } from "bun:test"', Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64")
) .toString("utf8")
.replace(/\r$/, ""),
).toBe('import { describe, expect } from "bun:test"')
}), }),
) )
it.effect("rejects directories as file attachments", () => it.effect("materializes directories as directory attachments", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const uri = pathToFileURL(import.meta.dir).href const uri = pathToFileURL(import.meta.dir).href
const error = yield* session const message = yield* session.prompt({
.prompt({ sessionID,
sessionID, prompt: { text: "Inspect this", files: [{ uri, name: "source" }] },
prompt: { text: "Inspect this", files: [{ uri, name: "source" }] }, resume: false,
resume: false,
})
.pipe(Effect.flip)
expect(error).toMatchObject({
_tag: "Session.AttachmentError",
uri,
message: `Attachment is not a file: ${uri}`,
}) })
expect(message.prompt.files).toHaveLength(1)
expect(message.prompt.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.prompt.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}), }),
) )
@ -332,7 +338,8 @@ describe("SessionV2.prompt", () => {
name: "image.png", name: "image.png",
}, },
]) ])
expect((yield* admitted(message.id))?.prompt.files).toEqual(message.prompt.files) const stored = yield* admitted(message.id)
expect(stored?.type === "prompt" ? stored.prompt.files : undefined).toEqual(message.prompt.files)
}), }),
) )
@ -565,7 +572,12 @@ describe("SessionV2.prompt", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const session = yield* SessionV2.Service const session = yield* SessionV2.Service
const events = yield* EventV2.Service const events = yield* EventV2.Service
yield* session.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Promote once" }), resume: false }) yield* session.prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Promote once" }),
resume: false,
})
yield* Effect.all( yield* Effect.all(
[SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)], [SessionInput.promoteSteers(db, events, sessionID), SessionInput.promoteSteers(db, events, sessionID)],
@ -677,7 +689,12 @@ describe("SessionV2.prompt", () => {
.pipe(Effect.orDie) .pipe(Effect.orDie)
const failure = yield* session const failure = yield* session
.prompt({ id: messageID, sessionID, prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }), resume: false }) .prompt({
id: messageID,
sessionID,
prompt: PromptInput.Prompt.make({ text: "Conflicting prompt" }),
resume: false,
})
.pipe(Effect.flip) .pipe(Effect.flip)
expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID }) expect(failure).toMatchObject({ _tag: "Session.PromptConflictError", sessionID, messageID })

View file

@ -0,0 +1,62 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { testEffect } from "./lib/effect"
const projects = Layer.succeed(
ProjectV2.Service,
ProjectV2.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: ProjectV2.ID.global, directory }),
directories: () => Effect.succeed([]),
commit: () => Effect.void,
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionV2.node]),
[
[ProjectV2.node, projects],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("SessionV2.remove", () => {
it.effect("removes a session and its children", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const parent = yield* session.create({ location })
const child = yield* session.create({ parentID: parent.id })
yield* session.remove(parent.id)
expect((yield* session.list()).data).toEqual([])
expect(yield* Effect.result(session.get(parent.id))).toMatchObject({ _tag: "Failure" })
expect(yield* Effect.result(session.get(child.id))).toMatchObject({ _tag: "Failure" })
}),
)
it.effect("fails when the session does not exist", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
const sessionID = SessionV2.ID.make("ses_missing")
expect(yield* Effect.result(session.remove(sessionID))).toMatchObject({
_tag: "Failure",
failure: { _tag: "Session.NotFoundError", sessionID },
})
}),
)
})

View file

@ -104,8 +104,10 @@ describe("SessionRunCoordinator", () => {
Effect.gen(function* () { Effect.gen(function* () {
const failure = new Error("failed") const failure = new Error("failed")
const defect = new Error("defect") const defect = new Error("defect")
const settled: Exit.Exit<void, Error>[] = []
const coordinator = yield* SessionRunCoordinator.make({ const coordinator = yield* SessionRunCoordinator.make({
drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)), drain: (key: string) => (key === "failure" ? Effect.fail(failure) : Effect.die(defect)),
settled: (_key, exit) => Effect.sync(() => void settled.push(exit)),
}) })
const failed = yield* coordinator.run("failure").pipe(Effect.exit) const failed = yield* coordinator.run("failure").pipe(Effect.exit)
@ -115,6 +117,25 @@ describe("SessionRunCoordinator", () => {
const died = yield* coordinator.run("defect").pipe(Effect.exit) const died = yield* coordinator.run("defect").pipe(Effect.exit)
expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue() expect(Exit.isFailure(died) && Cause.hasDies(died.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([]) expect(Array.from(yield* coordinator.active)).toEqual([])
expect(settled).toHaveLength(2)
}),
),
)
it.effect("preserves settlement hook defects while releasing ownership", () =>
Effect.scoped(
Effect.gen(function* () {
const defect = new Error("terminal publication failed")
const coordinator = yield* SessionRunCoordinator.make({
drain: () => Effect.void,
settled: () => Effect.die(defect),
})
const exit = yield* coordinator.run("session").pipe(Effect.exit)
expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBe(defect)
expect(yield* coordinator.active).toEqual(new Set())
}), }),
), ),
) )
@ -209,8 +230,41 @@ describe("SessionRunCoordinator", () => {
it.effect("does nothing when interrupted while idle", () => it.effect("does nothing when interrupted while idle", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make({ drain: () => Effect.void }) const reasons: Array<string | undefined> = []
yield* coordinator.interrupt("session") const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () => Effect.void,
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
})
yield* coordinator.interrupt("session", "user")
yield* coordinator.run("session")
expect(reasons).toEqual([undefined])
}),
),
)
it.effect("does not attach a late interrupt reason after terminal settlement starts", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () => Effect.void,
settled: (_key, _exit, reason) =>
Deferred.succeed(settling, undefined).pipe(
Effect.andThen(Deferred.await(release)),
Effect.andThen(Effect.sync(() => void reasons.push(reason))),
),
})
const run = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(settling)
yield* coordinator.interrupt("session", "user")
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(run)
yield* coordinator.run("session")
expect(reasons).toEqual([undefined, undefined])
}), }),
), ),
) )
@ -221,25 +275,28 @@ describe("SessionRunCoordinator", () => {
const started = yield* Deferred.make<void>() const started = yield* Deferred.make<void>()
const interrupted = yield* Deferred.make<void>() const interrupted = yield* Deferred.make<void>()
let runs = 0 let runs = 0
const coordinator = yield* SessionRunCoordinator.make({ const reasons: Array<string | undefined> = []
const coordinator = yield* SessionRunCoordinator.make<string, never, string>({
drain: () => drain: () =>
Effect.sync(() => ++runs).pipe( Effect.sync(() => ++runs).pipe(
Effect.andThen(Deferred.succeed(started, undefined)), Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never), Effect.andThen(Effect.never),
Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)), Effect.onInterrupt(() => Deferred.succeed(interrupted, undefined)),
), ),
settled: (_key, _exit, reason) => Effect.sync(() => void reasons.push(reason)),
}) })
const resumed = yield* coordinator.run("session").pipe(Effect.forkChild) const resumed = yield* coordinator.run("session").pipe(Effect.forkChild)
yield* Deferred.await(started) yield* Deferred.await(started)
yield* coordinator.wake("session") yield* coordinator.wake("session")
yield* coordinator.interrupt("session") yield* coordinator.interrupt("session", "user")
yield* Deferred.await(interrupted) yield* Deferred.await(interrupted)
const exit = yield* Fiber.await(resumed) const exit = yield* Fiber.await(resumed)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue() expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBeTrue()
expect(Array.from(yield* coordinator.active)).toEqual([]) expect(Array.from(yield* coordinator.active)).toEqual([])
expect(runs).toBe(1) expect(runs).toBe(1)
expect(reasons).toEqual(["user"])
}), }),
), ),
) )
@ -252,6 +309,7 @@ describe("SessionRunCoordinator", () => {
const cleanupGate = yield* Deferred.make<void>() const cleanupGate = yield* Deferred.make<void>()
const secondStarted = yield* Deferred.make<void>() const secondStarted = yield* Deferred.make<void>()
let runs = 0 let runs = 0
let starts = 0
const coordinator = yield* SessionRunCoordinator.make({ const coordinator = yield* SessionRunCoordinator.make({
drain: () => drain: () =>
Effect.sync(() => ++runs).pipe( Effect.sync(() => ++runs).pipe(
@ -266,6 +324,7 @@ describe("SessionRunCoordinator", () => {
: Deferred.succeed(secondStarted, undefined), : Deferred.succeed(secondStarted, undefined),
), ),
), ),
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
}) })
yield* coordinator.wake("session") yield* coordinator.wake("session")
@ -278,6 +337,7 @@ describe("SessionRunCoordinator", () => {
yield* Deferred.await(secondStarted) yield* Deferred.await(secondStarted)
expect(runs).toBe(2) expect(runs).toBe(2)
expect(starts).toBe(2)
}), }),
), ),
) )
@ -399,6 +459,7 @@ describe("SessionRunCoordinator", () => {
const gate = yield* Deferred.make<void>() const gate = yield* Deferred.make<void>()
const idle = yield* Deferred.make<void>() const idle = yield* Deferred.make<void>()
let drains = 0 let drains = 0
let starts = 0
const settled: Exit.Exit<void, never>[] = [] const settled: Exit.Exit<void, never>[] = []
const coordinator = yield* SessionRunCoordinator.make<string, never>({ const coordinator = yield* SessionRunCoordinator.make<string, never>({
drain: () => drain: () =>
@ -410,6 +471,7 @@ describe("SessionRunCoordinator", () => {
), ),
Effect.asVoid, Effect.asVoid,
), ),
started: () => Effect.sync(() => starts++).pipe(Effect.asVoid),
settled: (_key, exit) => settled: (_key, exit) =>
Effect.sync(() => void settled.push(exit)).pipe( Effect.sync(() => void settled.push(exit)).pipe(
Effect.andThen(Deferred.succeed(idle, undefined)), Effect.andThen(Deferred.succeed(idle, undefined)),
@ -424,6 +486,7 @@ describe("SessionRunCoordinator", () => {
yield* Deferred.await(idle) yield* Deferred.await(idle)
expect(drains).toBe(2) expect(drains).toBe(2)
expect(starts).toBe(1)
expect(settled).toHaveLength(1) expect(settled).toHaveLength(1)
expect(Exit.isSuccess(settled[0]!)).toBe(true) expect(Exit.isSuccess(settled[0]!)).toBe(true)
}), }),

View file

@ -27,17 +27,14 @@ describe("toLLMMessages", () => {
const messages = toLLMMessages( const messages = toLLMMessages(
[ [
assistant("empty", []), assistant("empty", []),
assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", id: "empty", text: "" })]), assistant("empty-text", [SessionMessage.AssistantText.make({ type: "text", text: "" })]),
assistant("empty-reasoning", [ assistant("empty-reasoning", [SessionMessage.AssistantReasoning.make({ type: "reasoning", text: "" })]),
SessionMessage.AssistantReasoning.make({ type: "reasoning", id: "empty-reasoning", text: "" }), assistant("text", [SessionMessage.AssistantText.make({ type: "text", text: "Partial" })]),
]),
assistant("text", [SessionMessage.AssistantText.make({ type: "text", id: "text", text: "Partial" })]),
assistant("reasoning", [ assistant("reasoning", [
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning",
text: "", text: "",
providerMetadata: { anthropic: { signature: "sig_1" } }, state: { signature: "sig_1" },
}), }),
]), ]),
], ],
@ -109,6 +106,7 @@ describe("toLLMMessages", () => {
SessionMessage.Compaction.make({ SessionMessage.Compaction.make({
id: id("compaction"), id: id("compaction"),
type: "compaction", type: "compaction",
status: "completed",
reason: "auto", reason: "auto",
summary: "Earlier work", summary: "Earlier work",
recent: "Recent work", recent: "Recent work",
@ -220,6 +218,35 @@ Recent work
]) ])
}) })
test("lowers directory attachments as directory context", () => {
const directory = FileAttachment.make({
data: Base64.make(Buffer.from("lib/\nindex.ts").toString("base64")),
mime: "application/x-directory",
source: { type: "uri", uri: "file:///project/src" },
name: "src/",
})
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-directory"),
type: "user",
text: "Review this directory",
files: [directory],
time: { created },
}),
],
model,
)
expect(messages).toHaveLength(2)
expect(messages[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "Attached directory: src/\n\nlib/\nindex.ts" }],
metadata: { attachment: { source: directory.source, name: "src/" } },
})
expect(messages[1]?.content).toEqual([{ type: "text", text: "Review this directory" }])
})
test("uses materialized image data as provider media and drops unsupported attachments", () => { test("uses materialized image data as provider media and drops unsupported attachments", () => {
const data = Base64.make("AAECAw==") const data = Base64.make("AAECAw==")
const messages = toLLMMessages( const messages = toLLMMessages(
@ -258,12 +285,11 @@ Recent work
agent: "build", agent: "build",
model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }, model: { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") },
content: [ content: [
SessionMessage.AssistantText.make({ type: "text", id: "text-1", text: "Checking" }), SessionMessage.AssistantText.make({ type: "text", text: "Checking" }),
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning-1",
text: "Think", text: "Think",
providerMetadata: { anthropic: { signature: "sig_1" } }, state: { signature: "sig_1" },
}), }),
SessionMessage.AssistantTool.make({ SessionMessage.AssistantTool.make({
type: "tool", type: "tool",
@ -308,11 +334,9 @@ Recent work
type: "tool", type: "tool",
id: "hosted", id: "hosted",
name: "web_search", name: "web_search",
provider: { executed: true,
executed: true, providerState: { continuation: "hosted-call" },
metadata: { fake: { continuation: "hosted-call" } }, providerResultState: { continuation: "hosted-result" },
resultMetadata: { fake: { continuation: "hosted-result" } },
},
state: SessionMessage.ToolStateCompleted.make({ state: SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
input: { query: "Effect" }, input: { query: "Effect" },
@ -325,7 +349,8 @@ Recent work
type: "tool", type: "tool",
id: "hosted-failed", id: "hosted-failed",
name: "write", name: "write",
provider: { executed: true, metadata: { fake: { continuation: "failed" } } }, executed: true,
providerState: { continuation: "failed" },
state: SessionMessage.ToolStateError.make({ state: SessionMessage.ToolStateError.make({
status: "error", status: "error",
input: { path: "README.md" }, input: { path: "README.md" },
@ -345,7 +370,7 @@ Recent work
expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"]) expect(messages.map((message) => message.role)).toEqual(["assistant", "tool"])
expect(messages[0]?.content).toEqual([ expect(messages[0]?.content).toEqual([
{ type: "text", text: "Checking" }, { type: "text", text: "Checking" },
{ type: "reasoning", text: "Think", providerMetadata: { anthropic: { signature: "sig_1" } } }, { type: "reasoning", text: "Think", providerMetadata: { provider: { signature: "sig_1" } } },
{ type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } }, { type: "tool-call", id: "pending", name: "read", input: { path: "README.md" } },
{ type: "tool-call", id: "running", name: "read", input: { path: "README.md" } }, { type: "tool-call", id: "running", name: "read", input: { path: "README.md" } },
{ {
@ -360,14 +385,14 @@ Recent work
name: "web_search", name: "web_search",
input: { query: "Effect" }, input: { query: "Effect" },
providerExecuted: true, providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-call" } }, providerMetadata: { provider: { continuation: "hosted-call" } },
}, },
{ {
type: "tool-result", type: "tool-result",
id: "hosted", id: "hosted",
name: "web_search", name: "web_search",
providerExecuted: true, providerExecuted: true,
providerMetadata: { fake: { continuation: "hosted-result" } }, providerMetadata: { provider: { continuation: "hosted-result" } },
result: { type: "text", value: "Found it" }, result: { type: "text", value: "Found it" },
}, },
{ {
@ -376,14 +401,14 @@ Recent work
name: "write", name: "write",
input: { path: "README.md" }, input: { path: "README.md" },
providerExecuted: true, providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } }, providerMetadata: { provider: { continuation: "failed" } },
}, },
{ {
type: "tool-result", type: "tool-result",
id: "hosted-failed", id: "hosted-failed",
name: "write", name: "write",
providerExecuted: true, providerExecuted: true,
providerMetadata: { fake: { continuation: "failed" } }, providerMetadata: { provider: { continuation: "failed" } },
result: { result: {
type: "error", type: "error",
value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} }, value: { error: { type: "unknown", message: "Denied" }, content: [], structured: {} },
@ -417,9 +442,8 @@ Recent work
content: [ content: [
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning-openai",
text: "Think", text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, state: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" },
}), }),
], ],
time: { created, completed: created }, time: { created, completed: created },
@ -432,7 +456,7 @@ Recent work
{ {
type: "reasoning", type: "reasoning",
text: "Think", text: "Think",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } }, providerMetadata: { provider: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
}, },
]) ])
}) })
@ -448,19 +472,16 @@ Recent work
content: [ content: [
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning-failed",
text: "Partial thought", text: "Partial thought",
providerMetadata: { openai: { itemId: "rs_failed", reasoningEncryptedContent: null } }, state: { itemId: "rs_failed", reasoningEncryptedContent: null },
}), }),
SessionMessage.AssistantTool.make({ SessionMessage.AssistantTool.make({
type: "tool", type: "tool",
id: "hosted-failed", id: "hosted-failed",
name: "web_search", name: "web_search",
provider: { executed: true,
executed: true, providerState: { itemId: "call_failed" },
metadata: { openai: { itemId: "call_failed" } }, providerResultState: { itemId: "result_failed" },
resultMetadata: { openai: { itemId: "result_failed" } },
},
state: SessionMessage.ToolStateError.make({ state: SessionMessage.ToolStateError.make({
status: "error", status: "error",
input: { query: "Effect" }, input: { query: "Effect" },
@ -520,19 +541,16 @@ Recent work
content: [ content: [
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning-old-model",
text: "Visible thought", text: "Visible thought",
providerMetadata: { anthropic: { signature: "sig_old" } }, state: { signature: "sig_old" },
}), }),
SessionMessage.AssistantTool.make({ SessionMessage.AssistantTool.make({
type: "tool", type: "tool",
id: "hosted-old-model", id: "hosted-old-model",
name: "web_search", name: "web_search",
provider: { executed: true,
executed: true, providerState: { itemId: "hosted-old-model" },
metadata: { openai: { itemId: "hosted-old-model" } }, providerResultState: { itemId: "hosted-old-model" },
resultMetadata: { openai: { itemId: "hosted-old-model" } },
},
state: SessionMessage.ToolStateCompleted.make({ state: SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
input: { query: "Effect" }, input: { query: "Effect" },
@ -546,11 +564,9 @@ Recent work
type: "tool", type: "tool",
id: "local-old-model", id: "local-old-model",
name: "read", name: "read",
provider: { executed: false,
executed: false, providerState: { call: "old" },
metadata: { fake: { call: "old" } }, providerResultState: { result: "old" },
resultMetadata: { fake: { result: "old" } },
},
state: SessionMessage.ToolStateCompleted.make({ state: SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
input: { path: "README.md" }, input: { path: "README.md" },
@ -620,9 +636,8 @@ Recent work
content: [ content: [
SessionMessage.AssistantReasoning.make({ SessionMessage.AssistantReasoning.make({
type: "reasoning", type: "reasoning",
id: "reasoning-alias",
text: "Visible thought", text: "Visible thought",
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, state: { reasoningEncryptedContent: "encrypted" },
}), }),
], ],
time: { created, completed: created }, time: { created, completed: created },
@ -635,7 +650,7 @@ Recent work
{ {
type: "reasoning", type: "reasoning",
text: "Visible thought", text: "Visible thought",
providerMetadata: { openai: { reasoningEncryptedContent: "encrypted" } }, providerMetadata: { provider: { reasoningEncryptedContent: "encrypted" } },
}, },
]) ])
}) })

View file

@ -45,6 +45,7 @@ const capture = () => {
id: ModelV2.ID.make("model"), id: ModelV2.ID.make("model"),
providerID: ProviderV2.ID.make("provider"), providerID: ProviderV2.ID.make("provider"),
}, },
provider: "openai",
}), }),
} }
} }
@ -88,7 +89,7 @@ test("local tool success serializes media base64 once and reconstructs from stru
}) })
}) })
test("provider-executed success retains its compatibility result", async () => { test("provider-executed success retains its raw provider result", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolCall({ ...call, providerExecuted: true })))
await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true }))) await Effect.runPromise(publisher.publish(LLMEvent.toolResult({ ...result, providerExecuted: true })))
@ -96,6 +97,19 @@ test("provider-executed success retains its compatibility result", async () => {
expect(success?.data).toHaveProperty("result") expect(success?.data).toHaveProperty("result")
}) })
test("provider state uses the route provider instead of the catalog provider", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
publisher.publish(
LLMEvent.reasoningStart({ id: "reasoning", providerMetadata: { openai: { itemId: "reasoning" } } }),
),
)
expect(published.find((event) => event.type === "session.reasoning.started.1")?.data).toMatchObject({
state: { itemId: "reasoning" },
})
})
test("binary failure emits no success event", async () => { test("binary failure emits no success event", async () => {
const { published, publisher } = capture() const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(call)) await Effect.runPromise(publisher.publish(call))
@ -112,7 +126,7 @@ test("binary failure emits no success event", async () => {
expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true) expect(published.some((event) => event.type === "session.tool.failed.1")).toBe(true)
}) })
test("old success event data containing result still decodes", () => { test("success event data can carry a provider-executed result", () => {
const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({ const decoded = Schema.decodeUnknownSync(SessionEvent.Tool.Success.data)({
sessionID, sessionID,
assistantMessageID: SessionMessage.ID.create(), assistantMessageID: SessionMessage.ID.create(),
@ -120,7 +134,7 @@ test("old success event data containing result still decodes", () => {
structured: { type: "media", mime: "image/png" }, structured: { type: "media", mime: "image/png" },
content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }], content: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }],
result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] }, result: { type: "content", value: [{ type: "file", uri: `data:image/png;base64,${base64}`, mime: "image/png" }] },
provider: { executed: false }, executed: true,
}) })
expect(decoded.result).toMatchObject({ type: "content" }) expect(decoded.result).toMatchObject({ type: "content" })
}) })
@ -133,3 +147,40 @@ test("step finish records settlement without publishing step ended", async () =>
expect(published.some((event) => event.type === "step.ended.2")).toBe(false) expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" }) expect(publisher.stepSettlement()).toMatchObject({ finish: "stop" })
}) })
test("content-filter finish retains failure evidence until step closeout", async () => {
const { published, publisher } = capture()
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
await Effect.runPromise(publisher.publish(LLMEvent.stepFinish({ index: 0, reason: "content-filter" })))
expect(published.map((event) => event.type)).toEqual(["session.step.started.1"])
await Effect.runPromise(publisher.publishStepFailure())
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
expect(published.at(-1)?.data).toMatchObject({
error: { type: "provider.content-filter", message: "Provider blocked the response" },
})
expect(publisher.stepSettlement()).toBeUndefined()
})
test("content-filter finish preserves partial streamed text and never ends the step successfully", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "Partial" }),
LLMEvent.stepFinish({ index: 0, reason: "content-filter" }),
],
(event) => publisher.publish(event),
{ discard: true },
),
)
await Effect.runPromise(publisher.publishStepFailure())
expect(published.some((event) => event.type === "session.step.ended.1")).toBe(false)
expect(published.find((event) => event.type === "session.text.ended.1")?.data).toMatchObject({ text: "Partial" })
expect(published.find((event) => event.type === "session.step.failed.1")?.data).toMatchObject({
error: { type: "provider.content-filter" },
})
})

File diff suppressed because it is too large Load diff

View file

@ -76,9 +76,8 @@ describe("Tool.Progress", () => {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
callID, callID,
tool: "bash",
input: { command: "pwd" }, input: { command: "pwd" },
provider: { executed: false }, executed: false,
}) })
}) })
@ -104,7 +103,7 @@ describe("Tool.Progress", () => {
callID: "call-success", callID: "call-success",
structured: { phase: "done" }, structured: { phase: "done" },
content: content("complete"), content: content("complete"),
provider: { executed: false }, executed: false,
}) })
expect((yield* readAssistant).content[0]).toMatchObject({ expect((yield* readAssistant).content[0]).toMatchObject({
state: { status: "completed", structured: { phase: "done" }, content: content("complete") }, state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
@ -123,7 +122,7 @@ describe("Tool.Progress", () => {
assistantMessageID, assistantMessageID,
callID: "call-failed", callID: "call-failed",
error: { type: "unknown", message: "boom" }, error: { type: "unknown", message: "boom" },
provider: { executed: false }, executed: false,
}) })
expect((yield* readAssistant).content[1]).toMatchObject({ expect((yield* readAssistant).content[1]).toMatchObject({
state: { state: {

View file

@ -103,6 +103,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreIntegration.Ref, Integration.Ref], [coreIntegration.Ref, Integration.Ref],
[coreLocation.Ref, Location.Ref], [coreLocation.Ref, Location.Ref],
[coreLLM.ProviderMetadata, LLM.ProviderMetadata], [coreLLM.ProviderMetadata, LLM.ProviderMetadata],
[coreLLM.FinishReason, LLM.FinishReason],
[coreLLM.ToolTextContent, LLM.ToolTextContent], [coreLLM.ToolTextContent, LLM.ToolTextContent],
[coreLLM.ToolFileContent, LLM.ToolFileContent], [coreLLM.ToolFileContent, LLM.ToolFileContent],
[coreLLM.ToolContent, LLM.ToolContent], [coreLLM.ToolContent, LLM.ToolContent],
@ -137,7 +138,7 @@ test("Core reuses the canonical shared schemas", async () => {
[coreSessionInput.Delivery, SessionInput.Delivery], [coreSessionInput.Delivery, SessionInput.Delivery],
[coreSessionInput.Admitted, SessionInput.Admitted], [coreSessionInput.Admitted, SessionInput.Admitted],
[coreSessionMessage.ID, SessionMessage.ID], [coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.UnknownError, SessionMessage.UnknownError], [coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected], [coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
[coreSessionMessage.ModelSelected, SessionMessage.ModelSelected], [coreSessionMessage.ModelSelected, SessionMessage.ModelSelected],
[coreSessionMessage.User, SessionMessage.User], [coreSessionMessage.User, SessionMessage.User],
@ -183,7 +184,7 @@ test("Core reuses the canonical shared schemas", async () => {
test("shared record schemas construct and decode plain objects", () => { test("shared record schemas construct and decode plain objects", () => {
const made = Prompt.make({ text: "hello" }) const made = Prompt.make({ text: "hello" })
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" }) const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", id: "part_1", text: "hi" }) const content = Schema.decodeUnknownSync(SessionMessage.AssistantText)({ type: "text", text: "hi" })
expect(Object.getPrototypeOf(made)).toBe(Object.prototype) expect(Object.getPrototypeOf(made)).toBe(Object.prototype)
expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype) expect(Object.getPrototypeOf(decoded)).toBe(Object.prototype)

View file

@ -47,7 +47,15 @@ const permission = Layer.succeed(
}).pipe( }).pipe(
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void), Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
Effect.andThen( Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
), ),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),

View file

@ -40,7 +40,15 @@ const permission = Layer.succeed(
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen( Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
), ),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),

View file

@ -23,7 +23,17 @@ const permission = Layer.succeed(
PermissionV2.Service.of({ PermissionV2.Service.of({
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),
@ -82,7 +92,13 @@ describe("QuestionTool", () => {
...toolIdentity, ...toolIdentity,
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
}), }),
).toEqual({ result: { type: "error", value: "Permission denied: question" } }) ).toEqual({
result: { type: "error", value: "Permission denied: question" },
error: {
type: "permission.rejected",
message: "Permission denied: question",
},
})
expect(capturedInput()).toBeUndefined() expect(capturedInput()).toBeUndefined()
deny = false deny = false
}), }),

View file

@ -81,7 +81,19 @@ const permission = Layer.succeed(
assert: (input) => assert: (input) =>
Effect.sync(() => { Effect.sync(() => {
assertions.push(input) assertions.push(input)
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))), }).pipe(
Effect.andThen(
allow
? Effect.void
: Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
),
),
),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),
get: () => Effect.die("unused"), get: () => Effect.die("unused"),

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs" import { realpathSync } from "node:fs"
import path from "path" import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { DateTime, Effect, Fiber, Layer, Scope } from "effect" import { DateTime, Duration, Effect, Fiber, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node" import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -47,7 +47,15 @@ const permission = Layer.succeed(
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))), Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen( Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
), ),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),
@ -75,7 +83,6 @@ const executionNode = makeGlobalNode({
const session = yield* store.get(id) const session = yield* store.get(id)
if (!session) return if (!session) return
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
const textID = "text_shell_test"
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
@ -85,12 +92,12 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
textID, ordinal: 0,
}) })
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID: id, sessionID: id,
assistantMessageID, assistantMessageID,
textID, ordinal: 0,
text: "ok", text: "ok",
}) })
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {
@ -435,7 +442,10 @@ describe("ShellTool", () => {
reset() reset()
return withSession(tmp.path, (registry) => return withSession(tmp.path, (registry) =>
Effect.gen(function* () { Effect.gen(function* () {
const settled = yield* settleTool(registry, call({ command: idleCommand, background: true })) const settled = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }),
)
const structured = settled.output?.structured as Record<string, unknown> | undefined const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false }) expect(settled.output?.structured).toMatchObject({ truncated: false })
@ -445,7 +455,45 @@ describe("ShellTool", () => {
if (!shellID) return if (!shellID) return
const id = ShellSchema.ID.make(shellID) const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id) expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id) expect((yield* shell.wait(id)).status).toBe("timeout")
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("updates and clears a running shell timeout", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* settleTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = (timed.output?.structured as Record<string, unknown> | undefined)?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
const cleared = yield* settleTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = (cleared.output?.structured as Record<string, unknown> | undefined)?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
yield* shell.timeout(clearedShellID, 0)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(clearedShellID)).status).toBe("running")
yield* shell.remove(clearedShellID)
}), }),
) )
}, },
@ -462,9 +510,10 @@ describe("ShellTool", () => {
Effect.gen(function* () { Effect.gen(function* () {
const jobs = yield* Job.Service const jobs = yield* Job.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe( const waiting = yield* settleTool(
Effect.forkIn(scope, { startImmediately: true }), registry,
) call({ command: idleCommand, timeout: 50 }, "call-background-signal"),
).pipe(Effect.forkIn(scope, { startImmediately: true }))
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> => const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
Effect.gen(function* () { Effect.gen(function* () {
@ -475,7 +524,6 @@ describe("ShellTool", () => {
return yield* backgroundWhenReady(remaining - 1) return yield* backgroundWhenReady(remaining - 1)
}) })
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }]) expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting) const settled = yield* Fiber.join(waiting)
const structured = settled.output?.structured as Record<string, unknown> | undefined const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
@ -493,6 +541,8 @@ describe("ShellTool", () => {
const shell = yield* Shell.Service const shell = yield* Shell.Service
if (!shellID) return if (!shellID) return
const id = ShellSchema.ID.make(shellID) const id = ShellSchema.ID.make(shellID)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(id)).status).toBe("running")
expect((yield* shell.list()).map((info) => info.id)).toContain(id) expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id) yield* shell.remove(id)
}), }),

View file

@ -55,7 +55,17 @@ describe("SkillTool", () => {
PermissionV2.Service.of({ PermissionV2.Service.of({
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),

View file

@ -49,7 +49,6 @@ const executionNode = makeGlobalNode({
} }
completed.add(sessionID) completed.add(sessionID)
const assistantMessageID = SessionMessage.ID.create() const assistantMessageID = SessionMessage.ID.create()
const textID = "text_subagent_test"
yield* events.publish(SessionEvent.Step.Started, { yield* events.publish(SessionEvent.Step.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
@ -59,12 +58,12 @@ const executionNode = makeGlobalNode({
yield* events.publish(SessionEvent.Text.Started, { yield* events.publish(SessionEvent.Text.Started, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
textID, ordinal: 0,
}) })
yield* events.publish(SessionEvent.Text.Ended, { yield* events.publish(SessionEvent.Text.Ended, {
sessionID, sessionID,
assistantMessageID, assistantMessageID,
textID, ordinal: 0,
text: childText, text: childText,
}) })
yield* events.publish(SessionEvent.Step.Ended, { yield* events.publish(SessionEvent.Step.Ended, {

View file

@ -33,7 +33,17 @@ const permission = Layer.succeed(
PermissionV2.Service.of({ PermissionV2.Service.of({
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void), Effect.andThen(
deny
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"), reply: () => Effect.die("unused"),

View file

@ -38,7 +38,15 @@ const permission = Layer.succeed(
assert: (input) => assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe( Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen( Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void, input.action === denyAction
? Effect.fail(
new PermissionV2.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
), ),
), ),
ask: () => Effect.die("unused"), ask: () => Effect.die("unused"),

22
packages/docs/AGENTS.md Normal file
View file

@ -0,0 +1,22 @@
# V2 documentation guide
## Structure
- This directory is a standalone Mintlify site deployed from `packages/docs` on the `dev` branch.
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
- `docs.json` owns site configuration and navigation. Add, move, or remove its page entries whenever the corresponding MDX pages change.
- Put static files in `assets/` and reference them with root-relative paths such as `/assets/example.svg`.
- The API endpoint reference is generated by Mintlify from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
- Keep documentation aligned with the V2 packages. Do not use `packages/opencode` as the source of truth unless the task explicitly concerns V1.
## Local development
- At the start of documentation work, launch `bun dev` from `packages/docs` using the shell tool with `background: true`. Never run the dev server in a foreground shell call and do not poll the process; wait for the background completion notification.
- Preview the site at `http://localhost:3333`. Mintlify does not expose a host option and binds the preview to all network interfaces. The server reloads changes to MDX and `docs.json` automatically.
- Use the running preview to verify navigation, links, Mintlify components, code blocks, and desktop and mobile layout.
## Validation
- Run `bun validate` from `packages/docs` after making documentation or configuration changes.
- Run `bun broken-links` from `packages/docs` when pages, navigation, headings, or links change.
- Treat validation errors and broken internal links as blockers. Also verify external links relevant to the change when practical.

View file

@ -4,21 +4,19 @@ The V2 documentation is a Mintlify site deployed from `packages/docs` on the `de
## Local preview ## Local preview
The Mintlify CLI requires Node.js 20 through 24.
From this directory, run: From this directory, run:
```bash ```bash
npx mint dev bun dev
``` ```
The preview opens at `http://localhost:3000` and reloads when MDX or `docs.json` changes. The preview opens at `http://localhost:3333` and reloads when MDX or `docs.json` changes.
Validate changes before opening a pull request: Validate changes before opening a pull request:
```bash ```bash
npx mint validate bun validate
npx mint broken-links bun broken-links
``` ```
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site). The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).

View file

@ -6,3 +6,439 @@ description: "Configure OpenCode."
<Tip> <Tip>
You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you. You shouldn't have to configure OpenCode manually. Ask OpenCode to update its configuration for you.
</Tip> </Tip>
## Format
OpenCode supports both **JSON** and **JSONC** (JSON with Comments) configuration files.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"model": "openai/gpt-5.2-custom",
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom"
}
}
}
}
}
```
## Locations
OpenCode loads global configuration from:
```text
~/.config/opencode/opencode.json(c)
```
Project-specific configuration can use either form:
```text
/home/user/projects/my-app/opencode.json(c)
/home/user/projects/my-app/.opencode/opencode.json(c)
```
When OpenCode starts, it searches for configuration files from the current
directory upward to the project root. The files are merged, and configuration
closer to the current directory takes precedence.
For example, consider a monorepo with OpenCode started from
`/home/user/projects/acme/packages/web`:
```text
~/.config/opencode/opencode.json
/home/user/projects/acme/
├── opencode.json
└── packages/
└── web/
├── opencode.json
└── src/
```
OpenCode applies these files from lowest to highest precedence:
1. `~/.config/opencode/opencode.json`
2. `/home/user/projects/acme/opencode.json`
3. `/home/user/projects/acme/packages/web/opencode.json`
Settings in the package config override matching settings from the repository
config, which override matching settings from the global config. Settings that
do not conflict are preserved from every file.
## Schema
The complete OpenCode configuration schema is available at
[opencode.ai/config.json](https://opencode.ai/config.json).
Add the `$schema` field to your configuration file to enable validation and
autocomplete in editors that support JSON Schema:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json"
}
```
Use the schema as the source of truth for available fields, accepted values,
and nested configuration shapes.
### Shell
Set the shell used by the terminal and shell tools.
```jsonc
{
"shell": "/bin/zsh"
}
```
### Model
Set the default model in `provider/model` format. Add `#variant` to select a
specific model variant.
```jsonc
{
"model": "anthropic/claude-sonnet-4-5#high"
}
```
See the [models guide](https://opencode.ai/docs/models/) for model selection
and local models.
### Default agent
Choose the primary agent used when a session does not select one explicitly.
```jsonc
{
"default_agent": "build"
}
```
See the [agents guide](https://opencode.ai/docs/agents/) for built-in and custom
agents.
### Autoupdate
Control automatic updates. Set this to `false` to disable updates or `"notify"`
to receive update notifications.
```jsonc
{
"autoupdate": false
}
```
### Sharing
Control whether sessions can be shared manually, shared automatically, or not
shared at all.
```jsonc
{
"share": "manual"
}
```
See the [sharing guide](https://opencode.ai/docs/share/) for more details.
### Username
Set the username displayed in conversations.
```jsonc
{
"username": "alice"
}
```
### Permissions
Define ordered rules that allow, deny, or ask before an agent uses a tool on a
matching resource.
```jsonc
{
"permissions": [
{
"action": "bash",
"resource": "git push *",
"effect": "ask"
}
]
}
```
See the [permissions guide](https://opencode.ai/docs/permissions/) for rule
matching and available actions.
### Agents
Override built-in agents or define specialized agents with their own model,
instructions, mode, and permissions.
```jsonc
{
"agents": {
"reviewer": {
"description": "Review changes without editing files",
"mode": "subagent",
"system": "Focus on correctness, security, and missing tests.",
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" }
]
}
}
}
```
See the [agents guide](https://opencode.ai/docs/agents/) for all agent options
and file-based agents.
### Snapshots
Enable or disable the snapshots used by undo and revert behavior.
```jsonc
{
"snapshots": false
}
```
### Watcher
Ignore files and directories that should not trigger filesystem updates.
```jsonc
{
"watcher": {
"ignore": ["dist/**", "coverage/**"]
}
}
```
### Formatter
Enable built-in formatters, disable formatting entirely, or configure formatter
commands by name.
```jsonc
{
"formatter": {
"prettier": {
"command": ["bunx", "prettier", "--write", "$FILE"],
"extensions": [".js", ".ts", ".tsx"]
}
}
}
```
See the [formatters guide](https://opencode.ai/docs/formatters/) for built-in
formatters and custom commands.
### LSP
Enable built-in language servers, disable them, or configure servers by name.
```jsonc
{
"lsp": {
"typescript": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"]
}
}
}
```
See the [LSP guide](https://opencode.ai/docs/lsp/) for language server setup.
### Attachments
Control how oversized image attachments are resized or rejected before they are
sent to a model.
```jsonc
{
"attachments": {
"image": {
"auto_resize": true,
"max_width": 2000,
"max_height": 2000,
"max_base64_bytes": 5242880
}
}
}
```
### Tool output
Set the maximum number of lines and bytes retained from a tool result.
```jsonc
{
"tool_output": {
"max_lines": 2000,
"max_bytes": 51200
}
}
```
### MCP
Configure local and remote Model Context Protocol servers. Global timeouts can
be overridden by an individual server.
```jsonc
{
"mcp": {
"servers": {
"playwright": {
"type": "local",
"command": ["bunx", "@playwright/mcp"]
}
}
}
}
```
See the [MCP guide](https://opencode.ai/docs/mcp-servers/) for remote servers,
OAuth, environment variables, and timeouts.
### Compaction
Control automatic context compaction and how much recent context it preserves.
```jsonc
{
"compaction": {
"auto": true,
"keep": {
"tokens": 8000
},
"buffer": 20000
}
}
```
### Skills
Add directories or URLs that OpenCode should search for agent skills.
```jsonc
{
"skills": ["./team-skills", "https://example.com/.well-known/skills/"]
}
```
See the [skills guide](https://opencode.ai/docs/skills/) for skill structure and
automatic discovery under `.opencode/skills/`.
### Commands
Define reusable slash commands as named prompt templates.
```jsonc
{
"commands": {
"review": {
"description": "Review the current changes",
"template": "Review the current diff for correctness and missing tests."
}
}
}
```
See the [commands guide](https://opencode.ai/docs/commands/) for arguments,
models, agents, and file-based commands.
### Instructions
Load additional instruction files, globs, or URLs into the agent's context.
```jsonc
{
"instructions": ["CONTRIBUTING.md", "docs/guidelines/*.md"]
}
```
See the [rules guide](https://opencode.ai/docs/rules/) for project instructions
and `AGENTS.md`.
### References
Make local directories or Git repositories available as named supporting
context.
```jsonc
{
"references": {
"docs": {
"path": "../product-docs",
"description": "Product behavior and terminology"
},
"effect": {
"repository": "Effect-TS/effect",
"branch": "main"
}
}
}
```
See the [references guide](https://opencode.ai/docs/references/) for shorthand,
visibility, and path resolution.
### Plugins
Load plugins from packages or local files. Use the object form when a plugin
accepts options.
```jsonc
{
"plugins": [
"opencode-example-plugin",
{
"package": "./plugins/local.ts",
"options": {
"enabled": true
}
}
]
}
```
See the [plugins guide](/plugins) for plugin development and configuration.
### Providers
Configure providers and add or override their models, request settings,
headers, and model variants.
```jsonc
{
"providers": {
"openai": {
"models": {
"gpt-5.2-custom": {
"modelID": "gpt-5.2",
"name": "GPT-5.2 Custom",
"limit": {
"context": 200000,
"output": 32000
}
}
}
}
}
}
```
See the [providers guide](https://opencode.ai/docs/providers/) for credentials,
custom endpoints, provider packages, and model configuration.

View file

@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/docs",
"private": true,
"scripts": {
"dev": "bun --bun mint dev --no-open --port 3333",
"validate": "bun --bun mint validate",
"broken-links": "bun --bun mint broken-links"
},
"devDependencies": {
"mint": "4.2.666"
}
}

View file

@ -619,6 +619,11 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
] ]
} }
const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const events: LLMEvent[] = []
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
}
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS] if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = [] const events: LLMEvent[] = []
@ -810,6 +815,8 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
const item = event.item const item = event.item
if (!item) return [state, NO_EVENTS] satisfies StepResult if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
if (item.type === "function_call") { if (item.type === "function_call") {
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id] const tools = state.tools[item.id]
@ -920,6 +927,7 @@ const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult =>
const step = (state: ParserState, event: OpenAIResponsesEvent) => { const step = (state: ParserState, event: OpenAIResponsesEvent) => {
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event)) if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
if ( if (
event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_text.delta" ||
event.type === "response.reasoning_summary.delta" || event.type === "response.reasoning_summary.delta" ||

View file

@ -1,5 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/llm" import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm"
export { ProviderMetadata } export { ProviderMetadata }
@ -36,7 +36,7 @@ export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole> export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason> export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)

View file

@ -764,6 +764,35 @@ describe("OpenAI Responses route", () => {
}), }),
) )
// OpenAI's documented stream orders output text within one message item; no
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
it.effect("closes sequential output messages before starting the next", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_text.delta", item_id: "msg_1", delta: "First" },
{ type: "response.output_text.done", item_id: "msg_1" },
{ type: "response.output_text.delta", item_id: "msg_2", delta: "Second" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_2" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([
{ type: "text-start", id: "msg_1" },
{ type: "text-delta", id: "msg_1", text: "First" },
{ type: "text-end", id: "msg_1" },
{ type: "text-start", id: "msg_2" },
{ type: "text-delta", id: "msg_2", text: "Second" },
{ type: "text-end", id: "msg_2" },
])
}),
)
it.effect("parses reasoning summary stream fixtures", () => it.effect("parses reasoning summary stream fixtures", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents( const body = sseEvents(

View file

@ -65,7 +65,7 @@ Exchange = { id, body, queue: Queue<Item | Error | Done>, deferred lifecycle }
### 4. Backend control WebSocket (simulation-gated) ### 4. Backend control WebSocket (simulation-gated)
Started when the simulation module loads (lazy import, `OPENCODE_SIMULATION` only): a loopback JSON-RPC 2.0 WebSocket on `127.0.0.1:40950+`, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all. Started when `OPENCODE_DRIVE` names a registry manifest: a loopback JSON-RPC 2.0 WebSocket at that manifest's exact backend endpoint, hosted by the backend process. Drivers connect to it directly — the standalone topology has exactly one backend per TUI, so there is no proxying through the frontend. This socket is also the headless-simulation interface: it works with no TUI at all.
Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing): Server -> driver notification (after `llm.attach`; pending exchanges are replayed on attach so late-attaching drivers miss nothing):
@ -101,8 +101,8 @@ Failure injection (`llm.fail`: HTTP status instead of SSE) is specced but not ye
A driver manages two loopback WebSocket connections: A driver manages two loopback WebSocket connections:
- TUI control server (`127.0.0.1:40900+`) — UI state, actions, render, trace. - TUI control server (manifest `endpoints.ui`) — UI state, actions, render, trace.
- Backend control server (`127.0.0.1:40950+`) — LLM exchanges, network log. - Backend control server (manifest `endpoints.backend`) — LLM exchanges, network log.
Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins. Both speak the same JSON-RPC shape. Headless drivers use only the backend socket plus the normal HTTP API. Multiple drivers are out of scope; last attach wins.
@ -117,7 +117,7 @@ The driver-facing model must be selectable in the TUI. Simulation seeds config (
## End-to-end flow ## End-to-end flow
``` ```
driver TUI sim server (40900+) backend + control WS (40950+) driver TUI drive server backend + drive WS
| | | | | |
|-- ui.action (submit) ----->| | |-- ui.action (submit) ----->| |
| |-- (normal app HTTP) ---->| session runner starts | |-- (normal app HTTP) ---->| session runner starts

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