Compare commits

..

1 Commits

Author SHA1 Message Date
Ryan Vogel 5e27dd7b3e feat(tui): show token throughput 2026-08-12 19:30:50 +00:00
601 changed files with 4864 additions and 154419 deletions
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -7
View File
@@ -55,12 +55,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Install ffmpeg
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -72,7 +66,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
+4 -4
View File
@@ -51,7 +51,7 @@ files. If the script is unsuccessful, automatically fix the script and run it ag
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
You can read the full typed API here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
@@ -83,7 +83,7 @@ itself (this is extremely rare, do not use this unless explicitly asked). In thi
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
here: https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
@@ -114,8 +114,8 @@ completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/simple.ts
- https://raw.githubusercontent.com/jlongster/opencode-drive/refs/heads/main/examples/serve.ts
## Prune
+603 -875
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -33,7 +33,6 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/lab/*",
"packages/stats/*",
"packages/slack"
],
@@ -47,9 +46,9 @@
"@octokit/rest": "22.0.0",
"@hono/standard-validator": "0.2.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.5.2",
"@opentui/keymap": "0.5.2",
"@opentui/solid": "0.5.2",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@tanstack/solid-virtual": "3.13.32",
"@shikijs/stream": "4.2.0",
"@standard-schema/spec": "1.1.0",
@@ -121,8 +120,7 @@
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "catalog:",
"turbo": "2.10.2",
"vitest": "4.1.10"
"turbo": "2.10.2"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
@@ -152,9 +150,9 @@
"electron"
],
"overrides": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@opentui/core": "0.0.0-20260808-9ecf7c0a",
"@opentui/keymap": "0.0.0-20260808-9ecf7c0a",
"@opentui/solid": "0.0.0-20260808-9ecf7c0a",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:"
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type {
@@ -188,18 +187,6 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
if (!adopted) return
input.setStore("session", index, (current) => ({ ...current, ...adopted }))
})
break
}
case "session.renamed": {
const properties = event.properties as { sessionID: string; title: string }
const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id)
@@ -18,18 +18,18 @@ describe("v2 session reducer", () => {
apply({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
inputID: "msg_user",
input: { type: "user", delivery: "steer", data: { text: "hello" } },
},
})
apply({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
})
apply({
...base,
@@ -203,8 +203,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -218,11 +218,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "cancel me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "cancel me" } },
},
}),
)
@@ -231,8 +231,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_cancelled",
type: "session.inbox.cancelled",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.cancelled",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
const result = reducer.reduce(
@@ -240,8 +240,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -255,11 +255,11 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
type: "session.input.admitted",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "queue", payload: { text: "steer me" } },
inputID: "msg_user",
input: { type: "user", delivery: "queue", data: { text: "steer me" } },
},
}),
)
@@ -268,8 +268,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_steered",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "steer" },
type: "session.input.steered",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
reducer.reduce(
@@ -277,8 +277,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_queued",
type: "session.inbox.delivery.changed",
data: { sessionID: "ses_1", inboxID: "msg_user", delivery: "queue" },
type: "session.input.queued",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -287,8 +287,8 @@ describe("v2 session reducer", () => {
event({
...base,
id: "evt_promoted",
type: "session.inbox.delivered",
data: { sessionID: "ses_1", inboxID: "msg_user" },
type: "session.input.promoted",
data: { sessionID: "ses_1", inputID: "msg_user" },
}),
)
@@ -1,4 +1,4 @@
import type { OpenCodeEvent, SessionInboxItem, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -12,7 +12,7 @@ export type V2SessionReduction = {
}
export function createV2SessionReducer() {
const pending = new Map<string, SessionInboxItem>()
const pending = new Map<string, SessionPendingMessage>()
const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => {
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
@@ -26,33 +26,32 @@ export function createV2SessionReducer() {
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
case "session.input.admitted":
pending.set(key(sessionID, event.data.inputID), event.data.input)
return result([...source])
case "session.inbox.cancelled":
pending.delete(key(sessionID, event.data.inboxID))
case "session.input.cancelled":
pending.delete(key(sessionID, event.data.inputID))
return
case "session.inbox.delivered": {
const input = pending.get(key(sessionID, event.data.inboxID))
pending.delete(key(sessionID, event.data.inboxID))
if (!input) return { ...result([...source]), missing: event.data.inboxID }
case "session.input.promoted": {
const input = pending.get(key(sessionID, event.data.inputID))
pending.delete(key(sessionID, event.data.inputID))
if (!input) return { ...result([...source]), missing: event.data.inputID }
if (input.type === "user")
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
time: { created: event.created },
})
if (input.type !== "synthetic") return result([...source])
return append({
id: event.data.inboxID,
id: event.data.inputID,
type: "synthetic",
metadata: input.payload.metadata,
text: input.payload.text,
description: input.payload.description,
metadata: input.data.metadata,
text: input.data.text,
description: input.data.description,
time: { created: event.created },
})
}
@@ -352,7 +351,7 @@ export function createV2SessionReducer() {
metadata: event.metadata,
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
recent: event.data.recent,
time: { created: event.created },
})
case "session.compaction.delta":
@@ -1,5 +1,4 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry"
import type { OpenCodeEvent, SessionApi, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Message, Part, Todo } from "@/types"
@@ -895,17 +894,6 @@ export function createServerSession(
}
const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "project.directory.resolved") {
Object.values(data.info).forEach((info) => {
if (!info) return
const adopted = ProjectDirectories.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
if (adopted) remember({ ...info, ...adopted })
})
return
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
const reduction = v2.reduce(data.session_message[sessionID] ?? [], event)
+3 -2
View File
@@ -538,8 +538,9 @@ async function replayMessage(
}
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
if (start.type === "input") return event.type === "session.input.promoted" && event.data.inputID === start.id
if (start.type === "compaction")
return event.type === "session.compaction.admitted" && event.data.inputID === start.id
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
}
+2 -2
View File
@@ -195,8 +195,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (!("sessionID" in event.data) || event.data.sessionID !== input.sessionID) continue
const time = toMillis("created" in event ? event.created : undefined)
if (event.type === "session.inbox.delivered") {
if (event.data.inboxID === messageID) {
if (event.type === "session.input.promoted") {
if (event.data.inputID === messageID) {
promoted = true
prePromotionError = undefined
continue
@@ -447,7 +447,7 @@ function UpdateFooter(props: {
})
return (
<box width="100%" height={4} flexDirection="row" gap={1} paddingLeft={1} live={props.animating()}>
<box width="100%" height={4} flexDirection="row" gap={1} live={props.animating()}>
<Monogram ink={monogramInk} />
<box flexDirection="column" flexGrow={1} overflow="hidden">
<CellLine cells={header()} />
+18 -18
View File
@@ -22,8 +22,8 @@ describe("acp event behavior", () => {
delta: "before admission",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_b", inboxID: id }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: "input_other" }))
send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_a",
@@ -32,7 +32,7 @@ describe("acp event behavior", () => {
delta: "wrong input",
}),
)
send(durableEvent("session.inbox.delivered", { sessionID: "ses_a", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_b",
@@ -68,7 +68,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_a",
inboxID: "input_a",
inputID: "input_a",
})
expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
@@ -99,7 +99,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
async onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_order", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
@@ -148,7 +148,7 @@ describe("acp event behavior", () => {
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
const result = turn({ fixture, connection, sessionID: "ses_order", inboxID: "input_order" })
const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
try {
await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
@@ -195,7 +195,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
@@ -240,7 +240,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
inputID: "input_parent",
})
expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
@@ -276,7 +276,7 @@ describe("acp event behavior", () => {
const completed = Promise.withResolvers<void>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_background",
@@ -292,7 +292,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_parent",
inboxID: "input_parent",
inputID: "input_parent",
childSessionUpdate: async (update) => {
childUpdates.push(update)
if (update.type === "status" && update.status === "completed") completed.resolve()
@@ -370,7 +370,7 @@ describe("acp event behavior", () => {
const updates: SessionUpdateParams[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_tools", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_tools",
@@ -460,7 +460,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection(updates),
sessionID: "ses_tools",
inboxID: "input_tools",
inputID: "input_tools",
})
expect(
@@ -597,7 +597,7 @@ describe("acp event behavior", () => {
const control: TurnControl = { cancelled: false, admission: new AbortController() }
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
},
onInterrupt({ sessionID, send }) {
send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
@@ -680,7 +680,7 @@ describe("acp event behavior", () => {
test("cancels unsupported session forms so execution can continue", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_form", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
send(
ephemeralEvent("form.created", {
form: {
@@ -704,7 +704,7 @@ describe("acp event behavior", () => {
fixture,
connection: recordingConnection([]),
sessionID: "ses_form",
inboxID: "input_form",
inputID: "input_form",
})
expect(response.stopReason).toBe("end_turn")
@@ -730,7 +730,7 @@ function turn(input: {
readonly fixture: Fixture
readonly connection: Connection
readonly sessionID: string
readonly inboxID: string
readonly inputID: string
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
}) {
return streamTurn({
@@ -738,12 +738,12 @@ function turn(input: {
connection: input.connection,
sessionID: input.sessionID,
cwd: "/workspace",
start: { type: "input", id: input.inboxID },
start: { type: "input", id: input.inputID },
writeTextFile: false,
control: { cancelled: false, admission: new AbortController() },
childSessionUpdate: input.childSessionUpdate,
submit: (signal) =>
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inboxID, text: "hello" }, { signal }),
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
})
}
+2 -2
View File
@@ -34,8 +34,8 @@ test("acp prompt resolves after ordered turn updates", async () => {
send(events, {
id: "evt_promoted",
created: 1,
type: "session.inbox.delivered",
data: { sessionID: "ses_test", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_test", inputID: id },
})
send(events, {
id: "evt_text",
@@ -38,7 +38,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_allow", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_allow", inputID: id }))
send(
permissionAsked("ses_allow", "perm_once", {
action: "shell",
@@ -112,7 +112,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_external", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_external", inputID: id }))
send(
permissionAsked("ses_external", "perm_external", {
action: "external_directory",
@@ -157,7 +157,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_parent", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
send(
durableEvent("session.created", {
sessionID: "ses_child",
@@ -219,7 +219,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_edit", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_edit",
@@ -309,7 +309,7 @@ describe("acp permission behavior", () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_patch", inputID: id }))
send(
durableEvent("session.tool.input.started", {
sessionID: "ses_patch",
@@ -389,7 +389,7 @@ describe("acp permission behavior", () => {
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_reject", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_reject", inputID: id }))
send(permissionAsked("ses_reject", "perm_selected_reject"))
send(permissionAsked("ses_reject", "perm_cancelled"))
send(permissionAsked("ses_reject", "perm_failed"))
@@ -426,7 +426,7 @@ describe("acp permission behavior", () => {
const permissionRequests: RequestPermissionRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_serial", inboxID: id }))
send(durableEvent("session.input.promoted", { sessionID: "ses_serial", inputID: id }))
send(permissionAsked("ses_serial", "perm_1"))
send(permissionAsked("ses_serial", "perm_2"))
send(durableEvent("session.execution.succeeded", { sessionID: "ses_serial" }))
@@ -477,8 +477,8 @@ describe("acp permission behavior", () => {
const blockedID = promptIDs.get("ses_blocked")
const freeID = promptIDs.get("ses_free")
if (!blockedID || !freeID) throw new Error("both permission test prompts must be registered")
send(durableEvent("session.inbox.delivered", { sessionID: "ses_blocked", inboxID: blockedID }))
send(durableEvent("session.inbox.delivered", { sessionID: "ses_free", inboxID: freeID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_blocked", inputID: blockedID }))
send(durableEvent("session.input.promoted", { sessionID: "ses_free", inputID: freeID }))
send(permissionAsked("ses_blocked", "perm_blocked"))
send(
ephemeralEvent("session.text.delta", {
@@ -538,16 +538,16 @@ describe("acp permission behavior", () => {
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inputID: string, cwd = "/workspace") {
return streamTurn({
client: fixture.client,
connection,
sessionID,
cwd,
start: { type: "input", id: inboxID },
start: { type: "input", id: inputID },
writeTextFile: true,
control: { cancelled: false, admission: new AbortController() },
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inboxID, text: "hello" }, { signal }),
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
})
}
+8 -8
View File
@@ -15,8 +15,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
@@ -33,8 +33,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
completeTurn(context, "ses_routes", {
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_routes", inboxID: id },
type: "session.compaction.admitted",
data: { sessionID: "ses_routes", inputID: id },
})
return Response.json({ data: {} })
}
@@ -95,8 +95,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_usage", inputID: id },
})
context.send({
id: "evt_step",
@@ -189,8 +189,8 @@ describe("acp service prompt routing and usage", () => {
const id = requestID(request)
context.send({
id: `evt_${id}`,
type: "session.inbox.delivered",
data: { sessionID: "ses_usage_failure", inboxID: id },
type: "session.input.promoted",
data: { sessionID: "ses_usage_failure", inputID: id },
})
context.send({
id: "evt_step_failure",
+10 -10
View File
@@ -28,13 +28,13 @@ function formCreated(info: FormInfo, eventLocation = location): V2Event {
return { id: `evt_${info.id}`, created: 0, type: "form.created", location: eventLocation, data: { form: info } }
}
function prompted(inboxID: string): V2Event {
function prompted(inputID: string): V2Event {
return {
id: "evt_prompted",
created: 0,
type: "session.inbox.delivered",
type: "session.input.promoted",
durable: { aggregateID: "ses_1", seq: 0, version: 1 },
data: { sessionID: "ses_1", inboxID },
data: { sessionID: "ses_1", inputID },
}
}
@@ -98,9 +98,9 @@ function executionFailed(message: string): V2Event {
}
}
function failedTool(inboxID: string): V2Event[] {
function failedTool(inputID: string): V2Event[] {
return [
prompted(inboxID),
prompted(inputID),
{
id: "evt_failed_tool_input",
created: 1,
@@ -156,10 +156,10 @@ function failedTool(inboxID: string): V2Event[] {
]
}
function successfulGrep(inboxID: string): V2Event[] {
function successfulGrep(inputID: string): V2Event[] {
const text = "Found 2 matches\n/src/a.ts:\n Line 1: needle\n/src/b.ts:\n Line 2: needle"
return [
prompted(inboxID),
prompted(inputID),
{
id: "evt_grep_input",
created: 1,
@@ -206,7 +206,7 @@ function successfulGrep(inboxID: string): V2Event[] {
// Runs one non-interactive prompt against a mocked SDK. `turn` produces the
// live events the prompt admission triggers, keyed by the generated message ID.
async function run(input: {
turn: (inboxID: string) => V2Event[]
turn: (inputID: string) => V2Event[]
pendingForms?: FormInfo[]
attached?: boolean
format?: "default" | "json"
@@ -214,7 +214,7 @@ async function run(input: {
cancel?: (input: { sessionID: string; formID: string }) => Promise<void>
renderTool?: (part: SessionMessageAssistantTool) => Promise<void>
renderToolError?: (part: SessionMessageAssistantTool) => Promise<void>
messages?: (inboxID: string) => SessionMessageInfo[]
messages?: (inputID: string) => SessionMessageInfo[]
wait?: () => Promise<void>
terminalDelay?: number
}) {
@@ -515,7 +515,7 @@ describe("runNonInteractivePrompt", () => {
const rendered: SessionMessageAssistantTool[] = []
const failed: SessionMessageAssistantTool[] = []
await capture({
turn: (inboxID) => failedTool(inboxID).filter((event) => event.type !== "session.tool.progress"),
turn: (inputID) => failedTool(inputID).filter((event) => event.type !== "session.tool.progress"),
renderTool: (part) => {
rendered.push(part)
return Promise.resolve()
+2 -2
View File
@@ -30,7 +30,7 @@ import { Reference } from "@opencode-ai/schema/reference"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { Shell } from "@opencode-ai/schema/shell"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
@@ -69,7 +69,7 @@ const effectTypeReferences = [
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
...namespaceTypes("SessionInbox", "@opencode-ai/schema/session-inbox", SessionInbox),
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
+54 -41
View File
@@ -11,9 +11,9 @@ import type { RelativePath } from "@opencode-ai/schema/schema"
import type { Brand } from "effect"
import type { Model } from "@opencode-ai/schema/model"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { SessionInbox } from "@opencode-ai/schema/session-inbox"
import type { PromptInput } from "@opencode-ai/schema/prompt-input"
import type { AgentAttachment } from "@opencode-ai/schema/prompt"
import type { SessionPending } from "@opencode-ai/schema/session-pending"
import type { Skill } from "@opencode-ai/schema/skill"
import type { Event } from "@opencode-ai/schema/event"
import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
@@ -170,7 +170,6 @@ export type Endpoint5_11Input = {
readonly sessionID: Session.ID
readonly directory: AbsolutePath
readonly workspaceID?: Workspace.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_11Output = void
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
@@ -183,10 +182,10 @@ export type Endpoint5_12Input = {
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_12Output = SessionInbox.User
export type Endpoint5_12Output = SessionPending.User
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
export type Endpoint5_13Input = {
@@ -199,10 +198,10 @@ export type Endpoint5_13Input = {
readonly files?: ReadonlyArray<PromptInput.FileAttachment> | undefined
readonly agents?: ReadonlyArray<AgentAttachment> | undefined
readonly skills?: ReadonlyArray<PromptInput.SkillAttachment> | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_13Output = SessionInbox.User
export type Endpoint5_13Output = SessionPending.User
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
export type Endpoint5_14Input = {
@@ -220,10 +219,10 @@ export type Endpoint5_15Input = {
readonly text: string
readonly description?: string | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly delivery?: SessionInbox.Delivery | undefined
readonly delivery?: "steer" | "queue" | undefined
readonly resume?: boolean | undefined
}
export type Endpoint5_15Output = SessionInbox.Synthetic
export type Endpoint5_15Output = SessionPending.Synthetic
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
export type Endpoint5_16Input = {
@@ -234,12 +233,8 @@ export type Endpoint5_16Input = {
export type Endpoint5_16Output = void
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
export type Endpoint5_17Input = {
readonly sessionID: Session.ID
readonly id?: SessionMessage.ID | undefined
readonly delivery?: SessionInbox.Delivery | undefined
}
export type Endpoint5_17Output = SessionInbox.Compaction
export type Endpoint5_17Input = { readonly sessionID: Session.ID; readonly id?: SessionMessage.ID | undefined }
export type Endpoint5_17Output = SessionPending.Compaction
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
@@ -267,20 +262,22 @@ export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_23Output = ReadonlyArray<SessionPending.Info>
export type SessionPendingListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_24Output = void
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
export type SessionPendingCancelOperation<E = never> = (
input: Endpoint5_24Input,
) => Effect.Effect<Endpoint5_24Output, E>
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_25Output = void
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type SessionPendingSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
export type Endpoint5_26Output = void
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type SessionPendingQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
@@ -371,7 +368,7 @@ export type Endpoint5_31Output =
readonly data: {
readonly sessionID: Session.ID
readonly location: Location.Ref
readonly projectID: Project.ID
readonly projectID?: Project.ID | undefined
readonly subpath?: RelativePath | undefined
}
}
@@ -413,45 +410,50 @@ export type Endpoint5_31Output =
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered"
readonly type: "session.input.promoted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued"
readonly type: "session.input.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly item: SessionInbox.Item
readonly inputID: SessionMessage.ID
readonly input: SessionPending.Message
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled"
readonly type: "session.input.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed"
readonly type: "session.input.steered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.input.queued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
@@ -593,6 +595,7 @@ export type Endpoint5_31Output =
readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number }
}
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
@@ -617,6 +620,7 @@ export type Endpoint5_31Output =
readonly cache: { readonly read: number; readonly write: number }
}
| undefined
readonly generated?: DateTime.Utc | undefined
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly files?: ReadonlyArray<RelativePath> | undefined
}
@@ -812,6 +816,15 @@ export type Endpoint5_31Output =
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
}
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.admitted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?: Location.Ref | undefined
readonly data: { readonly sessionID: Session.ID; readonly inputID: SessionMessage.ID }
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
@@ -942,11 +955,11 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
readonly pending: {
readonly list: SessionPendingListOperation<E>
readonly cancel: SessionPendingCancelOperation<E>
readonly steer: SessionPendingSteerOperation<E>
readonly queue: SessionPendingQueueOperation<E>
}
readonly instructions: {
readonly entry: {
+7 -10
View File
@@ -399,7 +399,7 @@ const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11I
preserveEffect<Endpoint5_11Output>()(
raw["session.move"]({
params: { sessionID: input["sessionID"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
payload: { directory: input["directory"], workspaceID: input["workspaceID"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -481,10 +481,7 @@ const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16I
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
preserveEffect<Endpoint5_17Output>()(
raw["session.compact"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], delivery: input["delivery"] },
}).pipe(
raw["session.compact"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -526,7 +523,7 @@ const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22I
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
preserveEffect<Endpoint5_23Output>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
raw["session.pending.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -534,21 +531,21 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
preserveEffect<Endpoint5_24Output>()(
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.cancel"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
preserveEffect<Endpoint5_25Output>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.steer"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
preserveEffect<Endpoint5_26Output>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
raw["session.pending.queue"]({ params: { sessionID: input["sessionID"], inputID: input["inputID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
@@ -640,7 +637,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
wait: Endpoint5_18(raw),
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
context: Endpoint5_22(raw),
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
pending: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
generate: Endpoint5_30(raw),
log: Endpoint5_31(raw),
+1 -1
View File
@@ -41,7 +41,7 @@ export { Reference } from "@opencode-ai/schema/reference"
export { WebSearch } from "@opencode-ai/schema/websearch"
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
export { Session } from "@opencode-ai/schema/session"
export { SessionInbox } from "@opencode-ai/schema/session-inbox"
export { SessionPending } from "@opencode-ai/schema/session-pending"
export { SessionMessage } from "@opencode-ai/schema/session-message"
export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
+23 -23
View File
@@ -56,14 +56,14 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionPendingListInput,
SessionPendingListOutput,
SessionPendingCancelInput,
SessionPendingCancelOutput,
SessionPendingSteerInput,
SessionPendingSteerOutput,
SessionPendingQueueInput,
SessionPendingQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -598,7 +598,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/move`,
body: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
body: { directory: input["directory"], workspaceID: input["workspaceID"] },
successStatus: 204,
declaredStatuses: [404, 400, 401],
empty: true,
@@ -697,7 +697,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`,
body: { id: input["id"], delivery: input["delivery"] },
body: { id: input["id"] },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
@@ -762,45 +762,45 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
pending: {
list: (input: SessionPendingListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionPendingListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending`,
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
cancel: (input: SessionInboxCancelInput, requestOptions?: RequestOptions) =>
request<SessionInboxCancelOutput>(
cancel: (input: SessionPendingCancelInput, requestOptions?: RequestOptions) =>
request<SessionPendingCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
steer: (input: SessionPendingSteerInput, requestOptions?: RequestOptions) =>
request<SessionPendingSteerOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/steer`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
queue: (input: SessionPendingQueueInput, requestOptions?: RequestOptions) =>
request<SessionPendingQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/pending/${encodeURIComponent(input.inputID)}/queue`,
successStatus: 204,
declaredStatuses: [409, 404, 401, 400],
empty: true,
+183 -188
View File
@@ -130,17 +130,15 @@ export type SessionMessageCompactionCompleted = {
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
export type SessionInboxCompactionPayload = {}
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
export type InstructionEntryKey = string
export type SessionGenerateResponse = { data: { text: string } }
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type SessionPendingSyntheticData1 = { text: string; description?: string; metadata?: { [x: string]: any } }
export type ShellInfo = {
id: string
@@ -422,8 +420,6 @@ export type SessionMessageLocationSwitched = {
previous?: { location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionInboxMovePayload = { location: LocationRef; projectID: string; subpath?: string }
export type SessionCreated = {
id: string
created: number
@@ -472,7 +468,7 @@ export type SessionMoved = {
type: "session.moved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; location: LocationRef; projectID: string; subpath?: string }
data: { sessionID: string; location: LocationRef; projectID?: string; subpath?: string }
}
export type SessionRenamed = {
@@ -505,24 +501,44 @@ export type SessionForked = {
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
}
export type SessionInboxDelivered = {
export type SessionInputPromoted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.delivered"
type: "session.input.promoted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string }
data: { sessionID: string; inputID: string }
}
export type SessionInboxCancelled = {
export type SessionInputCancelled = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.cancelled"
type: "session.input.cancelled"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string }
data: { sessionID: string; inputID: string }
}
export type SessionInputSteered = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.steered"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionInputQueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.queued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionExecutionStarted = {
@@ -608,6 +624,7 @@ export type SessionStepEnded = {
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
cost: MoneyUSD
tokens: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -643,6 +660,16 @@ export type SessionToolInputEnded = {
data: { sessionID: string; assistantMessageID: string; id: string; text: string }
}
export type SessionCompactionAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.compaction.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string }
}
export type SessionCompactionStarted = {
id: string
created: number
@@ -837,16 +864,6 @@ export type ProjectDirectoriesUpdated = {
data: { projectID: string }
}
export type ProjectDirectoryResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.directory.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
}
export type CommandUpdated = {
id: string
created: number
@@ -1121,6 +1138,7 @@ export type SessionStepFailed = {
error: SessionStructuredError
cost?: MoneyUSD
tokens?: TokenUsageInfo
generated?: number
snapshot?: string
files?: Array<string>
}
@@ -1146,36 +1164,23 @@ export type SessionCompactionFailed = {
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
}
export type SessionInboxDeliveryChanged = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.delivery.changed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; delivery: SessionInboxDelivery }
}
export type SessionInboxSynthetic = {
export type SessionPendingSynthetic = {
id: string
sessionID: string
timeCreated: number
type: "synthetic"
payload: SessionInboxSyntheticPayload
delivery: SessionInboxDelivery
}
export type SessionInboxCompaction = {
id: string
sessionID: string
timeCreated: number
type: "compaction"
payload: SessionInboxCompactionPayload
delivery: SessionInboxDelivery
data: SessionPendingSyntheticData
delivery: "steer" | "queue"
}
export type InstructionEntryInfo = { key: InstructionEntryKey; value: JsonValue }
export type SessionPendingSyntheticMessage = {
type: "synthetic"
data: SessionPendingSyntheticData1
delivery: "steer" | "queue"
}
export type SessionShellStarted = {
id: string
created: number
@@ -1524,15 +1529,6 @@ export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
export type SessionInboxMove = {
id: string
sessionID: string
timeCreated: number
type: "move"
payload: SessionInboxMovePayload
delivery: SessionInboxDelivery
}
export type SessionInfo = {
id: string
parentID?: string
@@ -1570,7 +1566,7 @@ export type SessionMessageUser = {
type: "user"
}
export type SessionInboxUserPayload = {
export type SessionPendingUserData = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1578,7 +1574,7 @@ export type SessionInboxUserPayload = {
metadata?: { [x: string]: JsonValue }
}
export type SessionInboxUserPayload1 = {
export type SessionPendingUserData1 = {
text: string
files?: Array<PromptFileAttachment>
agents?: Array<PromptAgentAttachment>
@@ -1890,20 +1886,16 @@ export type ConfigEntry =
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
export type SessionInboxUser = {
export type SessionPendingUser = {
id: string
sessionID: string
timeCreated: number
type: "user"
payload: SessionInboxUserPayload
delivery: SessionInboxDelivery
data: SessionPendingUserData
delivery: "steer" | "queue"
}
export type SessionInboxItem =
| { type: "user"; payload: SessionInboxUserPayload1; delivery: SessionInboxDelivery }
| { type: "synthetic"; payload: SessionInboxSyntheticPayload1; delivery: SessionInboxDelivery }
| { type: "compaction"; payload: SessionInboxCompactionPayload; delivery: SessionInboxDelivery }
| { type: "move"; payload: SessionInboxMovePayload; delivery: SessionInboxDelivery }
export type SessionPendingUserMessage = { type: "user"; data: SessionPendingUserData1; delivery: "steer" | "queue" }
export type SessionMessageAssistantTool = {
type: "tool"
@@ -1924,22 +1916,14 @@ export type FormFields = [FormField, ...Array<FormField>]
export type FormFields3 = [FormField1, ...Array<FormField1>]
export type SessionInboxInfo = SessionInboxUser | SessionInboxSynthetic | SessionInboxCompaction | SessionInboxMove
export type SessionPendingInfo = SessionPendingUser | SessionPendingSynthetic | SessionPendingCompaction
export type SessionInboxEnqueued = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.inbox.enqueued"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inboxID: string; item: SessionInboxItem }
}
export type SessionPendingMessage = SessionPendingUserMessage | SessionPendingSyntheticMessage
export type SessionMessageAssistant = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number; completed?: number }
time: { created: number; started?: number; generated?: number; completed?: number }
type: "assistant"
agent: string
model: ModelRef
@@ -1960,47 +1944,15 @@ export type FormInfo = { id: string; sessionID: string; title: string; metadata?
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields3 }
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionDeleted
| SessionForked
| SessionInboxDelivered
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionInputAdmitted = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.input.admitted"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; inputID: string; input: SessionPendingMessage }
}
export type SessionMessageInfo =
| SessionMessageAgentSelected
@@ -2029,7 +1981,49 @@ export type FormCreated = {
data: { form: FormInfo1 }
}
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type SessionEventDurable =
| SessionCreated
| SessionAgentSelected
| SessionModelSelected
| SessionMoved
| SessionRenamed
| SessionDeleted
| SessionForked
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
| SessionExecutionInterrupted
| SessionInstructionsUpdated
| SessionSynthetic
| SessionSkillActivated
| SessionShellStarted
| SessionShellEnded
| SessionStepStarted
| SessionStepEnded
| SessionStepFailed
| SessionTextStarted
| SessionTextEnded
| SessionReasoningStarted
| SessionReasoningEnded
| SessionToolInputStarted
| SessionToolInputEnded
| SessionToolCalled
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionEnded
| SessionCompactionFailed
| SessionRevertStaged
| SessionRevertCleared
| SessionRevertCommitted
| SessionUsageRecorded
export type SessionTransferData = { info: SessionInfo; messages: Array<SessionMessageInfo> }
@@ -2059,10 +2053,11 @@ export type V2Event =
| SessionUsageUpdated
| SessionDeleted
| SessionForked
| SessionInboxDelivered
| SessionInboxEnqueued
| SessionInboxCancelled
| SessionInboxDeliveryChanged
| SessionInputPromoted
| SessionInputAdmitted
| SessionInputCancelled
| SessionInputSteered
| SessionInputQueued
| SessionExecutionStarted
| SessionExecutionSucceeded
| SessionExecutionFailed
@@ -2089,6 +2084,7 @@ export type V2Event =
| SessionToolSuccess
| SessionToolFailed
| SessionRetryScheduled
| SessionCompactionAdmitted
| SessionCompactionStarted
| SessionCompactionDelta
| SessionCompactionEnded
@@ -2103,7 +2099,6 @@ export type V2Event =
| PluginAdded
| PluginUpdated
| ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| CommandUpdated
| ConfigUpdated
| SkillUpdated
@@ -2134,6 +2129,8 @@ export type V2Event =
| McpResourcesChanged
| V2EventServerConnected
export type SessionLogItem = SessionEventDurable | EventLogSynced
export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly message: string }
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
@@ -2647,7 +2644,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -2914,7 +2916,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3181,7 +3188,12 @@ export type SessionImportInput = {
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number; readonly completed?: number }
readonly time: {
readonly created: number
readonly started?: number
readonly generated?: number
readonly completed?: number
}
readonly type: "assistant"
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
@@ -3366,21 +3378,8 @@ export type SessionRenameOutput = void
export type SessionMoveInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly directory: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["directory"]
readonly workspaceID?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["workspaceID"]
readonly delivery?: {
readonly directory: string
readonly workspaceID?: string
readonly delivery?: ("steer" | "queue") | null
}["delivery"]
readonly directory: { readonly directory: string; readonly workspaceID?: string }["directory"]
readonly workspaceID?: { readonly directory: string; readonly workspaceID?: string }["workspaceID"]
}
export type SessionMoveOutput = void
@@ -3405,7 +3404,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly text: {
@@ -3426,7 +3425,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["text"]
readonly files?: {
@@ -3447,7 +3446,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
@@ -3468,7 +3467,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
@@ -3489,7 +3488,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["skills"]
readonly metadata?: {
@@ -3510,7 +3509,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["metadata"]
readonly delivery?: {
@@ -3531,7 +3530,7 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3552,12 +3551,12 @@ export type SessionPromptInput = {
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionPromptOutput = { data: SessionInboxUser }["data"]
export type SessionPromptOutput = { data: SessionPendingUser }["data"]
export type SessionCommandInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3581,7 +3580,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly command: {
@@ -3604,7 +3603,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["command"]
readonly arguments?: {
@@ -3627,7 +3626,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["arguments"]
readonly agent?: {
@@ -3650,7 +3649,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agent"]
readonly model?: {
@@ -3673,7 +3672,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["model"]
readonly files?: {
@@ -3696,7 +3695,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["files"]
readonly agents?: {
@@ -3719,7 +3718,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["agents"]
readonly skills?: {
@@ -3742,7 +3741,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["skills"]
readonly delivery?: {
@@ -3765,7 +3764,7 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3788,12 +3787,12 @@ export type SessionCommandInput = {
readonly id: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionCommandOutput = { data: SessionInboxUser }["data"]
export type SessionCommandOutput = { data: SessionPendingUser }["data"]
export type SessionSkillInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3823,7 +3822,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["id"]
readonly text: {
@@ -3831,7 +3830,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["text"]
readonly description?: {
@@ -3839,7 +3838,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["description"]
readonly metadata?: {
@@ -3847,7 +3846,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["metadata"]
readonly delivery?: {
@@ -3855,7 +3854,7 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["delivery"]
readonly resume?: {
@@ -3863,12 +3862,12 @@ export type SessionSyntheticInput = {
readonly text: string
readonly description?: string | null
readonly metadata?: { readonly [x: string]: JsonValue }
readonly delivery?: ("steer" | "queue") | null
readonly delivery?: "steer" | "queue" | null
readonly resume?: boolean | null
}["resume"]
}
export type SessionSyntheticOutput = { data: SessionInboxSynthetic }["data"]
export type SessionSyntheticOutput = { data: SessionPendingSynthetic }["data"]
export type SessionShellInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
@@ -3880,14 +3879,10 @@ export type SessionShellOutput = void
export type SessionCompactInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly id?: { readonly id?: string | undefined; readonly delivery?: ("steer" | "queue") | undefined }["id"]
readonly delivery?: {
readonly id?: string | undefined
readonly delivery?: ("steer" | "queue") | undefined
}["delivery"]
readonly id?: { readonly id?: string | undefined }["id"]
}
export type SessionCompactOutput = { data: SessionInboxCompaction }["data"]
export type SessionCompactOutput = { data: SessionPendingCompaction }["data"]
export type SessionWaitInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
@@ -3913,30 +3908,30 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionPendingListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
export type SessionPendingListOutput = { data: Array<SessionPendingInfo> }["data"]
export type SessionInboxCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingCancelInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxCancelOutput = void
export type SessionPendingCancelOutput = void
export type SessionInboxSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingSteerInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxSteerOutput = void
export type SessionPendingSteerOutput = void
export type SessionInboxQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inboxID: string }["sessionID"]
readonly inboxID: { readonly sessionID: string; readonly inboxID: string }["inboxID"]
export type SessionPendingQueueInput = {
readonly sessionID: { readonly sessionID: string; readonly inputID: string }["sessionID"]
readonly inputID: { readonly sessionID: string; readonly inputID: string }["inputID"]
}
export type SessionInboxQueueOutput = void
export type SessionPendingQueueOutput = void
export type SessionInstructionsEntryListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
+2 -4
View File
@@ -213,7 +213,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
expect(result.created.id).toBe("ses_test")
expect(Object.getPrototypeOf(result.admitted)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.payload)).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.admitted.data)).toBe(Object.prototype)
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" })
@@ -271,7 +271,7 @@ const admission = {
id: "msg_test",
sessionID: "ses_test",
type: "user",
payload: { text: "Hello" },
data: { text: "Hello" },
delivery: "steer",
timeCreated: 1_717_171_717_000,
},
@@ -280,8 +280,6 @@ const admission = {
const compactionAdmission = {
data: {
type: "compaction",
payload: {},
delivery: "queue",
id: "msg_compaction",
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
+11 -11
View File
@@ -373,7 +373,7 @@ test("session instructions methods use the public HTTP contract", async () => {
])
})
test("session.inbox.list uses the public HTTP contract", async () => {
test("session.pending.list uses the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const pending = [
{
@@ -381,7 +381,7 @@ test("session.inbox.list uses the public HTTP contract", async () => {
sessionID: "ses_test",
timeCreated: 1_717_171_717_000,
type: "user",
payload: { text: "Fix the failing tests" },
data: { text: "Fix the failing tests" },
delivery: "steer",
},
]
@@ -394,13 +394,13 @@ test("session.inbox.list uses the public HTTP contract", async () => {
},
})
const result = await client.session.inbox.list({ sessionID: "ses_test" })
const result = await client.session.pending.list({ sessionID: "ses_test" })
expect(result).toEqual(pending)
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/inbox" }])
expect(requests).toEqual([{ method: "GET", url: "http://localhost:3000/api/session/ses_test/pending" }])
})
test("session.inbox mutations use the public HTTP contract", async () => {
test("session.pending mutations use the public HTTP contract", async () => {
const requests: Array<{ method: string; url: string }> = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -411,14 +411,14 @@ test("session.inbox mutations use the public HTTP contract", async () => {
},
})
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
await client.session.pending.cancel({ sessionID: "ses_test", inputID: "msg_cancel" })
await client.session.pending.steer({ sessionID: "ses_test", inputID: "msg_steer" })
await client.session.pending.queue({ sessionID: "ses_test", inputID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/pending/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/pending/msg_queue/queue" },
])
})
-6
View File
@@ -19,12 +19,6 @@
"exports": {
".": "./src/index.ts"
},
"imports": {
"#transpile": {
"workerd": "./src/interpreter/transpile.workerd.ts",
"default": "./src/interpreter/transpile.node.ts"
}
},
"scripts": {
"build": "bun run script/build.ts",
"typecheck": "tsgo --noEmit",
+15 -6
View File
@@ -1,8 +1,6 @@
import { parse } from "acorn"
import { Cause, Effect, Scope } from "effect"
// #transpile: conditional import — full typescript on node/bun, an identity
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js"
import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js"
import type { Tools } from "../tools.js"
@@ -121,10 +119,21 @@ export const executeWithLimits = <const Provided extends Record<string, unknown>
}
const parseProgram = (code: string): ProgramNode => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (transpiled.error !== undefined) {
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
if (diagnostic) {
throw new InterpreterRuntimeError(
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
undefined,
"ParseError",
)
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
@@ -1,25 +0,0 @@
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// Full TypeScript transpilation on node/bun runtimes.
export const transpile = (source: string): TranspileResult => {
const transpiled = transpileModule(source, {
reportDiagnostics: true,
compilerOptions: {
target: ScriptTarget.ESNext,
module: ModuleKind.ESNext,
},
})
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
if (diagnostic) {
return {
outputText: transpiled.outputText,
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
}
}
return { outputText: transpiled.outputText }
}
@@ -1,11 +0,0 @@
export interface TranspileResult {
readonly outputText: string
readonly error?: string
}
// workerd profile: the typescript compiler is ~11 MiB and probes node
// internals at module init, so codemode programs are passed through
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
// error from the interpreter instead of a transpile diagnostic.
export const transpile = (source: string): TranspileResult => ({ outputText: source })
+2 -134
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"id": "00924d88-1842-4d71-ac74-5682ddc47e1c",
"prevIds": ["15060ec5-05f7-4b86-b2a5-9108609432b3"],
"ddl": [
{
"name": "account_state",
@@ -56,10 +56,6 @@
"name": "instruction_state",
"entityType": "tables"
},
{
"name": "session_inbox",
"entityType": "tables"
},
{
"name": "session_message",
"entityType": "tables"
@@ -846,76 +842,6 @@
"entityType": "columns",
"table": "instruction_state"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "session_id",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "type",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "delivery",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "enqueued_seq",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "session_inbox"
},
{
"type": "text",
"notNull": false,
@@ -1502,17 +1428,6 @@
"entityType": "fks",
"table": "instruction_state"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_inbox_session_id_session_v2_id_fk",
"entityType": "fks",
"table": "session_inbox"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
@@ -1637,13 +1552,6 @@
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
@@ -1734,46 +1642,6 @@
"entityType": "indexes",
"table": "permission"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "delivery",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_inbox_session_delivery_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "enqueued_seq",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "session_inbox_session_enqueued_seq_idx",
"entityType": "indexes",
"table": "session_inbox"
},
{
"columns": [
{
+31 -200
View File
@@ -6,8 +6,7 @@ import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
import { Database } from "./database/database.js"
import { EventSequenceTable, EventTable } from "./event/sql.js"
import type { Location } from "@opencode-ai/schema/location"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { Location } from "./location.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { isDeepStrictEqual } from "node:util"
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
@@ -93,16 +92,6 @@ export interface PublishOptions {
readonly commit?: (seq: number) => Effect.Effect<void>
}
export type PublishInput<D extends Event.DurableDefinition = Event.DurableDefinition> = readonly [
definition: D,
data: Event.Data<D>,
options?: PublishOptions,
]
export type PublishResult<I extends readonly PublishInput[]> = {
readonly [K in keyof I]: I[K] extends PublishInput<infer D> ? Event.Payload<D> : never
}
/** Marker/event union emitted by `log`. */
export type LogItem = Event.Payload | EventLog.Synced
@@ -135,9 +124,6 @@ export interface Interface {
data: Event.Data<D>,
options?: PublishOptions,
) => Effect.Effect<Event.Payload<D>>
readonly publishAll: <const I extends readonly [PublishInput, ...PublishInput[]]>(
events: I,
) => Effect.Effect<PublishResult<I>>
readonly subscribe: Subscribe
/**
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
@@ -183,9 +169,6 @@ export function configured(options?: Options) {
layer: Layer.effect(
Service,
Effect.gen(function* () {
// Deferred import: a static one would close the module cycle
// bus → location → project → bus and hit the node bindings in TDZ.
const { Location } = yield* Effect.promise(() => import("./location.js"))
const pubsub = {
live: yield* PubSub.unbounded<Event.Payload>(),
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
@@ -193,7 +176,6 @@ export function configured(options?: Options) {
}
const projectors = new Map<string, Subscriber[]>()
const listeners = new Array<Subscriber>()
const durableLocks = KeyedMutex.makeUnsafe<string>()
const { db } = yield* Database.Service
const logReadPageSize = options?.logReadPageSize ?? 512
const persist = options?.persist ?? false
@@ -403,23 +385,15 @@ export function configured(options?: Options) {
}),
)
if (definition?.durable) {
const aggregateID = (event.data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string")
return yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit).pipe(
Effect.as(event),
)
return yield* durableLocks.withLock(aggregateID)(
Effect.gen(function* () {
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (!committed) return event
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}),
)
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
if (committed) {
event = {
...event,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
}
yield* notify(event as Event.Payload, true)
return event
}
}
yield* notify(event as Event.Payload, false)
return event
@@ -470,144 +444,6 @@ export function configured(options?: Options) {
})
}
function publishAll<const I extends readonly [PublishInput, ...PublishInput[]]>(events: I) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const payloads = yield* Effect.forEach(events, ([definition, data, options]) =>
Effect.gen(function* () {
const aggregateID = (data as Record<string, unknown>)[definition.durable.aggregate]
if (typeof aggregateID !== "string") {
return yield* Effect.die(
new InvalidDurableEventError({
type: definition.type,
message: `Expected string aggregate field ${definition.durable.aggregate}`,
}),
)
}
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return {
definition,
aggregateID,
commit: options?.commit,
event: {
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
data,
} as Event.Payload,
}
}),
)
const aggregateID = payloads[0].aggregateID
if (payloads.some((item) => item.aggregateID !== aggregateID)) {
return yield* Effect.die(
new InvalidDurableEventError({
type: payloads[0].definition.type,
message: "Published events must belong to the same aggregate",
}),
)
}
return yield* durableLocks.withLock(aggregateID)(
Effect.uninterruptible(
Effect.gen(function* () {
const committed = yield* db
.transaction(
() =>
Effect.gen(function* () {
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
const firstSeq = (row?.seq ?? -1) + 1
const finalSeq = firstSeq + payloads.length - 1
const result = new Array<Event.Payload>()
const rows = new Array<typeof EventTable.$inferInsert>()
const ids = new Set<Event.ID>()
for (const [index, item] of payloads.entries()) {
const seq = firstSeq + index
const encoded = Schema.encodeUnknownSync(item.definition.data)(item.event.data) as Record<
string,
unknown
>
if (persist) {
if (ids.has(item.event.id))
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} appears more than once in the batch`,
}),
)
ids.add(item.event.id)
const stored = yield* db
.select({ aggregateID: EventTable.aggregate_id, seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.id, item.event.id))
.get()
.pipe(Effect.orDie)
if (stored)
yield* Effect.die(
new InvalidDurableEventError({
type: item.event.type,
message: `Event ${item.event.id} already exists at aggregate ${stored.aggregateID} sequence ${stored.seq}`,
}),
)
}
const event = {
...item.event,
durable: envelope(aggregateID, seq, item.definition.durable.version),
} as Event.Payload
for (const projector of projectors.get(
versionedType(item.definition.type, item.definition.durable.version),
) ?? []) {
yield* projector(event)
}
if (item.commit) yield* item.commit(seq)
if (persist)
rows.push({
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created),
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
result.push(event)
}
yield* db
.insert(EventSequenceTable)
.values([{ aggregate_id: aggregateID, seq: finalSeq }])
.onConflictDoUpdate({ target: EventSequenceTable.aggregate_id, set: { seq: finalSeq } })
.run()
.pipe(Effect.orDie)
if (persist) yield* db.insert(EventTable).values(rows).run().pipe(Effect.orDie)
return result
}),
{ behavior: "immediate" },
)
.pipe(Effect.orDie)
yield* Effect.forEach(
pubsub.durable.get(aggregateID) ?? [],
(wake) => PubSub.publish(wake, undefined),
{
discard: true,
},
)
yield* Effect.forEach(committed, (event) => notify(event, true), { discard: true })
return committed as PublishResult<I>
}),
),
)
})
}
function replay(
event: SerializedEvent,
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
@@ -619,31 +455,27 @@ export function configured(options?: Options) {
new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` }),
)
} else {
yield* durableLocks.withLock(event.aggregateID)(
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}),
)
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
const committed = yield* commitDurableEvent(definition, payload, {
seq: event.seq,
aggregateID: event.aggregateID,
ownerID: options?.ownerID,
strictOwner: options?.strictOwner,
})
if (committed && options?.publish) {
yield* notify(
{
...payload,
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
},
true,
)
}
}
})
}
@@ -860,7 +692,6 @@ export function configured(options?: Options) {
return Service.of({
publish,
publishAll,
subscribe,
log,
listen,
-2
View File
@@ -41,7 +41,6 @@ import m38 from "./migration/20260804233008_loose_psylocke.js"
import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
export const migrations = [
m00,
@@ -86,5 +85,4 @@ export const migrations = [
m39,
m40,
m41,
m42,
] satisfies DatabaseMigration.Migration[]
@@ -1,30 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812181746_session_inbox",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
})
},
}
export default migration
-18
View File
@@ -142,18 +142,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CONSTRAINT \`fk_instruction_state_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_inbox\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`type\` text NOT NULL,
\`payload\` text NOT NULL,
\`delivery\` text NOT NULL,
\`enqueued_seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
CONSTRAINT \`fk_session_inbox_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`session_message\` (
\`id\` text PRIMARY KEY,
@@ -230,12 +218,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_inbox_session_delivery_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`delivery\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
)
@@ -1,17 +0,0 @@
import { Effect, Layer, PlatformError } from "effect"
import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
export const spawner = make(() =>
Effect.fail(
PlatformError.systemError({
_tag: "Unknown",
module: "Environment",
method: "spawn",
description: "This location has no execution plane: no workspace is attached and the host cannot spawn processes",
}),
),
)
export const layer = Layer.succeed(ChildProcessSpawner, spawner)
export * as EnvironmentUnavailable from "./unavailable.js"
@@ -2,10 +2,9 @@ import { createRequire } from "node:module"
declare const OPENCODE_LIBC: string | undefined
// Lazy: on workerd import.meta.url is undefined and the watcher is never
// loaded, so createRequire must not run at module scope.
const require = createRequire(import.meta.url)
export default function load() {
const require = createRequire(import.meta.url)
const libc = typeof OPENCODE_LIBC === "undefined" ? undefined : OPENCODE_LIBC
return require(
process.env.OPENCODE_PARCEL_WATCHER_PATH ??
+7
View File
@@ -165,6 +165,8 @@ export const Options = Schema.Struct({
version: Schema.String,
}),
),
/** Set false on runtimes that cannot spawn child processes; local (stdio) servers report failed instead of connecting. */
stdio: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
@@ -498,6 +500,11 @@ export const layer = (options?: Options) =>
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
if (options?.stdio === false && entry.config.type === "local") {
entry.status = { status: "failed", error: "stdio MCP servers are unavailable in this runtime" }
yield* bus.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)
return
}
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
@@ -204,11 +204,10 @@ export const GithubCopilotPlugin = define({
for (const [id, model] of loaded.models) {
evt.model.update(item.provider.id, id, (draft) => Object.assign(draft, structuredClone(model)))
}
} else {
} else if (loaded.baseURL) {
for (const id of item.models.keys()) {
evt.model.update(item.provider.id, id, (model) => {
model.package = "@ai-sdk/github-copilot"
if (loaded.baseURL) model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
model.settings = Provider.mergeOverlay(model.settings, { baseURL: loaded.baseURL })
})
}
}
+19 -33
View File
@@ -5,9 +5,7 @@ import { ChildProcess } from "effect/unstable/process"
import { asc, desc } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
@@ -93,40 +91,28 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* upsertProject(db, project).pipe(Effect.orDie)
if (!project.vcs) return project
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
if (project.directory !== project.canonical)
directories.push({
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact (copy.ts registers copy directories directly; those never strand
// sessions and never announce). The row insert commits atomically with the
// event, so a crash between checks retries on the next resolve instead of
// stranding the announcement. The in-flight set keeps concurrent resolves
// from publishing the same fact twice.
for (const item of directories) {
const key = item.projectID + "\u0000" + item.directory
if (announcing.has(key)) continue
announcing.add(key)
yield* Effect.gen(function* () {
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
yield* bus.publish(
Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
)
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
}
yield* db
.transaction((tx) =>
Effect.gen(function* () {
yield* upsertProject(tx, project)
if (!project.vcs) return
yield* projectDirectories.create({ projectID: project.id, directory: project.canonical }, tx)
if (project.directory === project.canonical) return
yield* projectDirectories.create(
{
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
},
tx,
)
}),
)
.pipe(Effect.orDie)
return project
})
@@ -264,5 +250,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
deps: [Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
})
+57 -60
View File
@@ -31,7 +31,7 @@ import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/err
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { SessionInbox } from "./session/inbox.js"
import { SessionPending } from "./session/pending.js"
import { InstructionState } from "./session/instruction-state.js"
import { SessionGenerate } from "./session/generate.js"
import { Snapshot } from "./snapshot.js"
@@ -99,7 +99,6 @@ type CreateInput = CreateBaseInput &
type CompactInput = {
id?: SessionMessage.ID
sessionID: SessionSchema.ID
delivery?: SessionInbox.Delivery
}
type ForkInput = {
@@ -134,11 +133,14 @@ export class CompactionConflictError extends Schema.TaggedErrorClass<CompactionC
export class BusyError extends Schema.TaggedErrorClass<BusyError>()("Session.BusyError", {
sessionID: SessionSchema.ID,
}) {}
export class InboxConflictError extends Schema.TaggedErrorClass<InboxConflictError>()("Session.InboxConflictError", {
sessionID: SessionSchema.ID,
inboxID: SessionMessage.ID,
}) {}
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export class PendingInputConflictError extends Schema.TaggedErrorClass<PendingInputConflictError>()(
"Session.PendingInputConflictError",
{
sessionID: SessionSchema.ID,
inputID: SessionMessage.ID,
},
) {}
type PendingInputRef = { readonly sessionID: SessionSchema.ID; readonly inputID: SessionMessage.ID }
export class SkillNotFoundError extends Schema.TaggedErrorClass<SkillNotFoundError>()("Session.SkillNotFoundError", {
skill: Skill.ID,
}) {}
@@ -186,10 +188,10 @@ export interface Interface {
* ordered by admission. Includes unpromoted user and synthetic inputs and
* unhandled compaction barriers.
*/
readonly inbox: (sessionID: SessionSchema.ID) => Effect.Effect<SessionInbox.Info[], NotFoundError>
readonly cancelInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly steerInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly queueInbox: (input: InboxItemRef) => Effect.Effect<void, NotFoundError | InboxConflictError>
readonly pending: (sessionID: SessionSchema.ID) => Effect.Effect<SessionPending.Info[], NotFoundError>
readonly cancelPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly steerPending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
readonly queuePending: (input: PendingInputRef) => Effect.Effect<void, NotFoundError | PendingInputConflictError>
/**
* Durable, ordered session log read. Replays durable session bus after
* the exclusive `after` cursor, emits a `Synced` marker at the captured
@@ -209,7 +211,6 @@ export interface Interface {
sessionID: SessionSchema.ID
directory: AbsolutePath
workspaceID?: Location.Ref["workspaceID"]
delivery?: SessionInbox.Delivery
}) => Effect.Effect<void, NotFoundError | DestinationNotFoundError | DestinationNotDirectoryError>
readonly prompt: (input: {
id?: SessionMessage.ID
@@ -219,9 +220,9 @@ export interface Interface {
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
}) => Effect.Effect<SessionPending.User, NotFoundError | PromptConflictError | AttachmentError | SkillNotFoundError>
/** Generates text from current Session context without admitting input or mutating history. */
readonly generate: (input: {
sessionID: SessionSchema.ID
@@ -237,10 +238,10 @@ export interface Interface {
files?: PromptInput.Prompt["files"]
agents?: PromptInput.Prompt["agents"]
skills?: PromptInput.Prompt["skills"]
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<
SessionInbox.User,
SessionPending.User,
| NotFoundError
| PromptConflictError
| AttachmentError
@@ -261,7 +262,7 @@ export interface Interface {
}) => Effect.Effect<void, NotFoundError | SkillNotFoundError>
readonly compact: (
input: CompactInput,
) => Effect.Effect<SessionInbox.Compaction, NotFoundError | CompactionConflictError>
) => Effect.Effect<SessionPending.Compaction, NotFoundError | CompactionConflictError>
readonly wait: (id: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly active: Effect.Effect<ReadonlySet<SessionSchema.ID>>
readonly background: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
@@ -273,9 +274,9 @@ export interface Interface {
text: string
description?: string
metadata?: Record<string, unknown>
delivery?: SessionInbox.Delivery
delivery?: SessionPending.Delivery
resume?: boolean
}) => Effect.Effect<SessionInbox.Synthetic, NotFoundError | SyntheticConflictError>
}) => Effect.Effect<SessionPending.Synthetic, NotFoundError | SyntheticConflictError>
readonly revert: {
readonly stage: (input: {
sessionID: SessionSchema.ID
@@ -320,12 +321,12 @@ const layer = Layer.effect(
),
)
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: InboxItemRef) {
const pendingConflict = Effect.fn("Session.pendingConflict")(function* (input: PendingInputRef) {
yield* result.get(input.sessionID)
return yield* new InboxConflictError(input)
return yield* new PendingInputConflictError(input)
})
const mutatePending = (
input: InboxItemRef,
input: PendingInputRef,
mutation: (
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID },
@@ -334,9 +335,9 @@ const layer = Layer.effect(
) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* mutation(bus, { sessionID: input.sessionID, id: input.inboxID }).pipe(
yield* mutation(bus, { sessionID: input.sessionID, id: input.inputID }).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
defect instanceof SessionPending.LifecycleConflict ? pendingConflict(input) : Effect.die(defect),
),
)
if (wake) yield* execution.wake(input.sessionID)
@@ -528,13 +529,13 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
inbox: Effect.fn("Session.inbox")(function* (sessionID) {
pending: Effect.fn("Session.pending")(function* (sessionID) {
yield* result.get(sessionID)
return yield* SessionInbox.list(db, sessionID)
return yield* SessionPending.list(db, sessionID)
}),
cancelInbox: Effect.fn("Session.cancelInbox")((input) => mutatePending(input, SessionInbox.cancel)),
steerInbox: Effect.fn("Session.steerInbox")((input) => mutatePending(input, SessionInbox.steer, true)),
queueInbox: Effect.fn("Session.queueInbox")((input) => mutatePending(input, SessionInbox.queue)),
cancelPending: Effect.fn("Session.cancelPending")((input) => mutatePending(input, SessionPending.cancel)),
steerPending: Effect.fn("Session.steerPending")((input) => mutatePending(input, SessionPending.steer, true)),
queuePending: Effect.fn("Session.queuePending")((input) => mutatePending(input, SessionPending.queue)),
log: (input) =>
Stream.unwrap(
result
@@ -563,25 +564,25 @@ const layer = Layer.effect(
skills,
).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
const admittedInput = SessionPending.Message.make({
type: "user",
payload: { ...prompt, metadata: input.metadata },
data: { ...prompt, metadata: input.metadata },
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id: messageID,
sessionID: input.sessionID,
item: admittedInput,
input: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new PromptConflictError({ sessionID: input.sessionID, messageID })
: Effect.die(defect),
),
)
if (
admitted.type !== "user" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
)
return yield* new PromptConflictError({ sessionID: input.sessionID, messageID })
if (input.resume !== false) {
@@ -639,9 +640,7 @@ const layer = Layer.effect(
yield* execution.awaitIdle(input.sessionID)
const started = yield* Effect.gen(function* () {
const shell = yield* Shell.Service
return yield* shell
.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
.pipe(Effect.orDie)
return yield* shell.create({ command: input.command, cwd: session.location.directory, timeout: 0 })
}).pipe(Effect.provide(locations.get(session.location)))
yield* bus.publish(
SessionEvent.Shell.Started,
@@ -739,35 +738,33 @@ const layer = Layer.effect(
const info = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) return yield* new DestinationNotFoundError({ directory })
if (info.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory && current.location.workspaceID === input.workspaceID) return
const project = yield* projects.resolve(directory)
yield* persistProject(project)
const item = SessionInbox.Item.make({
type: "move",
payload: {
if ((yield* execution.active).has(input.sessionID)) {
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
}
yield* bus.publish(
SessionEvent.Moved,
{
sessionID: input.sessionID,
location: Location.Ref.make({ directory, workspaceID: input.workspaceID }),
projectID: project.id,
subpath: RelativePath.make(path.relative(project.directory, directory).replaceAll("\\", "/")),
},
delivery: input.delivery ?? "steer",
})
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID: input.sessionID,
item,
})
yield* execution.wake(input.sessionID)
{ location: current.location },
)
}),
compact: Effect.fn("Session.compact")(function* (input) {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admitted = yield* SessionInbox.admitCompaction(db, bus, {
const admitted = yield* SessionPending.admitCompaction(db, bus, {
id: inputID,
sessionID: input.sessionID,
delivery: input.delivery ?? "queue",
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new CompactionConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
@@ -807,29 +804,29 @@ const layer = Layer.effect(
Effect.gen(function* () {
yield* result.get(input.sessionID)
const inputID = input.id ?? SessionMessage.ID.create()
const admittedInput = SessionInbox.Item.make({
const admittedInput = SessionPending.Message.make({
type: "synthetic",
payload: {
data: {
text: input.text,
description: input.description,
metadata: input.metadata,
},
delivery: input.delivery ?? "steer",
})
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id: inputID,
sessionID: input.sessionID,
item: admittedInput,
input: admittedInput,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInbox.LifecycleConflict
defect instanceof SessionPending.LifecycleConflict
? new SyntheticConflictError({ sessionID: input.sessionID, inputID })
: Effect.die(defect),
),
)
if (
admitted.type !== "synthetic" ||
!SessionInbox.equivalent(admitted, { sessionID: input.sessionID, item: admittedInput })
!SessionPending.equivalent(admitted, { sessionID: input.sessionID, input: admittedInput })
)
return yield* new SyntheticConflictError({ sessionID: input.sessionID, inputID })
if (input.resume !== false && !(yield* result.get(input.sessionID)).revert)
@@ -842,7 +839,7 @@ const layer = Layer.effect(
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
if (options?.continue && (yield* SessionPending.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
),
+6 -10
View File
@@ -89,7 +89,6 @@ export type ManualInput = {
readonly session: SessionSchema.Info
readonly messages: readonly SessionMessage.Info[]
readonly inputID: SessionMessage.ID
readonly started?: boolean
}
type RequiredInput = Omit<AutoInput, "ref">
@@ -103,7 +102,6 @@ type Plan = {
readonly prompt: string
readonly recent: string
readonly inputID?: SessionMessage.ID
readonly started?: boolean
}
export type Outcome =
@@ -250,13 +248,12 @@ const make = (dependencies: Dependencies) => {
return { status: "failed" as const, error: input.error }
})
const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) {
if (!plan.started)
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
yield* dependencies.bus.publish(SessionEvent.Compaction.Started, {
sessionID: plan.session.id,
reason: plan.reason,
recent: plan.recent,
inputID: plan.inputID,
})
const chunks: string[] = []
let failure: SessionError.Error | undefined
@@ -411,7 +408,6 @@ const make = (dependencies: Dependencies) => {
cost: resolved.cost,
reason: "manual",
inputID: input.inputID,
started: input.started,
...content,
})
})
+10 -25
View File
@@ -1,6 +1,6 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -67,17 +67,16 @@ export const layer = Layer.effect(
const releaseOnCommit = (sessionID: SessionSchema.ID) => ({
commit: () => store.release(sessionID),
})
function drain(
sessionID: SessionSchema.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () {
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: Effect.fnUntraced(function* (sessionID: SessionSchema.ID, force) {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }),
).pipe(
return yield* SessionRunner.Service.use((runner) => runner.drain({ sessionID, force })).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
Cause.hasInterruptsOnly(cause)
@@ -85,17 +84,7 @@ export const layer = Layer.effect(
: Effect.logError("Failed to drain Session", cause).pipe(Effect.annotateLogs({ sessionID })),
),
)
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
started: (sessionID) =>
reportLifecycle(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force) => drain(sessionID, force),
}),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
@@ -127,10 +116,6 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
-505
View File
@@ -1,505 +0,0 @@
export * as SessionInbox from "./inbox.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
} from "@opencode-ai/schema/session-inbox"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionInboxTable, SessionMessageTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export {
Compaction,
CompactionPayload,
Delivery,
Info,
Item,
Move,
MovePayload,
Synthetic,
SyntheticPayload,
User,
UserPayload,
}
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserPayload)
const encodeUser = Schema.encodeSync(UserPayload)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
const encodeSynthetic = Schema.encodeSync(SyntheticPayload)
const decodeCompaction = Schema.decodeUnknownSync(CompactionPayload)
const encodeCompaction = Schema.encodeSync(CompactionPayload)
const decodeMove = Schema.decodeUnknownSync(MovePayload)
const encodeMove = Schema.encodeSync(MovePayload)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export const serialized = <A, E, R>(sessionID: SessionSchema.ID, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(sessionID)(effect)
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()("SessionInbox.LifecycleConflict", {
id: SessionMessage.ID,
}) {}
const fromRow = (row: typeof SessionInboxTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction")
return Compaction.make({
...base,
type: "compaction",
payload: decodeCompaction(row.payload),
delivery: row.delivery,
})
if (row.type === "move")
return Move.make({ ...base, type: "move", payload: decodeMove(row.payload), delivery: row.delivery })
if (row.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(row.payload),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(row.payload),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionInbox.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
payload: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
payload: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionInbox.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.item.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InboxEnqueued, {
inboxID: request.id,
sessionID: request.sessionID,
item: request.item,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(Info.make({ ...base, ...request.item }))
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.item.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionInbox.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.Interface,
input: { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID; readonly delivery: Delivery },
) {
const admitted = yield* admit(db, bus, {
id: input.id,
sessionID: input.sessionID,
item: Item.make({ type: "compaction", payload: {}, delivery: input.delivery }),
})
if (admitted.type === "compaction") return admitted
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly enqueuedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly item: Item
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionInboxTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.item.type,
payload:
request.item.type === "user"
? encodeUser(request.item.payload)
: request.item.type === "synthetic"
? encodeSynthetic(request.item.payload)
: request.item.type === "compaction"
? encodeCompaction(request.item.payload)
: encodeMove(request.item.payload),
delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectDelivered = Effect.fn("SessionInbox.projectDelivered")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(and(eq(SessionInboxTable.id, input.id), eq(SessionInboxTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return fromRow(deleted)
})
export const projectCancelled = Effect.fn("SessionInbox.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
or(eq(SessionInboxTable.delivery, "queue"), eq(SessionInboxTable.delivery, "steer")),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionInbox.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionInboxTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionInboxTable.id, input.id),
eq(SessionInboxTable.session_id, input.sessionID),
eq(SessionInboxTable.delivery, input.from),
),
)
.returning({ id: SessionInboxTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectDeliveryChanged = Effect.fn("SessionInbox.projectDeliveryChanged")(
(db: DatabaseService, input: PendingRef & { readonly delivery: Delivery }) =>
projectDelivery(db, {
...input,
from: input.delivery === "steer" ? "queue" : "steer",
to: input.delivery,
}),
)
export const list = Effect.fn("SessionInbox.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
export const nextQueued = Effect.fn("SessionInbox.nextQueued")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return row ? fromRow(row) : undefined
})
/**
* Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionInbox.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
const row = yield* db
.select({ id: SessionInboxTable.id })
.from(SessionInboxTable)
.where(
and(
eq(SessionInboxTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionInboxTable.delivery, "steer"), eq(SessionInboxTable.delivery, "queue"))
: eq(SessionInboxTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (input: Info, expected: { readonly sessionID: SessionSchema.ID; readonly item: Item }) => {
if (
input.type !== expected.item.type ||
input.delivery !== expected.item.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.item.type === "user")
return JSON.stringify(encodeUser(input.payload)) === JSON.stringify(encodeUser(expected.item.payload))
if (input.type === "synthetic" && expected.item.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.payload)) === JSON.stringify(encodeSynthetic(expected.item.payload))
if (input.type === "compaction" && expected.item.type === "compaction") return true
if (input.type === "move" && expected.item.type === "move")
return JSON.stringify(encodeMove(input.payload)) === JSON.stringify(encodeMove(expected.item.payload))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
serialized(input.sessionID, effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionInbox.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxCancelled, {
sessionID: input.sessionID,
inboxID: input.id,
}),
),
)
export const steer = Effect.fn("SessionInbox.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "steer",
}),
),
)
export const queue = Effect.fn("SessionInbox.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InboxDeliveryChanged, {
sessionID: input.sessionID,
inboxID: input.id,
delivery: "queue",
}),
),
)
const publish = Effect.fn("SessionInbox.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionInboxTable.$inferSelect>,
) {
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* serialized(
sessionID,
Effect.gen(function* () {
const steers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
return (
promoted +
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
)
}),
)
})
+11 -4
View File
@@ -109,10 +109,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.renamed": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
"session.inbox.enqueued": () => Effect.void,
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.input.promoted": () => Effect.void,
"session.input.admitted": () => Effect.void,
"session.input.cancelled": () => Effect.void,
"session.input.steered": () => Effect.void,
"session.input.queued": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
@@ -194,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.retry = undefined
draft.error = undefined
draft.finish = undefined
draft.time.started = undefined
draft.time.generated = undefined
draft.time.completed = undefined
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
}),
@@ -229,6 +232,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -247,6 +251,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.cost = event.data.cost
draft.tokens = castDraft(event.data.tokens)
}
draft.time.generated = event.data.generated
if (event.data.snapshot || event.data.files)
draft.snapshot = {
...draft.snapshot,
@@ -257,6 +262,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.text.started": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
if (draft.time.completed === undefined) draft.time.started ??= event.created
draft.content.push(castDraft(SessionMessage.AssistantText.make({ type: "text", text: "" })))
})
},
@@ -379,6 +385,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}
})
},
"session.compaction.admitted": () => Effect.void,
"session.compaction.started": (event) =>
adapter.appendMessage(
SessionMessage.CompactionRunning.make({
+545
View File
@@ -0,0 +1,545 @@
export * as SessionPending from "./pending.js"
import { and, asc, eq, or } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
import {
Compaction,
Delivery,
Info,
Message,
Synthetic,
SyntheticData,
User,
UserData,
} from "@opencode-ai/schema/session-pending"
import type { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { KeyedMutex } from "../effect/keyed-mutex.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable, SessionPendingTable } from "./sql.js"
type DatabaseService = Database.Interface["db"]
export { Compaction, Delivery, Info, Message, Synthetic, SyntheticData, User, UserData }
/**
* Which pending input `promote` may consume: "steer" promotes steers only (a step
* boundary mid-work), while "input" also allows one queued input when no steers are
* waiting (the idle boundary, where the Session picks up fresh work).
*/
export type Promotable = "input" | "steer"
const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict>()(
"SessionPending.LifecycleConflict",
{
id: SessionMessage.ID,
},
) {}
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
const base = {
id: SessionMessage.ID.make(row.id),
sessionID: SessionSchema.ID.make(row.session_id),
timeCreated: DateTime.makeUnsafe(row.time_created),
}
if (row.type === "compaction") return Compaction.make({ ...base, type: "compaction" })
if (!row.delivery) throw new LifecycleConflict({ id: base.id })
if (row.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(row.data),
delivery: row.delivery,
})
if (row.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(row.data),
delivery: row.delivery,
})
throw new LifecycleConflict({ id: base.id })
}
export const find = Effect.fn("SessionPending.find")(function* (db: DatabaseService, id: SessionMessage.ID) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.id, id))
.get()
.pipe(Effect.orDie)
return row === undefined ? undefined : fromRow(row)
})
export const compaction = Effect.fn("SessionPending.compaction")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.type, "compaction")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!row) return
const entry = fromRow(row)
return entry.type === "compaction" ? entry : undefined
})
const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
id: SessionMessage.ID,
delivery: Delivery,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, id))
.get()
.pipe(Effect.orDie)
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
...base,
type: "user",
data: decodeUser(message),
})
if (message.type === "synthetic")
return Synthetic.make({
...base,
type: "synthetic",
data: decodeSynthetic(message),
})
return yield* Effect.die(new LifecycleConflict({ id }))
})
export const admit = Effect.fn("SessionPending.admit")(function* (
db: DatabaseService,
bus: Bus.Interface,
request: {
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
},
) {
const existing = yield* find(db, request.id)
if (existing !== undefined) {
if (existing.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: request.id }))
return existing
}
const promoted = yield* promotedFromMessage(db, request.sessionID, request.id, request.input.delivery)
if (promoted !== undefined) return promoted
return yield* bus
.publish(SessionEvent.InputAdmitted, {
inputID: request.id,
sessionID: request.sessionID,
input: request.input,
})
.pipe(
Effect.flatMap((event) => {
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
}
return Effect.succeed(
request.input.type === "user"
? User.make({ ...base, ...request.input })
: Synthetic.make({ ...base, ...request.input }),
)
}),
Effect.catchDefect((defect) =>
find(db, request.id).pipe(
Effect.flatMap((stored) =>
stored?.type === request.input.type ? Effect.succeed(stored) : Effect.die(defect),
),
),
),
)
})
export const admitCompaction = Effect.fn("SessionPending.admitCompaction")(function* (
db: DatabaseService,
bus: Bus.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* compaction(db, input.sessionID)
if (pending) return pending
return yield* bus
.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 compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) =>
stored ? Effect.succeed(stored) : Effect.die(new LifecycleConflict({ id: input.id })),
),
)
}),
Effect.catchDefect((defect) =>
compaction(db, input.sessionID).pipe(
Effect.flatMap((stored) => (stored ? Effect.succeed(stored) : Effect.die(defect))),
),
),
)
}),
)
})
export const projectAdmitted = Effect.fn("SessionPending.projectAdmitted")(function* (
db: DatabaseService,
request: {
readonly admittedSeq: number
readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID
readonly input: Message
readonly timeCreated: DateTime.Utc
},
) {
const message = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, request.id))
.get()
.pipe(Effect.orDie)
if (message !== undefined) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
const stored = yield* db
.insert(SessionPendingTable)
.values({
id: request.id,
session_id: request.sessionID,
type: request.input.type,
data: request.input.type === "user" ? encodeUser(request.input.data) : encodeSynthetic(request.input.data),
delivery: request.input.delivery,
admitted_seq: request.admittedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!stored) return yield* Effect.die(new LifecycleConflict({ id: request.id }))
})
export const projectCompactionAdmitted = Effect.fn("SessionPending.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(SessionPendingTable)
.values({
id: input.id,
session_id: input.sessionID,
type: "compaction",
data: {},
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* compaction(db, input.sessionID)
if (pending) return pending
return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
/**
* Consume one pending row at promotion. The row's content feeds the projected
* message insert inside the same event transaction; the deleted row is what
* makes the table pending-only.
*/
export const projectPromoted = Effect.fn("SessionPending.projectPromoted")(function* (
db: DatabaseService,
input: PendingRef,
) {
if (yield* compaction(db, input.sessionID)) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.id, input.id), eq(SessionPendingTable.session_id, input.sessionID)))
.returning()
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
const stored = fromRow(deleted)
if (stored.type === "compaction") return yield* Effect.die(new LifecycleConflict({ id: input.id }))
return stored
})
export const projectCancelled = Effect.fn("SessionPending.projectCancelled")(function* (
db: DatabaseService,
input: PendingRef,
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
or(eq(SessionPendingTable.delivery, "queue"), eq(SessionPendingTable.delivery, "steer")),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!deleted) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
const projectDelivery = Effect.fn("SessionPending.projectDelivery")(function* (
db: DatabaseService,
input: PendingRef & { readonly from: Delivery; readonly to: Delivery },
) {
const updated = yield* db
.update(SessionPendingTable)
.set({ delivery: input.to })
.where(
and(
eq(SessionPendingTable.id, input.id),
eq(SessionPendingTable.session_id, input.sessionID),
eq(SessionPendingTable.delivery, input.from),
),
)
.returning({ id: SessionPendingTable.id })
.get()
.pipe(Effect.orDie)
if (!updated) return yield* Effect.die(new LifecycleConflict({ id: input.id }))
})
export const projectSteered = Effect.fn("SessionPending.projectSteered")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "queue", to: "steer" }),
)
export const projectQueued = Effect.fn("SessionPending.projectQueued")((db: DatabaseService, input: PendingRef) =>
projectDelivery(db, { ...input, from: "steer", to: "queue" }),
)
export const settleCompaction = Effect.fn("SessionPending.settleCompaction")(function* (
db: DatabaseService,
input: { readonly sessionID: SessionSchema.ID },
) {
const deleted = yield* db
.delete(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, input.sessionID), eq(SessionPendingTable.type, "compaction")))
.returning()
.get()
.pipe(Effect.orDie)
if (deleted) {
const stored = fromRow(deleted)
return stored.type === "compaction" ? stored : yield* Effect.die(new LifecycleConflict({ id: stored.id }))
}
return undefined
})
export const list = Effect.fn("SessionPending.list")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const rows = yield* db
.select()
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return rows.map(fromRow)
})
/**
* Which pending rows count: "any" counts every row including compaction, while
* delivery scopes are blocked behind a pending compaction barrier. "input" means
* any model-facing input, steered or queued.
*/
export type Scope = "any" | "input" | Delivery
export const has = Effect.fn("SessionPending.has")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
scope: Scope,
) {
if (scope !== "any" && (yield* compaction(db, sessionID))) return false
const row = yield* db
.select({ id: SessionPendingTable.id })
.from(SessionPendingTable)
.where(
and(
eq(SessionPendingTable.session_id, sessionID),
scope === "any"
? undefined
: scope === "input"
? or(eq(SessionPendingTable.delivery, "steer"), eq(SessionPendingTable.delivery, "queue"))
: eq(SessionPendingTable.delivery, scope),
),
)
.limit(1)
.get()
.pipe(Effect.orDie)
return row !== undefined
})
export const equivalent = (
input: User | Synthetic,
expected: { readonly sessionID: SessionSchema.ID; readonly input: Message },
) => {
if (
input.type !== expected.input.type ||
input.delivery !== expected.input.delivery ||
input.sessionID !== expected.sessionID
)
return false
if (input.type === "user" && expected.input.type === "user")
return JSON.stringify(encodeUser(input.data)) === JSON.stringify(encodeUser(expected.input.data))
if (input.type === "synthetic" && expected.input.type === "synthetic")
return JSON.stringify(encodeSynthetic(input.data)) === JSON.stringify(encodeSynthetic(expected.input.data))
return false
}
const publishMutation = <A, E, R>(input: PendingRef, effect: Effect.Effect<A, E, R>) =>
inboxLocks.withLock(input.sessionID)(effect).pipe(Effect.asVoid)
export const cancel = Effect.fn("SessionPending.cancel")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputCancelled, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const steer = Effect.fn("SessionPending.steer")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputSteered, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
export const queue = Effect.fn("SessionPending.queue")((bus: Bus.Interface, input: PendingRef) =>
publishMutation(
input,
bus.publish(SessionEvent.InputQueued, {
sessionID: input.sessionID,
inputID: input.id,
}),
),
)
const publish = Effect.fn("SessionPending.publish")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
rows: ReadonlyArray<typeof SessionPendingTable.$inferSelect>,
) {
if (yield* compaction(db, sessionID)) return 0
yield* Effect.forEach(
rows,
(row) => {
const entry = fromRow(row)
if (entry.type === "compaction") return Effect.die(new LifecycleConflict({ id: entry.id }))
return bus
.publish(SessionEvent.InputPromoted, {
sessionID,
inputID: entry.id,
})
.pipe(
Effect.catchDefect((defect) =>
defect instanceof LifecycleConflict
? promotedFromMessage(db, sessionID, entry.id, entry.delivery).pipe(
Effect.flatMap((stored) => (stored !== undefined ? Effect.void : Effect.die(defect))),
)
: Effect.die(defect),
),
)
},
{ discard: true },
)
return rows.length
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
*/
export const promote = Effect.fn("SessionPending.promote")(function* (
db: DatabaseService,
bus: Bus.Interface,
sessionID: SessionSchema.ID,
scope: Promotable,
) {
return yield* inboxLocks.withLock(sessionID)(
Effect.gen(function* () {
if (yield* compaction(db, sessionID)) return 0
const steers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
if (steers.length > 0 || scope === "steer") return yield* publish(db, bus, sessionID, steers)
const queued = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "queue")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!queued) return 0
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* db
.select()
.from(SessionPendingTable)
.where(and(eq(SessionPendingTable.session_id, sessionID), eq(SessionPendingTable.delivery, "steer")))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie)
return promoted + (yield* publish(db, bus, sessionID, arrivedSteers))
}),
)
})
+50 -75
View File
@@ -1,8 +1,7 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
import { and, asc, desc, eq, gt, gte, lt, lte, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -11,15 +10,12 @@ import { Model } from "../model.js"
import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { SessionPending } from "./pending.js"
import { Workspace } from "../workspace.js"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Event } from "@opencode-ai/schema/project-directories"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
@@ -439,47 +435,6 @@ const layer = Layer.effectDiscard(
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
// Sessions whose ownership came from the directory's previous resolution
// follow its new identity. Location, transcript, instructions, and recency
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
const rows = yield* db
.select({ id: SessionTable.id, directory: SessionTable.directory })
.from(SessionTable)
.where(
and(
inArray(SessionTable.project_id, stale),
// Lexicographic range narrows the scan to prefix neighbors without
// LIKE escaping; FSUtil.contains below decides containment exactly.
gte(SessionTable.directory, event.data.directory),
lte(SessionTable.directory, AbsolutePath.make(event.data.directory + "\uffff")),
),
)
.all()
.pipe(Effect.orDie)
yield* Effect.forEach(
rows,
(row) => {
if (!FSUtil.contains(event.data.directory, row.directory)) return Effect.void
return db
.update(SessionTable)
.set({
project_id: event.data.projectID,
path: RelativePath.make(path.relative(event.data.directory, row.directory).replaceAll("\\", "/")),
// Self-assignment suppresses the column's $onUpdate: adoption is not activity.
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, row.id))
.run()
.pipe(Effect.orDie)
},
{ discard: true },
)
}),
)
yield* bus.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
)
@@ -515,15 +470,14 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.UsageRecorded, (event) => applyUsage(db, event.data.sessionID, event.data))
yield* bus.project(SessionEvent.Forked, (event) => projectFork(db, event))
yield* bus.project(SessionEvent.InboxDelivered, (event) =>
yield* bus.project(SessionEvent.InputPromoted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const input = yield* SessionInbox.projectDelivered(db, {
id: event.data.inboxID,
const input = yield* SessionPending.projectPromoted(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
})
if (input.type === "compaction" || input.type === "move") return
yield* insertMessage(
db,
event,
@@ -531,33 +485,33 @@ const layer = Layer.effectDiscard(
? {
id: input.id,
type: "user",
metadata: input.payload.metadata,
text: input.payload.text,
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
metadata: input.data.metadata,
text: input.data.text,
files: input.data.files,
agents: input.data.agents,
skills: input.data.skills,
time: { created: event.created },
}
: {
id: input.id,
type: "synthetic",
text: input.payload.text,
description: input.payload.description,
metadata: input.payload.metadata,
text: input.data.text,
description: input.data.description,
metadata: input.data.metadata,
time: { created: event.created },
},
)
}),
)
yield* bus.project(SessionEvent.InboxEnqueued, (event) =>
yield* bus.project(SessionEvent.InputAdmitted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
yield* SessionInbox.projectAdmitted(db, {
enqueuedSeq: event.durable.seq,
id: event.data.inboxID,
yield* SessionPending.projectAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
item: event.data.item,
input: event.data.input,
timeCreated: event.created,
})
yield* db
@@ -568,17 +522,34 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
}),
)
yield* bus.project(SessionEvent.InboxCancelled, (event) =>
SessionInbox.projectCancelled(db, {
id: event.data.inboxID,
yield* bus.project(SessionEvent.InputCancelled, (event) =>
SessionPending.projectCancelled(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.project(SessionEvent.InboxDeliveryChanged, (event) =>
SessionInbox.projectDeliveryChanged(db, {
id: event.data.inboxID,
yield* bus.project(SessionEvent.InputSteered, (event) =>
SessionPending.projectSteered(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.InputQueued, (event) =>
SessionPending.projectQueued(db, {
id: event.data.inputID,
sessionID: event.data.sessionID,
}),
)
yield* bus.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"))
yield* SessionPending.projectCompactionAdmitted(db, {
admittedSeq: event.durable.seq,
id: event.data.inputID,
sessionID: event.data.sessionID,
timeCreated: event.created,
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
@@ -625,6 +596,8 @@ const layer = Layer.effectDiscard(
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
if (event.durable === undefined)
return yield* Effect.die(new Error("Durable Session event is missing aggregate sequence"))
if (event.data.reason === "manual")
yield* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* bus.project(SessionEvent.Compaction.Failed, (event) =>
@@ -632,6 +605,8 @@ const layer = Layer.effectDiscard(
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* SessionPending.settleCompaction(db, { sessionID: event.data.sessionID })
}),
)
yield* bus.project(SessionEvent.RevertEvent.Staged, (event) =>
@@ -675,11 +650,11 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionInboxTable)
.delete(SessionPendingTable)
.where(
and(
eq(SessionInboxTable.session_id, event.data.sessionID),
gte(SessionInboxTable.enqueued_seq, boundary.seq),
eq(SessionPendingTable.session_id, event.data.sessionID),
gte(SessionPendingTable.admitted_seq, boundary.seq),
),
)
.run()
+2 -9
View File
@@ -16,20 +16,13 @@ export type RunError =
| UserInterruptedError
| Instructions.InitializationBlocked
export type Continuation = { readonly step: number }
export type DrainResult =
| { readonly type: "complete" }
| { readonly type: "moved"; readonly continuation?: Continuation }
/** Runs one local continuation from already-recorded Session history. */
export interface Interface {
/** Drains eligible durable work, returning transient state when execution must continue at a new Location. */
/** Drains eligible durable work. Explicit runs make one model call even when no work is eligible. */
readonly drain: (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
}) => Effect.Effect<DrainResult, RunError>
}) => Effect.Effect<void, RunError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionRunner") {}
+33 -90
View File
@@ -17,13 +17,13 @@ import { InstructionState } from "../instruction-state.js"
import { SessionCompaction } from "../compaction.js"
import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionPending } from "../pending.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionTitle } from "../title.js"
import { Service, type Continuation } from "./index.js"
import { Service } from "./index.js"
import { createLLMEventPublisher, type StepRecord } from "./publish-llm-event.js"
import { Snapshot } from "../../snapshot.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -124,47 +124,28 @@ const layer = Layer.effect(
const drain = Effect.fn("SessionRunner.drain")(function* (input: {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
}) {
let force = input.force
let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
return { type: "complete" as const }
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "any"))) return
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation)
if (result.type === "moved") return result
force = false
continuation = undefined
}
yield* runPendingCompaction(input.sessionID)
if (!input.force && !(yield* SessionPending.has(db, input.sessionID, "input"))) return
do {
yield* runSteps(input.sessionID)
} while (yield* SessionPending.has(db, input.sessionID, "input"))
})
/**
* Runs logical steps until no tool result or newly admitted steer requires another
* model call. Queued inputs remain pending until the current model work reaches idle.
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation?: Continuation,
) {
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (sessionID: SessionSchema.ID) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
let step = continuation?.step ?? 1
let next = continuation
let promotable: SessionPending.Promotable = "input"
let step = 1
while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined
if (!result.needsContinuation && !(yield* SessionInbox.has(db, sessionID, "steer")))
return { type: "complete" as const }
yield* runPendingCompaction(sessionID)
if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return
promotable = "steer"
step = result.step + 1
}
@@ -173,7 +154,7 @@ const layer = Layer.effect(
/** Completes one logical model step, transparently retrying or rebuilding after compaction. */
const runStep = Effect.fnUntraced(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
promotable: SessionPending.Promotable,
step: number,
) {
// Minting message identity before any attempt lets retries resume the same durable
@@ -201,7 +182,7 @@ const layer = Layer.effect(
.pipe(Effect.andThen(Effect.fail(failure.cause))),
),
)
let currentPromotable: SessionInbox.Promotable | undefined = promotable
let currentPromotable: SessionPending.Promotable | undefined = promotable
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
@@ -244,7 +225,7 @@ const layer = Layer.effect(
*/
const callModel = Effect.fn("SessionRunner.callModel")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable | undefined,
promotable: SessionPending.Promotable | undefined,
step: number,
recoverOverflow: boolean,
assistantMessageID: SessionMessage.ID,
@@ -253,7 +234,7 @@ const layer = Layer.effect(
// Establish what the model knows before admitting what the user said, so
// a blocked first step leaves pending inputs untouched.
yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id)
const promoted = promotable ? yield* SessionInbox.promote(db, bus, selected.session.id, promotable) : 0
const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0
if (promoted > 0) yield* startTitle(sessionID)
// Promoted input opens a fresh step allowance.
const currentStep = promoted > 0 ? 1 : step
@@ -264,7 +245,7 @@ const layer = Layer.effect(
// Make room: history must fit the context window before the call. A pending manual
// compaction owns this instead; the runner executes it between steps.
const compactionInput = { session, messages: loaded.messages, model, ref: resolved.ref, cost: resolved.cost }
if (compaction.required(compactionInput)) {
if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) {
const compacted = yield* compaction.compact(compactionInput)
if (compacted.status === "completed")
return CallOutcome.Restart({ step: currentStep, recoveredOverflow: false })
@@ -294,6 +275,7 @@ const layer = Layer.effect(
const stepUsage = (finish: NonNullable<StepRecord["finish"]>) => ({
cost: SessionUsage.calculateCost(resolved.cost, finish.tokens),
tokens: finish.tokens,
generated: finish.generated,
})
const captureStepEnd = Effect.fnUntraced(function* () {
@@ -501,75 +483,36 @@ const layer = Layer.effect(
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
) {
const pending = yield* SessionPending.compaction(db, sessionID)
if (!pending) return
const session = yield* getSession(sessionID)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
[SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "", inputID: selected.id }],
])
return selected
}),
)
if (pending?.type !== "compaction") return false
const session = yield* getSession(sessionID)
const compacted = yield* restore(
Effect.gen(function* () {
return yield* compaction.compactManual({
session,
messages: yield* store.context(sessionID),
inputID: pending.id,
started: true,
})
}),
).pipe(Effect.exit)
if (Exit.isSuccess(compacted)) return true
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: pending.id,
})
if (Exit.isSuccess(compacted)) return
const unsettled = yield* SessionPending.compaction(db, sessionID)
if (unsettled)
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
reason: "manual",
error: Cause.hasInterruptsOnly(compacted.cause)
? { type: "aborted", message: "Compaction cancelled" }
: { type: "compaction.failed", message: Cause.pretty(compacted.cause) },
inputID: unsettled.id,
})
return yield* Effect.failCause(compacted.cause)
}),
)
})
const runPendingMove = Effect.fn("SessionRunner.runPendingMove")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
if (pending?.type !== "move") return false
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
SessionEvent.Moved,
{
sessionID,
location: pending.payload.location,
projectID: pending.payload.projectID,
subpath: pending.payload.subpath,
},
],
])
return true
}),
)
})
/** Closes stale tool calls left active by an earlier interrupted drain. */
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
sessionID: SessionSchema.ID,
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { DateTime, Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -36,6 +36,7 @@ export interface StepRecord {
readonly finish?: {
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
readonly tokens: ReturnType<typeof SessionUsage.tokens>
readonly generated: DateTime.Utc
}
readonly calls: ReadonlyArray<{
readonly id: string
@@ -310,6 +311,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const publishStepFailure = Effect.fnUntraced(function* (details?: {
readonly cost?: Money.USD
readonly tokens?: ReturnType<typeof SessionUsage.tokens>
readonly generated?: DateTime.Utc
readonly snapshot?: Snapshot.ID
readonly files?: readonly RelativePath[]
}) {
@@ -493,9 +495,14 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return
}
case "step-finish":
const generated = yield* DateTime.now
yield* flush()
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
stepSettlement = {
finish: event.reason.normalized,
tokens: SessionUsage.tokens(event.usage),
generated,
}
if (event.reason.normalized === "content-filter") {
providerFailed = true
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
+5 -27
View File
@@ -3,7 +3,7 @@ import { sql } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path.js"
import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
import type { SessionInbox } from "./inbox.js"
import type { SessionPending } from "./pending.js"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import { PermissionV1 } from "../v1/permission.js"
import { Project } from "../project.js"
@@ -12,7 +12,7 @@ import { Workspace } from "../workspace.js"
import { Timestamps } from "../database/schema.sql.js"
import type { Instruction } from "@opencode-ai/schema/instruction"
import type { Session } from "@opencode-ai/schema/session"
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
import type { SyntheticData, UserData } from "@opencode-ai/schema/session-pending"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { Schema } from "effect"
@@ -101,9 +101,9 @@ export const SessionPendingTable = sqliteTable(
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInbox.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | Record<string, never>>().notNull(),
delivery: text().$type<SessionInbox.Delivery>(),
type: text().$type<SessionPending.Info["type"]>().notNull(),
data: text({ mode: "json" }).$type<UserData | SyntheticData | Record<string, never>>().notNull(),
delivery: text().$type<SessionPending.Delivery>(),
admitted_seq: integer().notNull(),
time_created: integer()
.notNull()
@@ -118,28 +118,6 @@ export const SessionPendingTable = sqliteTable(
],
)
export const SessionInboxTable = sqliteTable(
"session_inbox",
{
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
type: text().$type<SessionInbox.Info["type"]>().notNull(),
payload: text({ mode: "json" }).$type<UserPayload | SyntheticPayload | CompactionPayload | MovePayload>().notNull(),
delivery: text().$type<SessionInbox.Delivery>().notNull(),
enqueued_seq: integer().notNull(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [
index("session_inbox_session_delivery_seq_idx").on(table.session_id, table.delivery, table.enqueued_seq),
uniqueIndex("session_inbox_session_enqueued_seq_idx").on(table.session_id, table.enqueued_seq),
],
)
export const InstructionEntryTable = sqliteTable(
"instruction_entry",
{
+12 -17
View File
@@ -5,7 +5,6 @@ import { Context, Deferred, Duration, Effect, Fiber, Layer, Schema, Stream } fro
import { ChildProcess } from "effect/unstable/process"
import { produce } from "immer"
import { Shell } from "@opencode-ai/schema/shell"
import { AppProcess } from "@opencode-ai/util/process"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Config } from "./config.js"
import { Bus } from "./bus.js"
@@ -51,7 +50,7 @@ export interface Interface {
readonly create: <E = never, R = never>(
input: Shell.CreateInput,
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
) => Effect.Effect<Shell.Info, E | AppProcess.AppProcessError, R>
) => Effect.Effect<Shell.Info, E, R>
// Currently running commands only; exited shells are retained for get/output but excluded here.
readonly list: () => Effect.Effect<Shell.Info[]>
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
@@ -216,23 +215,19 @@ export const layer = (options?: ShellSelect.Options) =>
// Spawn through the Environment and stream combined output to the file. The handle is scope-bound, so
// the managing fiber keeps its scope open until the command terminates (it awaits `done` at the
// end). `create` returns once `ready` resolves with the registered session.
const ready = Deferred.makeUnsafe<Active, AppProcess.AppProcessError>()
const ready = Deferred.makeUnsafe<Active>()
runFork(
Effect.scoped(
Effect.gen(function* () {
const handle = yield* environment.spawner
.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
.pipe(
Effect.mapError((cause) => new AppProcess.AppProcessError({ command: invocation.command, cause })),
)
const handle = yield* environment.spawner.spawn(
ChildProcess.make(invocation.shell, args, {
cwd: invocation.cwd,
env: invocation.env,
stdin: "ignore",
detached: process.platform !== "win32",
forceKillAfter: Duration.seconds(3),
}),
)
const session: Active = {
info: produce(info, (draft) => {
draft.pid = handle.pid
@@ -334,7 +329,7 @@ export const layer = (options?: ShellSelect.Options) =>
// release (kill) the process before its exit is observed.
yield* Deferred.await(session.done).pipe(Effect.catch(() => Effect.void))
}),
).pipe(Effect.catchTag("AppProcessError", (error) => Deferred.fail(ready, error))),
).pipe(Effect.catch(() => Effect.void)),
)
const session = yield* Deferred.await(ready)
+1 -14
View File
@@ -2,7 +2,7 @@ export * as Tool from "./tool.js"
export { CallID, Content, Error, FileContent, TextContent } from "@opencode-ai/schema/tool"
export type { Context, Metadata, Options, Result } from "@opencode-ai/schema/tool"
import { ToolDefinition, type ToolCall } from "@opencode-ai/ai"
import type { ToolCall, ToolDefinition } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { Context, Effect, Layer, Schema, Scope, Semaphore } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
@@ -165,19 +165,6 @@ const layer = Layer.effect(
}),
)
if (entries.length === 0) return
yield* Effect.forEach(
entries,
(entry) =>
Effect.try({
try: () => ToolDefinition.make(definition(entry.tool)),
catch: (error) =>
new RegistrationError({
name: entry.key,
message: `Invalid tool definition ${entry.key}: ${error instanceof Error ? error.message : String(error)}`,
}),
}),
{ discard: true },
)
yield* Effect.uninterruptible(
lock.withPermit(
Effect.gen(function* () {
-114
View File
@@ -455,120 +455,6 @@ describe("Bus", () => {
}),
)
it.effect("publishes a durable batch atomically in provided order", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
const observed = new Array<string>()
yield* bus.project(SyncMessage, (event) =>
Effect.sync(() => {
observed.push(`project:${event.data.text}`)
}),
)
yield* bus.listen((event) =>
event.type === SyncMessage.type
? Effect.gen(function* () {
const text = (event.data as { readonly text: string }).text
const row = yield* db
.select({ seq: EventSequenceTable.seq })
.from(EventSequenceTable)
.where(eq(EventSequenceTable.aggregate_id, aggregateID))
.get()
.pipe(Effect.orDie)
observed.push(`notify:${text}:${row?.seq}`)
})
: Effect.void,
)
const events = yield* bus.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
expect(events.map((event) => event.durable.seq)).toEqual([Event.Seq.make(0), Event.Seq.make(1)])
expect(observed).toEqual(["project:first", "project:second", "notify:first:1", "notify:second:1"])
expect(
(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).map(
(row) => row.seq,
),
).toEqual([0, 1])
}),
)
it.effect("rolls back every batch event when a projector fails", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
const notifications = new Array<string>()
yield* db.run("CREATE TABLE IF NOT EXISTS event_batch_probe (value text NOT NULL)")
yield* db.run("DELETE FROM event_batch_probe")
yield* bus.project(SyncMessage, (event) =>
db
.run(`INSERT INTO event_batch_probe (value) VALUES ('${event.data.text}')`)
.pipe(
Effect.orDie,
Effect.andThen(event.data.text === "second" ? Effect.die("projector failed") : Effect.void),
),
)
yield* bus.listen((event) =>
Effect.sync(() => {
notifications.push(event.type)
}),
)
const exit = yield* bus
.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
.pipe(Effect.exit)
expect(String(exit)).toContain("projector failed")
expect(yield* db.all("SELECT value FROM event_batch_probe")).toEqual([])
expect(yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, aggregateID)).all()).toEqual([])
expect(
yield* db.select().from(EventSequenceTable).where(eq(EventSequenceTable.aggregate_id, aggregateID)).all(),
).toEqual([])
expect(notifications).toEqual([])
}),
)
it.effect("does not interleave a concurrent publish with batch notifications", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const aggregateID = Event.ID.create()
const firstObserved = yield* Deferred.make<void>()
const continueNotifications = yield* Deferred.make<void>()
const observed = new Array<string>()
yield* bus.listen((event) => {
if (event.type !== SyncMessage.type) return Effect.void
const text = (event.data as { readonly text: string }).text
return Effect.sync(() => observed.push(text)).pipe(
Effect.andThen(text === "first" ? Deferred.succeed(firstObserved, undefined) : Effect.void),
Effect.andThen(text === "first" ? Deferred.await(continueNotifications) : Effect.void),
)
})
const batch = yield* bus
.publishAll([
[SyncMessage, { id: aggregateID, text: "first" }],
[SyncMessage, { id: aggregateID, text: "second" }],
])
.pipe(Effect.forkScoped)
yield* Deferred.await(firstObserved)
const single = yield* bus.publish(SyncMessage, { id: aggregateID, text: "third" }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(observed).toEqual(["first"])
yield* Deferred.succeed(continueNotifications, undefined)
yield* Fiber.join(batch)
yield* Fiber.join(single)
expect(observed).toEqual(["first", "second", "third"])
}),
)
it.effect("replays durable aggregate events after a sequence and tails new events", () =>
Effect.gen(function* () {
const bus = yield* Bus.Service
+1 -13
View File
@@ -1,10 +1,9 @@
import fs from "node:fs/promises"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process"
import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { EnvironmentUnavailable } from "../src/environment/unavailable"
import {
execDefaults,
Failed,
@@ -36,17 +35,6 @@ describe("typeFollowing", () => {
)
})
describe("no execution plane", () => {
it.effect("fails spawn with a typed location error", () =>
Effect.gen(function* () {
const error = yield* EnvironmentUnavailable.spawner.spawn(ChildProcess.make("echo", ["hello"])).pipe(Effect.flip)
expect(error._tag).toBe("PlatformError")
expect(error.message).toContain("location has no execution plane")
}),
)
})
environmentConformance("memory environment", () =>
Effect.sync(() => {
const driver = makeMemoryDriver()
-22
View File
@@ -23,7 +23,6 @@ import { ID, type Payload } from "@opencode-ai/schema/event"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { Environment } from "@opencode-ai/core/environment/index"
import { EnvironmentUnavailable } from "@opencode-ai/core/environment/unavailable"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { MCPClient } from "@opencode-ai/core/mcp/client"
@@ -479,27 +478,6 @@ test("spawns local MCP servers through the location environment", async () => {
expect(command.options.env).toEqual({ MCP_LOCATION_TEST: "configured" })
})
test("reports a local MCP server as failed when the location has no execution plane", async () => {
const config = new ConfigMCP.Local({ type: "local", command: ["example-mcp"] })
const driver = Environment.makeMemoryDriver()
const environment = Layer.succeed(
Environment.Service,
Environment.Service.of({ files: Environment.makeFiles(driver), spawner: EnvironmentUnavailable.spawner }),
)
await Effect.runPromise(
Effect.gen(function* () {
const service = yield* MCP.Service
yield* service.tools()
const status = (yield* service.servers()).find((server) => server.name === "resources")?.status
expect(status).toEqual({
status: "failed",
error: expect.stringContaining("location has no execution plane"),
})
}).pipe(Effect.provide(resourceMcpLayer(config, undefined, undefined, { environment }))),
)
})
test("rejects sends before the stdio transport is started", async () => {
await Effect.runPromise(
Effect.scoped(
+3 -3
View File
@@ -11,7 +11,7 @@ import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Tool } from "@opencode-ai/core/tool"
import { Provider } from "@opencode-ai/core/provider"
import { define } from "@opencode-ai/plugin/promise/plugin"
@@ -61,12 +61,12 @@ describe("fromPromise", () => {
synthetic: (value) => {
seen = value
return Effect.succeed(
SessionInbox.Synthetic.make({
SessionPending.Synthetic.make({
id: SessionMessage.ID.make(input.id),
sessionID: Session.ID.make(input.sessionID),
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
payload: {
data: {
text: input.text,
metadata: input.metadata,
},
@@ -195,22 +195,6 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("rewrites models.dev fallback models to the GitHub Copilot package", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.githubCopilot, () => {})
catalog.model.update(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"), (model) => {
model.package = "@ai-sdk/openai-compatible"
})
})
yield* addPlugin()
expect(required(yield* catalog.model.get(Provider.ID.githubCopilot, Model.ID.make("gpt-5.6-sol"))).package).toBe(
"@ai-sdk/github-copilot",
)
}),
)
it.effect("selects languageModel when responses and chat are absent", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
+12 -16
View File
@@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
@@ -80,7 +81,7 @@ const it = testEffect(
)
describe("Session.compact", () => {
it.effect("durably stacks manual compaction", () =>
it.effect("durably admits and coalesces manual compaction", () =>
Effect.gen(function* () {
requests = []
const session = yield* Session.Service
@@ -88,18 +89,18 @@ describe("Session.compact", () => {
const created = yield* session.create({ location })
const messageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID: created.id,
inboxID: messageID,
item: {
inputID: messageID,
input: {
type: "user",
payload: { text: "Please compact this session history." },
data: { text: "Please compact this session history." },
delivery: "steer",
},
})
yield* bus.publish(SessionEvent.InboxDelivered, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID: created.id,
inboxID: messageID,
inputID: messageID,
})
expect(yield* session.compact({ id: messageID, sessionID: created.id }).pipe(Effect.flip)).toMatchObject({
@@ -109,17 +110,12 @@ describe("Session.compact", () => {
const first = yield* session.compact({ sessionID: created.id })
const second = yield* session.compact({ sessionID: created.id })
expect(second.id).not.toBe(first.id)
expect(second.id).toBe(first.id)
expect(requests).toHaveLength(0)
expect(yield* session.inbox(created.id)).toEqual([
expect.objectContaining({ id: first.id, type: "compaction", delivery: "queue" }),
expect.objectContaining({ id: second.id, type: "compaction", delivery: "queue" }),
])
expect(yield* SessionPending.compaction((yield* Database.Service).db, created.id)).toMatchObject({
id: first.id,
})
expect((yield* session.context(created.id)).find((message) => message.id === first.id)).toBeUndefined()
const steered = yield* session.create({ location })
const steer = yield* session.compact({ sessionID: steered.id, delivery: "steer" })
expect(steer).toMatchObject({ type: "compaction", delivery: "steer" })
}),
)
})
+23 -97
View File
@@ -1,6 +1,4 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { DateTime, Effect, Layer, Stream } from "effect"
import { Money } from "@opencode-ai/schema/money"
@@ -9,7 +7,6 @@ import { asc, eq } from "drizzle-orm"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Hash } from "@opencode-ai/util/hash"
import { Bus } from "@opencode-ai/core/bus"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
@@ -22,7 +19,7 @@ import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -56,15 +53,6 @@ const it = testEffect(
],
),
)
const liveIt = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Project.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const id = Session.ID.create()
@@ -90,65 +78,6 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
}
describe("Session.create", () => {
liveIt.live("follows the directory's project identity established after creation", () =>
withTmp((directory) =>
Effect.gen(function* () {
const session = yield* Session.Service
const projects = yield* Project.Service
const { db } = yield* Database.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const nested = Location.Ref.make({ directory: AbsolutePath.make(path.join(directory, "packages", "app")) })
const created = yield* session.create({ location: ref, title: "Before git" })
const child = yield* session.create({ location: nested, title: "Nested before git" })
const originalUpdated = created.time.updated
yield* Effect.promise(async () => {
await $`git init -q`.cwd(directory)
await $`git config user.email test@example.com`.cwd(directory)
await $`git config user.name Test`.cwd(directory)
await fs.writeFile(path.join(directory, "README.md"), "test\n")
await $`git add README.md`.cwd(directory)
await $`git commit -qm initial`.cwd(directory)
await $`git remote add origin git@github.com:owner/adopted.git`.cwd(directory)
})
const project = yield* projects.resolve(ref.directory)
const repeat = yield* projects.resolve(ref.directory)
const adopted = yield* session.get(created.id)
const nestedAdopted = yield* session.get(child.id)
const page = yield* session.list({ project: project.id })
const log = Array.from(yield* Stream.runCollect(logEvents(session, created.id)))
expect(created.projectID).toBe(Project.ID.global)
expect(project.id).toBe(Project.ID.make(Hash.fast("git-remote:github.com/owner/adopted")))
expect(repeat.id).toBe(project.id)
expect(page.data.map((item) => item.id)).toEqual(expect.arrayContaining([created.id, child.id]))
expect(adopted).toMatchObject({
projectID: project.id,
location: ref,
subpath: undefined,
time: { updated: originalUpdated },
})
expect(nestedAdopted).toMatchObject({
projectID: project.id,
location: nested,
subpath: RelativePath.make("packages/app"),
})
// Adoption is a project-domain fact; the session log records nothing new.
expect(log.map((event) => event.type)).toEqual(["session.created"])
expect(yield* session.messages({ sessionID: created.id })).toEqual([])
// Repeated resolution announces the directory's identity exactly once.
const announced = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, project.id))
.all()
.pipe(Effect.orDie)
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
}),
),
)
it.effect("persists a missing title until one is generated or supplied", () =>
Effect.gen(function* () {
const session = yield* Session.Service
@@ -338,9 +267,9 @@ describe("Session.create", () => {
text: "First",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const parentContext = yield* session.context(parent.id)
@@ -360,24 +289,24 @@ describe("Session.create", () => {
durable: { seq: 0 },
data: { sessionID: forked.id, parentID: parent.id },
})
expect(yield* SessionInbox.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionInbox.find(db, forkContext[1].id)).toBeUndefined()
expect(yield* SessionPending.find(db, forkContext[0].id)).toBeUndefined()
expect(yield* SessionPending.find(db, forkContext[1].id)).toBeUndefined()
expect(
yield* session.prompt({ id: forkContext[0].id, sessionID: forked.id, text: "First", resume: false }),
).toMatchObject({ id: forkContext[0].id, type: "user", payload: { text: "First" } })
).toMatchObject({ id: forkContext[0].id, type: "user", data: { text: "First" } })
yield* session.prompt({
sessionID: parent.id,
text: "Parent changed",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
yield* session.prompt({
sessionID: forked.id,
text: "Child continues",
resume: false,
})
yield* SessionInbox.promote(db, bus, forked.id, "steer")
yield* SessionPending.promote(db, bus, forked.id, "steer")
expect((yield* session.context(parent.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
expect((yield* session.context(forked.id)).map((message) => message.type)).toEqual(["user", "synthetic", "user"])
@@ -387,7 +316,7 @@ describe("Session.create", () => {
(event): number | undefined => event.durable?.seq,
),
).toEqual([0, 5, 6])
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
}),
)
@@ -398,7 +327,7 @@ describe("Session.create", () => {
const { db } = yield* Database.Service
const parent = yield* session.create({ location })
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
@@ -430,13 +359,13 @@ describe("Session.create", () => {
text: "First",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const second = yield* session.prompt({
sessionID: parent.id,
text: "Second",
resume: false,
})
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* SessionPending.promote(db, bus, parent.id, "steer")
const assistantMessageID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
yield* bus.publish(SessionEvent.Step.Started, {
@@ -567,7 +496,7 @@ describe("Session.create", () => {
text: "Hello",
resume: false,
})
yield* SessionInbox.promote(db, bus, created.id, "steer")
yield* SessionPending.promote(db, bus, created.id, "steer")
expect(
Array.from(yield* logEvents(session, created.id, true).pipe(Stream.take(3), Stream.runCollect)),
@@ -575,13 +504,10 @@ describe("Session.create", () => {
{ durable: { seq: 0 }, type: "session.created" },
{
durable: { seq: 1 },
type: "session.inbox.enqueued",
data: {
inboxID: expect.any(String),
item: { type: "user", payload: { text: "Hello" }, delivery: "steer" },
},
type: "session.input.admitted",
data: { input: { type: "user", data: { text: "Hello" }, delivery: "steer" } },
},
{ durable: { seq: 2 }, type: "session.inbox.delivered" },
{ durable: { seq: 2 }, type: "session.input.promoted" },
])
}),
)
@@ -597,7 +523,7 @@ describe("Session.create", () => {
text: "Replay lifecycle",
resume: false,
})
yield* SessionInbox.promote(sourceDb, sourceEvents, created.id, "steer")
yield* SessionPending.promote(sourceDb, sourceEvents, created.id, "steer")
const serialized = (yield* sourceDb
.select()
.from(EventTable)
@@ -637,17 +563,17 @@ describe("Session.create", () => {
expect(yield* store.get(created.id)).toBeUndefined()
expect(yield* bus.replayAll(serialized.slice(0, 2))).toBe(created.id)
expect(yield* SessionInbox.find(db, admitted.id)).toMatchObject({
expect(yield* SessionPending.find(db, admitted.id)).toMatchObject({
id: admitted.id,
sessionID: created.id,
type: "user",
payload: { text: "Replay lifecycle" },
data: { text: "Replay lifecycle" },
delivery: "steer",
})
expect(yield* store.context(created.id)).toEqual([])
expect(yield* bus.replayAll(serialized.slice(2))).toBe(created.id)
expect(yield* SessionInbox.find(db, admitted.id)).toBeUndefined()
expect(yield* SessionPending.find(db, admitted.id)).toBeUndefined()
expect(yield* store.context(created.id)).toMatchObject([
{ id: admitted.id, type: "user", text: "Replay lifecycle" },
])
@@ -661,8 +587,8 @@ describe("Session.create", () => {
.pipe(Effect.orDie)).map((event) => [event.seq, event.type]),
).toEqual([
[0, Bus.versionedType(SessionEvent.Created.type, 1)],
[1, Bus.versionedType(SessionEvent.InboxEnqueued.type, 1)],
[2, Bus.versionedType(SessionEvent.InboxDelivered.type, 1)],
[1, Bus.versionedType(SessionEvent.InputAdmitted.type, 1)],
[2, Bus.versionedType(SessionEvent.InputPromoted.type, 1)],
])
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
@@ -882,7 +808,7 @@ describe("SessionTransfer", () => {
])
yield* session.prompt({ sessionID, text: "Continue", resume: false })
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect((yield* session.messages({ sessionID, order: "asc" })).map((message) => message.type)).toEqual([
"user",
+2 -5
View File
@@ -346,17 +346,14 @@ function attempts(database: Database.Service["Service"], sessionID: Session.ID)
/** Builds the local execution layer plus the restart actions against the test harness services. */
function buildExecution(
scope: Scope.Closeable,
drain: (input: Parameters<SessionRunner.Interface["drain"]>[0]) => Effect.Effect<void, SessionRunner.RunError>,
drain: SessionRunner.Interface["drain"],
options?: SessionRestart.Options,
) {
return Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
const store = yield* SessionStore.Service
const runner = Layer.succeed(
SessionRunner.Service,
SessionRunner.Service.of({ drain: (input) => drain(input).pipe(Effect.as({ type: "complete" as const })) }),
)
const runner = Layer.succeed(SessionRunner.Service, SessionRunner.Service.of({ drain }))
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
+11 -14
View File
@@ -38,7 +38,7 @@ import {
InstructionBlobTable,
InstructionStateTable,
SessionMessageTable,
SessionInboxTable,
SessionPendingTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -170,9 +170,9 @@ const durableState = (db: Database.Interface["db"], sessionID: SessionSchema.ID)
.pipe(Effect.orDie),
pending: db
.select()
.from(SessionInboxTable)
.where(eq(SessionInboxTable.session_id, sessionID))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.from(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.orderBy(asc(SessionPendingTable.admitted_seq))
.all()
.pipe(Effect.orDie),
instructions: db
@@ -225,15 +225,12 @@ it.effect("generates from fresh settled Session context without durable mutation
const { db, bus, instructions } = yield* setup
yield* InstructionState.prepare(db, bus, instructions, sessionID)
const existing = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: existing,
item: { type: "user", payload: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
sessionID,
inboxID: existing,
inputID: existing,
input: { type: "user", data: { text: "Existing durable context" }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InputPromoted, { sessionID, inputID: existing })
const settledAssistant = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -278,10 +275,10 @@ it.effect("generates from fresh settled Session context without durable mutation
input: {},
executed: false,
})
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.create(),
item: { type: "user", payload: { text: "Queued input must remain invisible" }, delivery: "queue" },
inputID: SessionMessage.ID.create(),
input: { type: "user", data: { text: "Queued input must remain invisible" }, delivery: "queue" },
})
instruction = "Changed context"
const before = yield* durableState(db, sessionID)
+14 -57
View File
@@ -1,7 +1,6 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { Event } from "@opencode-ai/schema/project-directories"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -9,7 +8,6 @@ import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -36,7 +34,7 @@ const it = testEffect(
)
describe("Session.move", () => {
it.effect("enqueues one move when the source directory no longer exists", () =>
it.effect("moves a session whose source directory no longer exists", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
@@ -51,65 +49,24 @@ describe("Session.move", () => {
yield* session.move({ sessionID: created.id, directory: destination })
expect((yield* session.get(created.id)).location.directory).toBe(
AbsolutePath.make(path.join(tmp.path, "deleted")),
)
expect(yield* session.inbox(created.id)).toMatchObject([
{
type: "move",
delivery: "steer",
payload: {
location: { directory: destination },
expect((yield* session.get(created.id)).location.directory).toBe(destination)
const messages = yield* session.messages({ sessionID: created.id, order: "asc" })
expect(messages).toEqual([
expect.objectContaining({
type: "location-switched",
location: { directory: destination },
projectID: Project.ID.global,
previous: {
location: { directory: path.join(tmp.path, "deleted") },
projectID: Project.ID.global,
subpath: "",
},
},
subpath: "",
}),
])
yield* session.move({ sessionID: created.id, directory: destination })
expect(yield* session.inbox(created.id)).toHaveLength(2)
const steered = yield* session.create({
location: Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "other")) }),
})
yield* session.move({ sessionID: steered.id, directory: destination, delivery: "queue" })
expect(yield* session.inbox(steered.id)).toMatchObject([{ type: "move", delivery: "queue" }])
}),
),
),
)
it.effect("keeps a moved session out of its former directory's new identity", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const previous = AbsolutePath.make(path.join(tmp.path, "previous"))
const destination = AbsolutePath.make(tmp.path)
const created = yield* session.create({ location: Location.Ref.make({ directory: previous }) })
// Moves are admitted through the inbox and applied by the drain;
// publish the applied move directly since execution is a no-op here.
yield* bus.publish(SessionEvent.Moved, {
sessionID: created.id,
location: Location.Ref.make({ directory: destination }),
projectID: Project.ID.global,
})
// The former directory becomes a project after the session left it.
yield* bus.publish(Event.Resolved, {
projectID: Project.ID.make("adopting"),
directory: previous,
previous: Project.ID.global,
})
expect(yield* session.get(created.id)).toMatchObject({
projectID: Project.ID.global,
location: { directory: destination },
subpath: undefined,
})
expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toEqual(messages)
}),
),
),
+33 -21
View File
@@ -20,11 +20,11 @@ import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { fromRow } from "@opencode-ai/core/session/info"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Shell } from "@opencode-ai/schema/shell"
import {
InstructionStateTable,
SessionInboxTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
@@ -47,7 +47,9 @@ const build = Agent.defaultID
const assistantRow = (
id: SessionMessage.ID,
seq: number,
time: { created: DateTime.Utc; completed?: DateTime.Utc } = { created },
time: { created: DateTime.Utc; started?: DateTime.Utc; generated?: DateTime.Utc; completed?: DateTime.Utc } = {
created,
},
usage?: Pick<SessionMessage.Assistant, "cost" | "tokens">,
) => {
const {
@@ -81,7 +83,7 @@ describe("SessionProjector", () => {
.run()
const bus = yield* Bus.Service
const inputID = SessionMessage.ID.make("msg_manual_compaction")
yield* SessionInbox.admitCompaction(db, bus, { id: inputID, sessionID, delivery: "queue" })
yield* SessionPending.admitCompaction(db, bus, { id: inputID, sessionID })
yield* bus.publish(SessionEvent.Compaction.Failed, {
sessionID,
@@ -89,7 +91,7 @@ describe("SessionProjector", () => {
error: { type: "compaction.failed", message: "Auto compaction failed" },
})
expect(yield* SessionInbox.find(db, inputID)).toMatchObject({ id: inputID })
expect(yield* SessionPending.compaction(db, sessionID)).toMatchObject({ id: inputID })
}),
)
@@ -247,29 +249,29 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.make("msg_first"),
item: { type: "user", payload: { text: "first" }, delivery: "steer" },
inputID: SessionMessage.ID.make("msg_first"),
input: { type: "user", data: { text: "first" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InboxDelivered,
SessionEvent.InputPromoted,
{
sessionID,
inboxID: SessionMessage.ID.make("msg_first"),
inputID: SessionMessage.ID.make("msg_first"),
},
{ id: Event.ID.make("evt_z") },
)
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: SessionMessage.ID.make("msg_second"),
item: { type: "user", payload: { text: "second" }, delivery: "steer" },
inputID: SessionMessage.ID.make("msg_second"),
input: { type: "user", data: { text: "second" }, delivery: "steer" },
})
yield* bus.publish(
SessionEvent.InboxDelivered,
SessionEvent.InputPromoted,
{
sessionID,
inboxID: SessionMessage.ID.make("msg_second"),
inputID: SessionMessage.ID.make("msg_second"),
},
{ id: Event.ID.make("evt_a") },
)
@@ -320,20 +322,20 @@ describe("SessionProjector", () => {
.pipe(Effect.orDie)
const bus = yield* Bus.Service
const id = SessionMessage.ID.make("msg_admitted")
const admitted = yield* SessionInbox.admit(db, bus, {
const admitted = yield* SessionPending.admit(db, bus, {
id,
sessionID,
item: { type: "user", payload: { text: "promote me" }, delivery: "steer" },
input: { type: "user", data: { text: "promote me" }, delivery: "steer" },
})
if (!admitted) return yield* Effect.die("Prompt admission failed")
const event = yield* bus.publish(SessionEvent.InboxDelivered, {
const event = yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inboxID: id,
inputID: id,
})
expect(
yield* db.select().from(SessionInboxTable).where(eq(SessionInboxTable.id, id)).get().pipe(Effect.orDie),
yield* db.select().from(SessionPendingTable).where(eq(SessionPendingTable.id, id)).get().pipe(Effect.orDie),
).toBeUndefined()
expect(
yield* db.select().from(SessionMessageTable).where(eq(SessionMessageTable.id, id)).get().pipe(Effect.orDie),
@@ -667,12 +669,18 @@ describe("SessionProjector", () => {
const usageUpdated = yield* service
.subscribe(SessionEvent.UsageUpdated)
.pipe(Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* service.publish(SessionEvent.Text.Started, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
ordinal: 0,
})
yield* service.publish(SessionEvent.Step.Ended, {
sessionID,
assistantMessageID: SessionMessage.ID.make("msg_assistant_2"),
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
generated: DateTime.makeUnsafe(0),
})
const rows = yield* db
@@ -691,7 +699,11 @@ describe("SessionProjector", () => {
finish: "stop",
cost: Money.USD.make(1.25),
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
time: { completed: DateTime.makeUnsafe(0) },
time: {
started: DateTime.makeUnsafe(0),
generated: DateTime.makeUnsafe(0),
completed: DateTime.makeUnsafe(0),
},
})
expect(
yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie),
+87 -83
View File
@@ -21,8 +21,8 @@ import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
@@ -100,11 +100,11 @@ const setup = Effect.gen(function* () {
.pipe(Effect.orDie)
})
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionInbox.find(db, id))
const admitted = (id: SessionMessage.ID) => Database.Service.use(({ db }) => SessionPending.find(db, id))
const admittedCount = Database.Service.use(({ db }) =>
db
.select()
.from(SessionInboxTable)
.from(SessionPendingTable)
.all()
.pipe(
Effect.orDie,
@@ -227,13 +227,13 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.text).toBe("Fix the failing tests")
expect(message.data.text).toBe("Fix the failing tests")
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admitted(message.id)).toMatchObject({
id: message.id,
sessionID,
type: "user",
payload: { text: "Fix the failing tests" },
data: { text: "Fix the failing tests" },
delivery: "steer",
})
}),
@@ -251,7 +251,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const stale = SessionMessage.ID.make("msg_stale_assistant")
yield* db.insert(SessionMessageTable).values(assistantRow(stale, 100)).run().pipe(Effect.orDie)
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
@@ -268,7 +268,7 @@ describe("Session.prompt", () => {
(row) => row.id,
),
).not.toContainAnyValues([boundary.id, stale])
expect(yield* SessionInbox.find(db, boundary.id)).toBeUndefined()
expect(yield* SessionPending.find(db, boundary.id)).toBeUndefined()
}),
)
@@ -283,7 +283,7 @@ describe("Session.prompt", () => {
text: "boundary",
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* bus.publish(SessionEvent.RevertEvent.Staged, {
sessionID,
revert: { messageID: boundary.id, files: [] },
@@ -293,11 +293,11 @@ describe("Session.prompt", () => {
const completion = yield* session.synthetic({ sessionID, text: "stale completion" })
expect(wakeCalls).toEqual([])
expect(yield* SessionInbox.find(db, completion.id)).toMatchObject({ type: "synthetic" })
expect(yield* SessionPending.find(db, completion.id)).toMatchObject({ type: "synthetic" })
yield* session.revert.commit(sessionID)
expect(yield* SessionInbox.find(db, completion.id)).toBeUndefined()
expect(yield* SessionPending.find(db, completion.id)).toBeUndefined()
}),
)
@@ -315,7 +315,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: uri.slice(uri.indexOf(",") + 1),
mime: "image/png",
@@ -326,7 +326,7 @@ describe("Session.prompt", () => {
])
const stored = yield* admitted(message.id)
expect(stored?.type).toBe("user")
if (stored?.type === "user") expect(stored.payload.files).toEqual(message.payload.files)
if (stored?.type === "user") expect(stored.data.files).toEqual(message.data.files)
}),
)
@@ -347,14 +347,14 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "text/plain",
source: { type: "uri", uri: sourceUri.href },
name: "main.ts",
})
expect(
Buffer.from(message.payload.files?.[0]?.data ?? "", "base64")
Buffer.from(message.data.files?.[0]?.data ?? "", "base64")
.toString("utf8")
.replace(/\r$/, ""),
).toBe('import { describe, expect } from "bun:test"')
@@ -374,13 +374,13 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toHaveLength(1)
expect(message.payload.files?.[0]).toMatchObject({
expect(message.data.files).toHaveLength(1)
expect(message.data.files?.[0]).toMatchObject({
mime: "application/x-directory",
source: { type: "uri", uri },
name: "source",
})
expect(Buffer.from(message.payload.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
expect(Buffer.from(message.data.files?.[0]?.data ?? "", "base64").toString("utf8")).toContain(
"session-prompt.test.ts",
)
}),
@@ -408,7 +408,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: bytes.toString("base64"),
mime: "image/png",
@@ -417,7 +417,7 @@ describe("Session.prompt", () => {
},
])
const stored = yield* admitted(message.id)
expect(stored?.type === "user" ? stored.payload.files : undefined).toEqual(message.payload.files)
expect(stored?.type === "user" ? stored.data.files : undefined).toEqual(message.data.files)
}),
)
@@ -440,7 +440,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: "AA==",
mime: "image/png",
@@ -463,7 +463,7 @@ describe("Session.prompt", () => {
resume: false,
})
expect(message.payload.files).toEqual([
expect(message.data.files).toEqual([
{
data: Buffer.from("export const value = 1\n").toString("base64"),
mime: "text/plain",
@@ -512,20 +512,20 @@ describe("Session.prompt", () => {
yield* session.prompt({ sessionID, text: "First", resume: false })
yield* session.prompt({ sessionID, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const streamed = Array.from(yield* Fiber.join(fiber))
expect(streamed.map((event): [number | undefined, string] => [event.durable?.seq, event.type])).toEqual([
[0, "session.inbox.enqueued"],
[1, "session.inbox.enqueued"],
[2, "session.inbox.delivered"],
[3, "session.inbox.delivered"],
[0, "session.input.admitted"],
[1, "session.input.admitted"],
[2, "session.input.promoted"],
[3, "session.input.promoted"],
])
expect(
Array.from(
yield* publicEvents({ sessionID, after: streamed[0].durable?.seq }).pipe(Stream.take(1), Stream.runCollect),
).map((event): [number | undefined, string] => [event.durable?.seq, event.type]),
).toEqual([[1, "session.inbox.enqueued"]])
).toEqual([[1, "session.input.admitted"]])
}),
)
@@ -593,12 +593,12 @@ describe("Session.prompt", () => {
const { db } = yield* Database.Service
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
const first = yield* session.prompt(input)
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
yield* db.delete(EventTable).where(eq(EventTable.aggregate_id, sessionID)).run().pipe(Effect.orDie)
const retried = yield* session.prompt(input)
expect(retried).toMatchObject({ id: first.id, type: "user", payload: { text: first.payload.text } })
expect(retried).toMatchObject({ id: first.id, type: "user", data: { text: first.data.text } })
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Fix the failing tests" },
])
@@ -613,11 +613,11 @@ describe("Session.prompt", () => {
const { db } = yield* Database.Service
const input = { sessionID, id: messageID, text: "Fix the failing tests", resume: false }
yield* session.prompt(input)
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
const retried = yield* session.prompt({ ...input, delivery: "queue" })
expect(retried).toMatchObject({ id: messageID, type: "user", payload: { text: input.text } })
expect(retried).toMatchObject({ id: messageID, type: "user", data: { text: input.text } })
expect(yield* admitted(messageID)).toBeUndefined()
}),
)
@@ -708,7 +708,7 @@ describe("Session.prompt", () => {
expect(messages[1]).toEqual(messages[0])
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* admittedCount).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@@ -726,11 +726,11 @@ describe("Session.prompt", () => {
})
yield* Effect.all(
[SessionInbox.promote(db, bus, sessionID, "steer"), SessionInbox.promote(db, bus, sessionID, "steer")],
[SessionPending.promote(db, bus, sessionID, "steer"), SessionPending.promote(db, bus, sessionID, "steer")],
{ concurrency: "unbounded" },
)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDelivered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputPromoted.type, 1))).toBe(1)
expect(yield* admitted(messageID)).toBeUndefined()
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: messageID, type: "user", text: "Promote once" },
@@ -761,7 +761,11 @@ describe("Session.prompt", () => {
.pipe(Effect.orDie)
yield* bus.remove(sessionID)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, sessionID)).run().pipe(Effect.orDie)
yield* db
.delete(SessionPendingTable)
.where(eq(SessionPendingTable.session_id, sessionID))
.run()
.pipe(Effect.orDie)
yield* db
.delete(SessionMessageTable)
.where(eq(SessionMessageTable.session_id, sessionID))
@@ -781,12 +785,12 @@ describe("Session.prompt", () => {
expect(yield* admitted(messageID)).toMatchObject({
id: messageID,
type: "user",
payload: { text: "Replay pending" },
data: { text: "Replay pending" },
})
expect(yield* admitted(syntheticID)).toMatchObject({
id: syntheticID,
type: "synthetic",
payload: { text: "Replay synthetic" },
data: { text: "Replay synthetic" },
})
expect(yield* session.messages({ sessionID })).toEqual([])
expect(wakeCalls).toEqual([])
@@ -919,7 +923,7 @@ describe("Session.prompt", () => {
const failure = yield* session.prompt({ ...input, metadata: { source: "plugin" } }).pipe(Effect.flip)
expect(retried).toEqual(first)
expect(first.payload.metadata).toEqual({ source: "api" })
expect(first.data.metadata).toEqual({ source: "api" })
expect(failure._tag).toBe("Session.PromptConflictError")
}),
)
@@ -945,14 +949,14 @@ describe("Session.prompt", () => {
type: "synthetic",
sessionID,
delivery: "steer",
payload: {
data: {
text: "Background work completed",
description: "shell completion",
metadata: { job: "shell" },
},
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(yield* session.messages({ sessionID })).toMatchObject([
{
@@ -977,15 +981,15 @@ describe("Session.prompt", () => {
const entries = yield* Effect.all([session.synthetic(input), session.synthetic(input)], {
concurrency: "unbounded",
})
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
yield* SessionPending.promote(database.db, bus, sessionID, "steer")
const promotedRetry = yield* session.synthetic(input)
const failure = yield* session.synthetic({ ...input, text: "Different completion" }).pipe(Effect.flip)
expect(entries[1]).toEqual(entries[0])
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", payload: { text: "Completed" } })
expect(promotedRetry).toMatchObject({ id: messageID, type: "synthetic", data: { text: "Completed" } })
expect(failure).toMatchObject({ _tag: "Session.SyntheticConflictError", sessionID, inputID: messageID })
expect(yield* admittedCount).toBe(0)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxEnqueued.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputAdmitted.type, 1))).toBe(1)
}),
)
@@ -1004,11 +1008,11 @@ describe("Session.prompt", () => {
})
expect(input.delivery).toBe("queue")
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionInbox.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(true)
expect(yield* SessionPending.promote(db, bus, sessionID, "steer")).toBe(0)
expect(yield* session.messages({ sessionID })).toEqual([])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(false)
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.messages({ sessionID })).toMatchObject([
{ id: input.id, type: "synthetic", text: "Queued completion" },
])
@@ -1034,7 +1038,7 @@ describe("Session.prompt", () => {
resume: false,
})
yield* SessionInbox.promote(db, bus, sessionID, "steer")
yield* SessionPending.promote(db, bus, sessionID, "steer")
expect(
(yield* session.messages({ sessionID, order: "asc" })).map((message) =>
@@ -1045,11 +1049,11 @@ describe("Session.prompt", () => {
)
})
describe("Session.inbox", () => {
describe("Session.pending", () => {
it.effect("fails for an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
expect(yield* session.inbox(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
expect(yield* session.pending(Session.ID.make("ses_missing")).pipe(Effect.flip)).toMatchObject({
_tag: "Session.NotFoundError",
})
}),
@@ -1071,34 +1075,34 @@ describe("Session.inbox", () => {
})
const second = yield* session.prompt({ sessionID, text: "Second steer", resume: false })
expect(yield* session.inbox(sessionID)).toMatchObject([
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: first.id, type: "user", delivery: "steer" },
{ id: queued.id, type: "synthetic", delivery: "queue" },
{ id: second.id, type: "user", delivery: "steer" },
])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(2)
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(2)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: queued.id, type: "synthetic" }])
expect(yield* SessionInbox.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.inbox(sessionID)).toEqual([])
expect(yield* SessionPending.promote(db, bus, sessionID, "input")).toBe(1)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
it.effect("lists an unhandled compaction until it is cancelled", () =>
it.effect("lists an unhandled compaction barrier until it settles", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const { db } = yield* Database.Service
const barrier = yield* session.compact({ sessionID })
expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionInbox.has(db, sessionID, "input")).toBe(true)
expect(yield* session.inbox(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "input")).toBe(false)
expect(yield* session.pending(sessionID)).toMatchObject([{ id: barrier.id, type: "compaction" }])
yield* session.cancelInbox({ sessionID, inboxID: barrier.id })
expect(yield* SessionInbox.has(db, sessionID, "any")).toBe(false)
expect(yield* session.inbox(sessionID)).toEqual([])
yield* SessionPending.settleCompaction(db, { sessionID })
expect(yield* SessionPending.has(db, sessionID, "any")).toBe(false)
expect(yield* session.pending(sessionID)).toEqual([])
}),
)
@@ -1115,16 +1119,16 @@ describe("Session.inbox", () => {
resume: false,
})
yield* session.cancelInbox({ sessionID, inboxID: inputID })
yield* session.cancelPending({ sessionID, inputID })
expect(yield* session.inbox(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* session.cancelInbox({ sessionID, inboxID: inputID }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
expect(yield* session.pending(sessionID)).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
expect(yield* session.cancelPending({ sessionID, inputID }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PendingInputConflictError",
sessionID,
inboxID: inputID,
inputID,
})
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
const retried = yield* session.prompt({
id: inputID,
@@ -1150,33 +1154,33 @@ describe("Session.inbox", () => {
const alreadySteered = yield* session.prompt({ sessionID, text: "Already steer", resume: false })
wakeCalls.length = 0
yield* session.steerInbox({ sessionID, inboxID: queued.id })
yield* session.steerPending({ sessionID, inputID: queued.id })
expect(yield* session.inbox(sessionID)).toMatchObject([
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "steer" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([sessionID])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
wakeCalls.length = 0
yield* session.queueInbox({ sessionID, inboxID: queued.id })
expect(yield* session.inbox(sessionID)).toMatchObject([
yield* session.queuePending({ sessionID, inputID: queued.id })
expect(yield* session.pending(sessionID)).toMatchObject([
{ id: queued.id, delivery: "queue" },
{ id: alreadySteered.id, delivery: "steer" },
])
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputQueued.type, 1))).toBe(1)
expect(yield* session.steerInbox({ sessionID, inboxID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.InboxConflictError",
expect(yield* session.steerPending({ sessionID, inputID: alreadySteered.id }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.PendingInputConflictError",
sessionID,
inboxID: alreadySteered.id,
inputID: alreadySteered.id,
})
yield* session.cancelInbox({ sessionID, inboxID: alreadySteered.id })
yield* session.cancelPending({ sessionID, inputID: alreadySteered.id })
expect(wakeCalls).toEqual([])
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxDeliveryChanged.type, 1))).toBe(2)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InboxCancelled.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputSteered.type, 1))).toBe(1)
expect(yield* eventCount(Bus.versionedType(SessionEvent.InputCancelled.type, 1))).toBe(1)
}),
)
})
@@ -129,7 +129,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }).pipe(Effect.asVoid),
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
@@ -243,9 +243,9 @@ describe("SessionRunnerLLM recorded", () => {
.orderBy(EventTable.seq)
.all()).map((event) => event.type),
).toEqual([
"session.inbox.enqueued.1",
"session.input.admitted.1",
"session.instructions.updated.2",
"session.inbox.delivered.1",
"session.input.promoted.1",
"session.step.started.1",
"session.text.started.1",
"session.text.ended.1",
@@ -266,6 +266,7 @@ test("step finish records settlement without publishing step ended", async () =>
expect(published.some((event) => event.type === "step.ended.2")).toBe(false)
expect(publisher.record().finish).toMatchObject({ finish: "stop" })
expect(publisher.record().finish).toHaveProperty("generated")
})
test("content-filter finish retains failure evidence until step closeout", async () => {
@@ -110,28 +110,6 @@ describe("Tool", () => {
}),
)
it.effect("rejects invalid tool definitions before installing any tools", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
const error = yield* service
.transform((draft) => {
draft.add({ ...make(), name: "healthy", options: { codemode: false } })
draft.add({
name: "phone_type",
input: Schema.Struct({}),
execute: () => Effect.succeed({ content: "ok" }),
options: { codemode: false },
} as unknown as Info)
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Tool.RegistrationError)
expect(error.name).toBe("phone_type")
expect(error.message).toContain('Expected string, got undefined\n at ["description"]')
expect((yield* service.snapshot()).definitions.map((tool) => tool.name)).toEqual(["execute"])
}),
)
it.effect("canonicalizes effective definitions and keeps Code Mode last", () =>
Effect.gen(function* () {
const service = yield* Tool.Service
+30 -113
View File
@@ -32,7 +32,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Money } from "@opencode-ai/schema/money"
import { SessionProjector } from "@opencode-ai/core/session/projector"
@@ -55,7 +55,7 @@ import { Tool } from "@opencode-ai/core/tool"
import type { Info as ToolInfo } from "@opencode-ai/schema/tool"
import {
InstructionStateTable,
SessionInboxTable,
SessionPendingTable,
SessionMessageTable,
SessionTable,
} from "@opencode-ai/core/session/sql"
@@ -72,7 +72,7 @@ import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { asc, desc, eq } from "drizzle-orm"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
import { agentHost, catalogHost, host } from "./plugin/host"
import PROMPT_DEFAULT from "../src/session/runner/prompt/base.txt"
@@ -387,21 +387,8 @@ const execution = Layer.effect(
SessionExecution.Service,
Effect.gen(function* () {
const sessionRunner = yield* SessionRunner.Service
function drain(
sessionID: Session.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
): Effect.Effect<void, SessionRunner.RunError> {
return sessionRunner
.drain({ sessionID, force, continuation })
.pipe(
Effect.flatMap((result) =>
result.type === "complete" ? Effect.void : drain(sessionID, false, result.continuation),
),
)
}
const coordinator = yield* SessionRunCoordinator.make<Session.ID, SessionRunner.RunError>({
drain: (sessionID, force) => drain(sessionID, force),
drain: (sessionID, force) => sessionRunner.drain({ sessionID, force }),
})
return SessionExecution.Service.of({
active: coordinator.active,
@@ -642,7 +629,7 @@ const replaySessionProjection = (id: Session.ID) =>
yield* bus.remove(id)
yield* db.delete(InstructionStateTable).where(eq(InstructionStateTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionInboxTable).where(eq(SessionInboxTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionPendingTable).where(eq(SessionPendingTable.session_id, id)).run().pipe(Effect.orDie)
yield* db.delete(SessionMessageTable).where(eq(SessionMessageTable.session_id, id)).run().pipe(Effect.orDie)
yield* bus.replayAll(
recorded.map((event) => ({
@@ -1199,7 +1186,7 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Instructions.InitializationBlocked)
expect(requests).toHaveLength(0)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
@@ -1223,7 +1210,6 @@ describe("SessionRunnerLLM", () => {
yield* bus.publish(SessionEvent.Moved, {
sessionID,
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
})
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
@@ -1234,78 +1220,7 @@ describe("SessionRunnerLLM", () => {
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
}),
)
it.effect("delivers a queued move atomically at the idle boundary", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const inboxID = SessionMessage.ID.create()
yield* SessionInbox.admit(db, bus, {
id: inboxID,
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }),
projectID: Project.ID.global,
},
delivery: "queue",
},
})
yield* session.resume(sessionID)
expect((yield* session.get(sessionID)).location.directory).toBe(AbsolutePath.make("/moved"))
expect(yield* session.inbox(sessionID)).toEqual([])
expect(requests).toEqual([])
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, sessionID))
.orderBy(desc(EventTable.seq))
.limit(2)
.all()).map((event) => event.type),
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
}),
)
it.effect("preserves a tool continuation across a steered move", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* admit(session, "Echo before moving")
yield* TestLLM.push(
TestLLM.tool("call-move", "echo", { text: "moving" }),
TestLLM.text("Done", "text-after-move"),
)
const tools = yield* blockTools()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* tools.release
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(yield* session.inbox(sessionID)).toEqual([])
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
}),
)
@@ -1910,15 +1825,15 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("runs steers before queued compaction and later queued input", () =>
it.effect("runs one durable compaction barrier after tool settlement and before later inputs", () =>
Effect.gen(function* () {
const session = yield* setup
currentModel = recoveryModel
const stream = yield* TestLLM.gate
yield* TestLLM.push(
TestLLM.tool("call-active", "echo", { text: "active" }),
TestLLM.text("Steer complete", "text-steer"),
[LLMEvent.textDelta({ id: "summary", text: "durable summary" })],
TestLLM.text("Steer complete", "text-steer"),
TestLLM.text("Queue complete", "text-queue"),
)
yield* admit(session, "Active work")
@@ -1926,7 +1841,9 @@ describe("SessionRunnerLLM", () => {
yield* stream.started
const first = yield* session.compact({ sessionID })
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toMatchObject({
const second = yield* session.compact({ sessionID })
expect(second.id).toBe(first.id)
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toMatchObject({
id: first.id,
})
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toBeUndefined()
@@ -1939,17 +1856,17 @@ describe("SessionRunnerLLM", () => {
delivery: "queue",
resume: false,
})
expect(yield* SessionInbox.has((yield* Database.Service).db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has((yield* Database.Service).db, sessionID, "steer")).toBe(false)
yield* stream.release
yield* Fiber.join(active)
expect(requests).toHaveLength(4)
expect(userTexts(requests[1])).toContain("Steer after compaction")
expect(userTexts(requests[1])).toContain("Completion after compaction")
expect(userTexts(requests[2])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[1])[0]).toContain("Create a new anchored summary")
expect(userTexts(requests[2])).toContain("Steer after compaction")
expect(userTexts(requests[2])).toContain("Completion after compaction")
expect(userTexts(requests[3])).toContain("Queue after compaction")
expect(yield* SessionInbox.find((yield* Database.Service).db, first.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === first.id)).toMatchObject({
type: "compaction",
status: "completed",
@@ -1984,7 +1901,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(3)
expect(userTexts(requests[2])).toContain("Continue after failure")
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2006,7 +1923,7 @@ describe("SessionRunnerLLM", () => {
yield* session.resume(sessionID)
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2021,7 +1938,7 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("delivers steered manual compaction when the model has no context limit", () =>
it.effect("manually compacts when the model has no context limit", () =>
Effect.gen(function* () {
const session = yield* setup
yield* TestLLM.push(TestLLM.text("Earlier answer", "text-manual-unknown-history"))
@@ -2029,7 +1946,7 @@ describe("SessionRunnerLLM", () => {
requests.length = 0
yield* TestLLM.push(TestLLM.text("Manual summary", "text-manual-unknown-summary"))
const compaction = yield* session.compact({ sessionID, delivery: "steer" })
const compaction = yield* session.compact({ sessionID })
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
@@ -2098,7 +2015,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
yield* Fiber.await(run)
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2119,7 +2036,7 @@ describe("SessionRunnerLLM", () => {
expect(yield* Effect.exit(session.resume(sessionID))).toMatchObject({ _tag: "Failure" })
expect(yield* SessionInbox.find((yield* Database.Service).db, compaction.id)).toBeUndefined()
expect(yield* SessionPending.compaction((yield* Database.Service).db, sessionID)).toBeUndefined()
expect((yield* session.messages({ sessionID })).find((message) => message.id === compaction.id)).toMatchObject({
type: "compaction",
status: "failed",
@@ -2947,7 +2864,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "queue")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
yield* stream.release
@@ -2977,7 +2894,7 @@ describe("SessionRunnerLLM", () => {
yield* session.interrupt(sessionID)
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
expect(requests).toHaveLength(1)
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(true)
expect(yield* SessionPending.has(db, sessionID, "steer")).toBe(true)
const resumed = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* stream.started
@@ -3130,7 +3047,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3187,7 +3104,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted hosted tool")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3238,7 +3155,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* admit(session, "Recover interrupted tool input")
yield* SessionInbox.promote((yield* Database.Service).db, bus, sessionID, "steer")
yield* SessionPending.promote((yield* Database.Service).db, bus, sessionID, "steer")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID,
@@ -3291,7 +3208,7 @@ describe("SessionRunnerLLM", () => {
const bus = yield* Bus.Service
const defect = new Error("fail after prompt promotion")
let fail = true
yield* bus.project(SessionEvent.InboxDelivered, () => (fail ? Effect.die(defect) : Effect.void))
yield* bus.project(SessionEvent.InputPromoted, () => (fail ? Effect.die(defect) : Effect.void))
yield* admit(session, "Recover promoted input")
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
@@ -3313,7 +3230,7 @@ describe("SessionRunnerLLM", () => {
const session = yield* setup
const bus = yield* Bus.Service
yield* bus.listen((event) =>
event.type === SessionEvent.InboxDelivered.type
event.type === SessionEvent.InputPromoted.type
? Effect.die("fail after prompt promotion commits")
: Effect.void,
)
+2 -2
View File
@@ -15,7 +15,7 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionStore } from "@opencode-ai/core/session/store"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { Skill } from "@opencode-ai/core/skill"
import { testEffect } from "./lib/effect"
@@ -71,7 +71,7 @@ describe("Session.skill", () => {
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
yield* SessionPending.promote(database.db, bus, session.id, "steer")
expect(yield* sessions.messages({ sessionID: session.id })).toContainEqual(
expect.objectContaining({
+5 -5
View File
@@ -114,14 +114,14 @@ const prompt = (sessionID: Session.ID, text: string) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const messageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.InboxEnqueued, {
yield* bus.publish(SessionEvent.InputAdmitted, {
sessionID,
inboxID: messageID,
item: { type: "user", payload: { text }, delivery: "steer" },
inputID: messageID,
input: { type: "user", data: { text }, delivery: "steer" },
})
yield* bus.publish(SessionEvent.InboxDelivered, {
yield* bus.publish(SessionEvent.InputPromoted, {
sessionID,
inboxID: messageID,
inputID: messageID,
})
})
+7 -7
View File
@@ -9,7 +9,7 @@ import { Project } from "@opencode-ai/schema/project"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
import { SessionPending } from "@opencode-ai/schema/session-pending"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Workspace } from "@opencode-ai/schema/workspace"
import { Command } from "@opencode-ai/schema/command"
@@ -44,7 +44,7 @@ test("Core reuses the canonical shared schemas", async () => {
coreProject,
coreProvider,
coreReference,
coreSessionInbox,
coreSessionPending,
coreSessionMessage,
coreSkill,
coreSchema,
@@ -65,7 +65,7 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/project/schema"),
import("@opencode-ai/core/provider"),
import("@opencode-ai/core/reference"),
import("@opencode-ai/core/session/inbox"),
import("@opencode-ai/core/session/pending"),
import("@opencode-ai/core/session/message"),
import("@opencode-ai/core/skill"),
import("@opencode-ai/core/schema"),
@@ -126,10 +126,10 @@ test("Core reuses the canonical shared schemas", async () => {
[Session.ID, schemaSession.Session.ID],
[Session.Info, schemaSession.Session.Info],
[Session.ListAnchor, schemaSession.Session.ListAnchor],
[coreSessionInbox.Delivery, SessionInbox.Delivery],
[coreSessionInbox.Item, SessionInbox.Item],
[coreSessionInbox.User, SessionInbox.User],
[coreSessionInbox.Synthetic, SessionInbox.Synthetic],
[coreSessionPending.Delivery, SessionPending.Delivery],
[coreSessionPending.Message, SessionPending.Message],
[coreSessionPending.User, SessionPending.User],
[coreSessionPending.Synthetic, SessionPending.Synthetic],
[coreSessionMessage.ID, SessionMessage.ID],
[coreSessionMessage.AssistantRetry, SessionMessage.AssistantRetry],
[coreSessionMessage.AgentSelected, SessionMessage.AgentSelected],
+3 -3
View File
@@ -669,8 +669,8 @@ describe("ShellTool", () => {
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.item.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === sessionID && event.data.input.type === "synthetic"),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
)
@@ -684,7 +684,7 @@ describe("ShellTool", () => {
const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
expect((yield* shell.wait(id)).status).toBe("timeout")
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.item.payload).toMatchObject({
expect((yield* Fiber.join(admitted)).valueOrUndefined?.data.input.data).toMatchObject({
description: idleCommand,
metadata: {
source: "shell",
+7 -9
View File
@@ -19,7 +19,7 @@ import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionPending } from "@opencode-ai/core/session/pending"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionStore } from "@opencode-ai/core/session/store"
@@ -304,7 +304,7 @@ describe("SubagentTool", () => {
agent: "reviewer",
model: childModel,
})
expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.text).toBe(
expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
"You are a subagent spawned by another session.\nreview this",
)
@@ -376,8 +376,8 @@ describe("SubagentTool", () => {
const locations = yield* LocationServiceMap.Service
const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
const bus = yield* Bus.Service
const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.type === "synthetic"),
const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
@@ -401,10 +401,8 @@ describe("SubagentTool", () => {
expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
const admission = Array.from(yield* Fiber.join(admitted))[0]
expect(admission?.data.item.type).toBe("synthetic")
if (admission?.data.item.type !== "synthetic") return yield* Effect.die("Expected synthetic inbox item")
expect(admission?.data.item.payload.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.item.payload).toMatchObject({
expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(admission?.data.input.data).toMatchObject({
description: "background review",
metadata: {
source: "subagent",
@@ -414,7 +412,7 @@ describe("SubagentTool", () => {
},
})
const database = yield* Database.Service
yield* SessionInbox.promote(database.db, bus, parent.id, "steer")
yield* SessionPending.promote(database.db, bus, parent.id, "steer")
const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
+14 -4
View File
@@ -1,6 +1,7 @@
import { $ } from "bun"
import { homedir } from "node:os"
import { join } from "node:path"
import { downloadCliToResources, windowsify } from "./utils"
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
@@ -37,9 +38,18 @@ function selectOptions(): DevOptions {
}
async function prepareServer(source: ServerSource) {
if (source.type === "download")
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
const destination = windowsify("resources/opencode-cli-dev")
if (source.type === "download") return downloadCliToResources(source.version, destination)
return buildCliToResources(destination, developmentStateHome())
}
function developmentStateHome() {
const appData = (() => {
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
})()
return join(appData, "ai.opencode.desktop.dev")
}
async function startDesktop(args: string[]) {
+28
View File
@@ -86,6 +86,34 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
try {
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --skip-web-ui --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: process.env.OPENCODE_VERSION,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
const child = Bun.spawn([dest, "service", "stop"], {
env: { ...process.env, XDG_STATE_HOME: stateHome },
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await child.exited
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
}
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Built local CLI at ${dest}`)
}
async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
+22 -34
View File
@@ -17,46 +17,34 @@ type Logger = {
export async function startBackgroundCli(logger: Logger) {
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
const cli = development
? {
version: "local",
command: ["bun", "run", "--cwd", development, "dev", "--"],
binary: undefined,
}
: await resolveBundledCli(isolated, logger)
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
command: [...cli.command, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(service.url),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
return {
url: service.url,
username: service.auth.username,
password: service.auth.password,
}
}
async function resolveBundledCli(isolated: boolean, logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
return { version, binary, command: [binary] }
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version,
command: [binary, "serve", "--service"],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
logger.log("v2 CLI background service ready", {
username: service.auth.username,
version,
...endpoint(service.url),
})
if (isolated) await cleanCliStages(binary, logger)
return {
url: service.url,
username: service.auth.username,
password: service.auth.password,
}
}
async function cleanCliStages(binary: string, logger: Logger) {
-89
View File
@@ -1,89 +0,0 @@
# opencode-drive
## 1.4.3
### Patch Changes
- 99561ad: Restore controlled tools against the current V2 plugin API and add typed runtime control for write calls.
## 1.4.2
### Patch Changes
- b524213: Render light box-drawing borders as continuous geometric primitives.
- a24a09d: Defer recording font initialization so source-checkout scripts can start without loading a duplicate renderer.
## 1.4.1
### Patch Changes
- 6a8d52b: Prevent concurrent detached launchers from stealing prepared instance ownership and spawning competing daemon processes.
- d71356f: Restore compatibility with current OpenCode V2 checkouts and packed Drive installations. Drive now uses V2's built-in simulation transport and provider shape, isolates scripted service ports and command forms, and compiles standalone scripts against the launching Drive toolchain without package installation or source-directory links.
## 1.4.0
### Minor Changes
- c20d147: Control arbitrary provider-backed tool lifecycles with dynamic registration, structured progress, success, failure, cancellation, and reconnect-safe replay.
## 1.3.0
### Minor Changes
- 7caebeb: Expose semantic UI snapshots, exact semantic node polling, and safe semantic-node clicks for compatible OpenCode endpoints.
## 1.2.0
### Minor Changes
- 4e0c002: Write screenshots and recordings beneath run- and restart-scoped media directories so named outputs cannot overwrite earlier runs.
## 1.1.0
### Minor Changes
- fad9f96: Allow scripts and library drivers to intercept declared tools and control concurrent invocations by call ID at runtime.
### Patch Changes
- 63d3464: Keep service and progress output out of visible TUI sessions and avoid reinstalling the OpenTUI preload package for development checkouts.
- fd45cfe: Allow Drive runs to select a durable OpenCode database with the Effect-configured `OPENCODE_DRIVE_DB` setting while retaining `:memory:` as the default.
- e66adc1: Preserve recorded frame timing during MP4 encoding and reduce work for dense or unchanged terminal output.
- e7dff5f: Render diagonal quadrant block glyphs as exact terminal cell geometry in screenshots, recordings, and catalog frames.
- 63d3464: Export recordings at 60 FPS by default and preserve the requested frame rate in generated MP4 files.
## 1.0.0
### Major Changes
- 1009394: Remove the Promise-based simulation clients. `SimulationClient`, `BackendSimulationClient`, `connectSimulation`, and `connectBackendSimulation` are gone, along with the `opencode-drive/experimental` entry point. The `opencode-drive/client` entry now exports only the canonical protocol schemas and default ports; the public API is Effect-only, as documented. The CLI drives instances through the Effect `SimulationConnector` directly.
### Minor Changes
- 9deab8d: Add the browser-safe `opencode-drive/frame` entry point: canonical cell geometry, OpenTUI text-attribute bits, the geometric block/bar glyph table, and baseline placement shared by the Drive PNG renderer and downstream canvas renderers. The PNG renderer now also draws the `┃` and `╹` structural bars geometrically instead of with fonts.
### Patch Changes
- 8481090: Settle simulated LLM responses cleanly when OpenCode terminates an invocation during interruption. Drive now uses the negotiated `llm.pending` capability to distinguish external termination from genuine response write failures.
## 0.6.0
### Minor Changes
- 58c4801: Return simulated background shells immediately, continue their handlers asynchronously, notify the session when they finish, and cancel them when Drive shuts down.
- b5e8dfe: Make the script API Effect-only. Script setup and run callbacks, UI, LLM, filesystem, server, and TUI operations now return Effects; LLM serve handlers return Streams; and script cancellation uses Effect interruption without a Promise compatibility shim.
- 775f799: Remove the tool handler `AbortSignal`. Foreground session interruption, transport disconnects, and Drive shutdown now surface uniformly as Effect interruption, and controller shutdown awaits handler finalizers. Detached background shell handlers remain active after launch and are interrupted during Drive shutdown.
- 8e51796: Add deterministic shell, web fetch, and web search handlers with progress, success, failure, and interruption simulation.
- 905f846: Add `opencode-drive script init` for generating an Effect-native starter script and show focused migration guidance when `check` finds Promise-style script callbacks.
- d1bba54: Add first-class tool call input streaming through `Llm.toolCall` stream options.
- 72f7aff: Expose the authenticated generated OpenCode SDK as `opencode` to drivers and scripts.
- 37b4cd1: Give capabilities precise typed errors, validate UI predicates in canonical `ui.waitFor`, expose concrete failures through `Errors`, and keep pure response constructors exclusively under `Llm`.
- 13ec474: Unify the Effect driver and `defineScript` around one canonical programmatic model. Both expose the generated SDK as `opencode`, the primary frontend as `tui`, additional frontends through `tuis`, and the primary UI as `ui`. Every `Tui` has the same `{ ui, close, recording }` shape and `{ recording, viewport }` options. Project setup now uses the shared `Project`, `Setup`, `SetupContext`, and `ProjectFileSystem` types. Remove duplicate script UI types, flattened frontend handles, partial settlement controls, root-level raw simulation exports, convenience CLI aliases, and the `wait` helper.
### Patch Changes
- c8f5b51: Attach one best-effort normalized terminal frame to UI polling timeout errors without retaining screenshot artifacts.
- c8f5b51: Render OpenCode's full UI symbol set with deterministic bundled fallback fonts instead of platform fonts or hand-drawn symbol exceptions.
- c8f5b51: Preserve the managed driver's `Scope.Scope` requirement when consumed from TypeScript workspace applications.
- 40d2241: Render the background completion arrow correctly in exported recordings.
- 11cbbfd: Preserve the canonical OpenCode UI command shapes for optional named screenshots and key presses.
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 opencode-drive
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-565
View File
@@ -1,565 +0,0 @@
# opencode-drive
This project gives your agents control over OpenCode:
- Run it during development and let your agents see and poke at the running instance
- Allow your agents to run it in headless mode and drive it to test things
## Requirements
OpenCode Drive requires [Bun](https://bun.sh/) 1.3.14 or newer. MP4 recording export also requires `ffmpeg` on `PATH`.
Install dependencies with:
```sh
bun install
```
## Skill
```sh
npx skills add anomalyco/opencode --agent opencode --skill opencode-drive
```
## Effect programs
The primary way to automate OpenCode is a default-exported, fully provided
Effect. Drive type-checks the module contract, compiles the script and its local
imports against the launching Drive toolchain, then validates and runs the
export in an isolated Bun process:
```ts
// drive.ts
import { OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
```
```sh
opencode-drive run ./drive.ts
```
`run` accepts exactly one module path. It rejects `--command.*` flags, other
command flags, and application arguments after `--`. Backend and UI behavior
belongs in the Effect program.
`OpenCodeDriver.use` is the safe default. It owns the scope, observes backend
failure, settles queued LLM work, closes every TUI, and exports recordings
whether the program succeeds or fails:
```ts
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use(
{
project: {
git: true,
files: { "src/value.ts": "export const value = 1\n" },
},
},
({ ui, llm }) =>
Effect.gen(function* () {
yield* llm.queue(Llm.text("The value is 1."))
yield* ui.submit("Read src/value.ts")
yield* ui.waitFor("The value is 1.")
}),
)
```
Use `OpenCodeDriver.useReport` when the program also needs structured evidence.
It returns the program value plus a schema-validated report containing branded
artifact and recording paths, retention, and the negotiated or legacy
compatibility of every simulation endpoint:
```ts
const result = yield * OpenCodeDriver.useReport(options, program)
yield * Effect.log(result.report)
```
Drive prefers `simulation.handshake` and explicitly records legacy fallback.
Require negotiation when protocol skew must fail before the program runs:
```ts
OpenCodeDriver.use(
{
opencode: { compatibility: "required" },
},
program,
)
```
Additional TUIs share the same server and LLM controller:
```ts
import { Effect } from "effect"
import { OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use((oc) =>
Effect.gen(function* () {
const secondary = yield* oc.tuis.launch({
viewport: { cols: 120, rows: 40 },
})
yield* oc.ui.screenshot("primary")
yield* secondary.ui.screenshot("secondary")
}),
)
```
The generated OpenCode SDK client is exposed as `opencode`; launched frontend
processes are `tui` and `tuis`. This keeps SDK calls distinct from terminal UI
control:
```ts
const health = yield * opencode.health.get()
const frame = yield * tui.ui.capture()
```
Enable recording per TUI. Settlement finishes each timeline and exports its
video automatically:
```ts
import { Effect } from "effect"
import { OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use({ tui: { recording: true } }, (oc) =>
Effect.gen(function* () {
yield* oc.ui.screenshot("recorded-home")
yield* Effect.log(`recording will be exported to ${oc.tui.recording?.path}`)
}),
)
```
Settlement errors are program failures. For example, output after a terminal
LLM event fails the run while `use` still closes TUIs and attempts recording
export:
```ts
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use(({ ui, llm }) =>
Effect.gen(function* () {
yield* llm.queue(Llm.finish(), Llm.text("too late"))
yield* ui.submit("trigger a response")
}),
)
```
Use `OpenCodeDriver.make` only when the program needs explicit terminal
settlement. It requires a scope, and `driver.settle()` must run before leaving
that scope:
```ts
import { Effect } from "effect"
import { OpenCodeDriver } from "opencode-drive"
export default Effect.scoped(
Effect.gen(function* () {
const driver = yield* OpenCodeDriver.make()
yield* driver.ui.screenshot("home")
yield* driver.settle()
}),
)
```
Use `opencode-drive check ./drive.ts` and `start --script` for the Effect-native
`defineScript` workflow described below.
## OpenCode development
Run this:
```sh
OPENCODE_DRIVE=1 bun run dev
```
If you installed the skill file, OpenCode will be able to see and interact with the running instance.
## Using with agents
Install the skill file above and ask the agent to test various flows with the app. Start with `--record` when you want a video; `opencode-drive stop` then exports the complete session and prints its path.
Screenshots and videos are written beneath `<system temp>/opencode-drive/output/<run-id>/<generation-id>`, so named outputs cannot overwrite media from earlier runs or restarts. Set `OPENCODE_DRIVE_MEDIA_DIR` to use a different media root.
Captured frames use the official full Commit Mono v1.143 faces at 16px with bundled Noto Symbols, Symbols 2, and Math fallbacks in a fixed 10x20 cell grid. Set `OPENCODE_DRIVE_FONT` to a comma-separated list of font files (for example regular, bold, italic, and bold-italic faces) to use a different primary capture font without changing the symbol fallback or cell geometry.
## UI development
If you are doing UI development in OpenCode, you might want to run it in a simulated mode. This allows `opencode-drive` to drive it and always put it into a state that you want to see.
Run it in visible mode:
```sh
opencode-drive start --visible --dev ~/projects/opencode
```
Initialize first when you need to customize the isolated environment before OpenCode starts:
```sh
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --visible --dev ~/projects/opencode
```
`start` reuses the prepared artifacts for that name. If `init` was not run, `start` initializes them automatically.
Drive uses an in-memory OpenCode database by default. Set
`OPENCODE_DRIVE_DB` when a test restarts the OpenCode service and needs sessions
to survive the replacement process. Relative paths resolve inside the isolated
run's OpenCode data directory:
```sh
OPENCODE_DRIVE_DB=restart.sqlite \
opencode-drive start --name restart-demo --script ./restart.ts
```
Remove artifact directories left by sessions that are no longer active:
```sh
opencode-drive prune
```
Prune one inactive instance's artifacts by instance name, or force removal of all artifact directories:
```sh
opencode-drive prune --name demo
opencode-drive prune --force
```
While developing, you can run `opencode-drive restart` to restart only the UI (the server will persist as a separate process). Do this with agents, and they will always restart and get the UI where you want it to be automatically.
View the [skills file](https://github.com/anomalyco/opencode/blob/v2/.opencode/skills/opencode-drive/SKILL.md) for more details about the CLI.
## Effect script API
Scripted runs use one fully typed, Effect-only definition. `setup` and `run`
return Effects; Promise callbacks are not part of the API:
```sh
opencode-drive script init ./drive.ts
```
This creates a canonical starter without overwriting an existing file. The
generated script is ready for `opencode-drive check ./drive.ts` and
`start --script ./drive.ts`.
```ts
import { defineScript, Effect, Llm } from "opencode-drive"
export default defineScript({
config: {
autoupdate: false,
},
tuiConfig: {
theme: "system",
},
project: {
git: true,
files: {
"src/example.ts": "export const value = 1\n",
},
},
setup: ({ config, tuiConfig }) =>
Effect.sync(() => {
config.username = "Drive"
tuiConfig.scroll_speed = 1
}),
run: ({ ui, llm }) =>
Effect.gen(function* () {
yield* ui.submit("Read src/example.ts")
yield* llm.send(Llm.text("The value is 1."))
yield* ui.waitFor("The value is 1.")
}),
})
```
`project.files` seeds the isolated project before `setup` runs. With
`project.git: true`, Drive creates a fresh repository and commits the complete
pre-launch state, including files written in `setup`. A prepared repository is
never replaced; omit `project.git` when an `init` step supplies Git history.
Declared `config` and `tuiConfig` values are deeply merged over fixture
`.opencode/opencode.jsonc` and `.opencode/tui.jsonc` files. Arrays replace
instead of merging, and mutations made in `setup` take final precedence.
Attach arbitrary provider-backed tools at runtime with their JSON schemas, then
take and settle native OpenCode invocations by model call ID. `attach` replaces
the complete dynamic set atomically; it does not affect the built-in adapters
configured through the driver or script `tools` option.
```ts
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use(({ tools, llm, ui }) =>
Effect.gen(function* () {
yield* tools.attach({
tools: [
{
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
outputSchema: {
type: "object",
properties: { answer: { type: "number" } },
required: ["answer"],
},
options: { codemode: false },
},
],
})
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "call_lookup",
name: "lookup",
input: { query: "meaning" },
}),
Llm.finish("tool-calls"),
)
yield* ui.submit("Look up the meaning")
const lookup = yield* tools.take("call_lookup")
yield* lookup.progress({
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
})
yield* lookup.finish({
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
}),
)
```
Drive owns progress sequence numbers and retries uncertain operations without
rerunning a claimed call. `awaitCancelled()` completes when OpenCode interrupts
the native invocation before `finish` or `fail`. Dynamic registrations survive
the tool-only controller reconnecting; an intentional server generation change
cancels unresolved calls and reapplies the desired set after launch.
Declare which built-in tools Drive should intercept with `tools`, then control
their invocations inside `run`. Each tool controller accepts calls in arrival
order or by the stable call ID chosen in `Llm.toolCall`:
```ts
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
tools: ["shell"],
run: ({ ui, llm, tools }) =>
Effect.gen(function* () {
const shells = yield* tools.control("shell")
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "call_shell",
name: "shell",
input: { command: "deploy production" },
}),
Llm.finish("tool-calls"),
)
yield* ui.submit("Deploy production")
const shell = yield* shells.take("call_shell")
yield* shell.progress(`Running: ${shell.input.command}...\n`)
yield* shell.succeed({ output: "Controlled output\n", exit: 0 })
}),
})
```
Use `calls.take(id)` to coordinate known parallel calls independently, or
`calls.take()` to accept the next unclaimed invocation. A controlled call can
emit progress and then succeed or fail exactly once. `awaitInterrupted()`
observes OpenCode interruption or transport disconnection. Drive interrupts
all unresolved calls when it shuts down.
The original `tools(registry)` callback remains available for fixed handlers
that do not need orchestration from `run`. Foreground handler Effects are
interrupted when OpenCode interrupts the session, the transport disconnects,
or Drive shuts down. Detached background shell handlers continue after their
launch response and are interrupted when Drive shuts down.
Only declared or registered tools are replaced. Unhandled tools continue to
use OpenCode's real implementations. Each `progress` value replaces the
visible tool output; send accumulated output when earlier lines should remain
visible.
Supported adapters are `shell`, `webfetch`, and `websearch`; each handler
receives its canonical typed V2 input and maintains an independent call index.
When a shell call sets `background: true`, Drive returns immediately with the
OpenCode tool call ID as `shellID`, keeps the handler running, and injects the
terminal `completed`, `error`, or `cancelled` result into the session
automatically. Background handlers are cancelled when Drive shuts down.
Type-check every new or edited script before running it:
```sh
opencode-drive check ./drive.ts
```
Drive resolves its script API, Effect, Bun declarations, and `tsgo` from the
launching installation without installing packages or modifying the script's
directory. When it detects an old Promise-style `setup`, `run`, or `ui.waitFor`
callback, it prints the equivalent Effect shape after the TypeScript
diagnostics. Use `Effect.sleep(milliseconds)` for unconditional delays.
The `fs`, `ui`, `llm`, `tools`, `server`, and `tuis` capabilities expose
Effect-returning operations. Compose them with `yield*`, `Effect.flatMap`, or
other Effect operators. Scripts receive the same `Ui`, `Tui`, `Tuis`, and TUI
options as `OpenCodeDriver`; `defineScript` does not define a second
programmatic interface. Predicates passed to `ui.waitFor` may return a boolean
or an Effect. Set `launch: "manual"` to launch the shared OpenCode server and
every TUI explicitly:
```ts
import { Effect } from "effect"
import { defineScript } from "opencode-drive"
export default defineScript({
launch: "manual",
run: ({ ui, server, tuis }) =>
Effect.gen(function* () {
// ui is null in manual mode.
yield* server.launch()
const alice = yield* tuis.launch("alice")
const bob = yield* tuis.launch("bob")
yield* alice.ui.submit("Hello from Alice")
yield* bob.ui.screenshot("bob-view")
}),
})
```
Only one server may be launched per script. All TUIs share its LLM backend. TUI
processes and compiled script artifacts are cleaned up when the script ends.
`yield* server.kill()` stops the server so it can be launched again later.
`yield* tui.close()` closes a TUI, after which its name may be reused.
Pass `{ recording: true }` to record an individual TUI:
```ts
const alice = yield * tuis.launch("alice", { recording: true })
yield * alice.ui.submit("Hello")
yield * alice.close()
```
Recordings are exported when the script settles. Call
`alice.recording.finish()` only when the video is needed before settlement.
Background title requests receive `OpenCode Drive` by default and do not
consume `llm.queue`, `llm.send`, or `llm.serve` responses. Manual-launch
scripts can customize them before starting the server:
```ts
yield * llm.title(() => Effect.succeed("Custom title"))
yield * server.launch()
```
Use `yield* llm.send(...)` to wait for and complete the next request or `yield*
llm.queue(...)` to declare future responses upfront. For ongoing responses,
the handler passed to `llm.serve` returns an Effect `Stream`:
```ts
import { Stream } from "effect"
import { Llm } from "opencode-drive"
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
```
The backend connection, default `finish("stop")`, and cleanup are automatic.
Cancellation is represented by Effect interruption: interrupting the script or
the fiber running an operation interrupts its in-flight work and runs scoped
finalizers. There is no Promise compatibility shim or separate cancellation
API. All public script types are canonically defined in
[`src/script/types.ts`](./src/script/types.ts), which can be provided directly
to an authoring agent.
`Llm.text()` streams text in randomized chunks. It defaults to a 2 ms delay and
a target chunk size of 15 characters, varied by plus or minus 5 per chunk:
```ts
Llm.text("A deliberately slower response", { delay: 20, chunkSize: 10 })
```
`Llm.reasoning()` accepts the same streaming options. Use
`Llm.pause(milliseconds)` to add timing between any two outputs.
`Llm.toolCall()` emits a complete call atomically by default. Pass the same
streaming options to expose partial JSON input while it is generated:
```ts
Llm.toolCall(
{
index: 0,
id: "call_patch",
name: "patch",
input: { patchText: "*** Begin Patch\n*** End Patch" },
},
{ delay: 40, chunkSize: 12 },
)
```
Finish a tool-calling response with `Llm.finish("tool-calls")`. Streamed calls
drive OpenCode's normal tool-input start, delta, and end lifecycle; `Llm.raw()`
remains available for provider-wire scenarios not covered by these helpers.
Current OpenCode simulation endpoints expose a semantic UI tree alongside
renderer state and terminal capture. Use `ui.snapshot()` for the complete
versioned tree or `ui.getNode()` to poll for one exact semantic match. Semantic
nodes carry stable IDs, optional occurrence identity, role, label, hierarchy,
component-owned state, and a transient element handle that `ui.click()` can
resolve safely:
```ts
const allow =
yield *
ui.getNode({
role: "option",
label: "Allow once",
selected: true,
disabled: false,
})
yield * ui.click(allow)
```
`ui.snapshot` and atomic semantic clicks are negotiated as optional
capabilities so ordinary operations remain compatible with older OpenCode
checkouts. Calling `ui.snapshot()`, `ui.getNode()`, or `ui.click(node)` when its
required capability is unavailable fails locally with `UiCapabilityError`.
Capability errors are typed and the concrete classes are grouped under
`Errors`. UI timeouts remain owner-fatal even when caught; recover locally
from errors for which the script has a truthful fallback:
Polling timeouts from `ui.waitFor`, `ui.getElement`, and `ui.getNode` make one
best-effort, bounded `ui.capture` request. When it succeeds, the resulting
normalized terminal frame is available as `error.frame` without creating or
retaining a screenshot file. RPC-level timeouts and failed diagnostic captures
leave `error.frame` undefined.
```ts
import { Effect } from "effect"
import { Errors } from "opencode-drive"
yield *
ui
.getElement({ editor: true })
.pipe(Effect.catchTag("UiElementAmbiguousError", (error) => Effect.logWarning(`Matched ${error.count} editors`)))
const isFileSystemError = (error: unknown) => error instanceof Errors.FileSystemError
```
## Release validation
Before publishing a release, run the non-publishing validation command to
check, test, and inspect the packed artifact:
```sh
bun run release:validate
```
-27
View File
@@ -1,27 +0,0 @@
# Releasing opencode-drive
`opencode-drive` keeps its own version line. OpenCode product releases must not rewrite its version.
The imported baseline is `1.4.3` and the workspace package remains `private` until release setup is complete.
Do not remove that guard or publish from this repository until both release gates are complete:
1. The versions of `@opencode-ai/client` and `@opencode-ai/protocol` written into the packed Drive manifest are available on npm, including the `@opencode-ai/protocol/simulation` export.
2. npm package administration and trusted publishing move from `anomalyco/opencode-drive` to `anomalyco/opencode`.
The npm package is currently maintained by `jlongster`, and its trusted publisher is the old repository's
`publish.yml`. James must add the destination release operator as an npm owner or update the trusted publisher
himself. Keep James as an owner through the first successful release from this repository.
The first destination release will be `1.4.4`, which contains the pending special-key fix after `1.4.3`. Use a
dedicated GitHub-hosted workflow named `publish-drive.yml` with Node 24, npm trusted publishing, and
`id-token: write`. Its tag must be `opencode-drive-v1.4.4`; bare `v1.4.4` already belongs to OpenCode.
Before enabling that workflow:
1. Pack Drive and inspect the rewritten `package.json` inside the tarball.
2. Install the tarball in a clean Bun consumer and import every public export.
3. Run the installed `opencode-drive` binary and one scripted flow.
4. Configure npm's trusted publisher for `anomalyco/opencode` and `publish-drive.yml`.
5. Publish the namespaced tag and verify npm provenance points at this repository and workflow.
After the first successful destination release, disable the old publish workflow and archive the old repository.
@@ -1,90 +0,0 @@
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
@@ -1,93 +0,0 @@
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/symbols)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-2
View File
@@ -1,2 +0,0 @@
#!/usr/bin/env bun
import "../src/cli/index.js"
-46
View File
@@ -1,46 +0,0 @@
import * as Effect from "effect/Effect"
import { OpenCodeDriver, Tool } from "../src/index.js"
import type { Frontend, Project, Tui, Tuis, Ui } from "../src/index.js"
import type { ScriptContext } from "../src/script/types.js"
type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends <Value>() => Value extends Right ? 1 : 2 ? true : false
type Assert<Value extends true> = Value
export type ScriptUiIsCanonical = Assert<Equal<ScriptContext["ui"], Ui>>
export type ScriptTuiIsCanonical = Assert<Equal<ScriptContext["tui"], Tui>>
export type ScriptTuisAreCanonical = Assert<Equal<ScriptContext["tuis"], Tuis>>
export type ScriptToolsAreCanonical = Assert<Equal<ScriptContext["tools"], Tool.Controls>>
export type DriverToolsAreCanonical = Assert<Equal<OpenCodeDriver.Driver["tools"], Tool.Controls>>
export type LaunchedTuiIsCanonical = Assert<Equal<Effect.Success<ReturnType<Tuis["launch"]>>, Tui>>
export type ResizeIsCanonicalAction = Assert<
Equal<
Extract<Frontend.Action, { readonly type: "ui.resize" }>,
{
readonly type: "ui.resize"
readonly cols: number
readonly rows: number
}
>
>
export type DriverProjectIsCanonical = Assert<Equal<NonNullable<OpenCodeDriver.Options["project"]>, Project>>
const zeroConfig = OpenCodeDriver.use(() => Effect.void)
export type ZeroConfigUseIsRunnable = Assert<Equal<Effect.Services<typeof zeroConfig>, never>>
const controlledOptions: OpenCodeDriver.Options = { tools: ["shell"] }
declare const controls: Tool.Controls
const shellCalls = controls.control("shell")
declare const dynamicTools: Tool.AttachParams
const attached = controls.attach(dynamicTools)
const dynamicCall = controls.take("call_lookup")
declare const toolName: Tool.Name
controls.control(toolName)
export type ShellControlIsTyped = Assert<
Equal<Effect.Success<typeof shellCalls>, Tool.ControlledCalls<Tool.ShellInput, Tool.ShellResult>>
>
export type DynamicAttachIsTyped = Assert<Equal<Effect.Success<typeof attached>, void>>
export type DynamicCallIsTyped = Assert<Equal<Effect.Success<typeof dynamicCall>, Tool.Invocation>>
const controlled = OpenCodeDriver.use(controlledOptions, ({ tools }) => tools.control("shell").pipe(Effect.asVoid))
export type ControlledUseIsRunnable = Assert<Equal<Effect.Services<typeof controlled>, never>>
-500
View File
@@ -1,500 +0,0 @@
# OpenCode Driver API
Status: exploratory implementation, settled call sites only
This document records interface shapes that have been accepted during design. It intentionally omits unresolved alternatives rather than presenting them as competing proposals.
Internal resource ownership and desugaring are documented in [OpenCode Driver Architecture](./open-code-driver-architecture.md).
## Run Effect programs from the CLI
`opencode-drive run <module>` is the primary CLI entrypoint. The module must
default-export an `Effect<_, _, never>`. Before importing the module, Drive
generates and type-checks a contract entrypoint that assigns its default export
to that fully provided Effect type. Drive then imports the module, verifies the
value with `Effect.isEffect`, and yields it directly from the command handler.
There is no nested runtime or detached owner.
```ts
import { OpenCodeDriver } from "opencode-drive"
export default OpenCodeDriver.use(({ ui }) => ui.screenshot("home"))
```
```sh
opencode-drive run ./drive.ts
```
The command accepts no flags and no arguments after `--`. Use the driver API in
the module for simulation control. `opencode-drive check` validates Effect-only
`defineScript` modules, and `start --script` executes them.
## `use` settles one scoped driver
`OpenCodeDriver.use(run)` is the zero-configuration top-level interface;
`OpenCodeDriver.use(options, run)` configures the same lifecycle. Both acquire
the driver returned by `make`, run the program, validate queued LLM work,
finish recordings, close TUIs, export videos, and then release the server
and project scope.
`OpenCodeDriver.useReport(run)` and `useReport(options, run)` have the same lifecycle semantics and
returns both the user value and a compact `RunReport`. The report contains
validated artifact and recording paths, retention, and endpoint compatibility.
Set `opencode.compatibility` to `"required"` or `"preferred"`;
the default is `"preferred"`, which negotiates when supported and reports an
explicit legacy profile otherwise.
```ts
import { NodeRuntime } from "@effect/platform-node"
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"
const program = OpenCodeDriver.use(
{
project: {
git: true,
files: {
"src/example.ts": "export const value = 1\n",
},
},
config: {
autoupdate: false,
},
tui: {
viewport: {
cols: 96,
rows: 32,
},
recording: false,
},
},
({ ui, llm }) =>
Effect.gen(function* () {
yield* llm.queue(Llm.text("The value is 1."))
yield* ui.submit("Read src/example.ts")
yield* ui.waitFor("The value is 1.")
}),
)
NodeRuntime.runMain(program)
```
`OpenCodeDriver.make(...)` remains the lower-level scoped constructor for programs that need to control settlement explicitly. Call `driver.settle()` before leaving its scope. `settle()` is terminal: it rejects new TUIs and LLM responses, validates queued work, stops TUIs, and exports recordings.
```ts
const program = Effect.scoped(
Effect.gen(function* () {
const driver = yield* OpenCodeDriver.make(options)
yield* driver.ui.submit("Hello")
yield* driver.settle()
}),
)
```
Capture font size is not part of this interface. The current renderer uses a fixed 16px font in 10-by-20 cells; the terminal catalog's `OPENCODE_DRIVE_FONT_SIZE=14` environment variable is currently ignored.
The generated SDK client is `opencode`. The primary frontend process is `tui`,
its UI is also available directly as `ui`, and `tuis` launches more frontend
processes:
```ts
const health = yield * driver.opencode.health.get()
const frame = yield * driver.tui.ui.capture()
const secondary = yield * driver.tuis.launch()
```
## The driver has one primary TUI and optional additional TUIs
The `tui` section configures the primary frontend created by `make`. Its UI is exposed directly as `ui` for the common case.
Additional TUIs connect to the same server and expose their own UI:
```ts
const program = Effect.scoped(
Effect.gen(function* () {
const oc = yield* OpenCodeDriver.make({
tui: {
viewport: {
cols: 96,
rows: 32,
},
},
})
const secondary = yield* oc.tuis.launch({
viewport: {
cols: 120,
rows: 40,
},
recording: true,
})
yield* oc.ui.submit("Prompt from the primary TUI")
yield* secondary.ui.submit("Prompt from the secondary TUI")
yield* oc.settle()
}),
)
```
`tuis.launch(options)` generates an identity. Pass a name as the first argument
when a stable identity is useful for logs, recordings, or closing and
relaunching the same TUI: `tuis.launch(name, options)`.
```text
╭────────────────╮
│ OpenCodeDriver ├───────────────────────╮
╰────────┬───────╯ │
╭────────────────╰──────────────────╮ │
▼ ▼ │
╭────────────────────────╮ ╭────────────────────╮ │
│ Shared OpenCode Server │ │ Shared LLM Control │ │
╰────────────┬───────────╯ ╰────────────────────╯ │
╰───────────────────────────────╮ │
▼ ▼ │
╭────────────────╮ ╭────────────────────╮ │
│ Primary TUI │◀───────────│ Additional TUIs │◀─────╯
╰────────┬───────╯ ╰──────────┬─────────╯
╰───╮ ╭──────╯
▼ ▼
╭────╮ ╭───────────╮
│ ui │ │ tui.ui │
╰────╯ ╰───────────╯
```
## Common scripts destructure UI and LLM control
Scripts that only need the primary TUI should normally destructure the driver:
```ts
const driver = yield * OpenCodeDriver.make()
const { ui, llm } = driver
yield * llm.queue(Llm.text("Hello from the simulated model."))
yield * ui.submit("Hello")
yield * ui.waitFor("Hello from the simulated model.")
yield * driver.settle()
```
Keep the aggregate value only when driver-wide capabilities such as `tuis` are needed:
```ts
const oc = yield * OpenCodeDriver.make()
const secondary = yield * oc.tuis.launch()
yield * oc.ui.screenshot("primary")
yield * secondary.ui.screenshot("secondary")
yield * oc.settle()
```
## Runtime tool control uses statically declared adapters
Declare the built-in tool names Drive should intercept before OpenCode starts,
then control each invocation through the live `tools` capability. Undeclared
tools keep their real OpenCode implementations.
```ts
const program = OpenCodeDriver.use({ tools: ["shell"] }, ({ tools, llm, ui }) =>
Effect.gen(function* () {
const shells = yield* tools.control("shell")
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "call_build",
name: "shell",
input: { command: "bun run build" },
}),
Llm.toolCall({
index: 1,
id: "call_test",
name: "shell",
input: { command: "bun run test" },
}),
Llm.finish("tool-calls"),
)
yield* ui.submit("Build and test")
const build = yield* shells.take("call_build")
const test = yield* shells.take("call_test")
yield* test.succeed({ output: "Tests passed\n", exit: 0 })
yield* build.succeed({ output: "Build passed\n", exit: 0 })
}),
)
```
`take(callID)` reserves and accepts one known invocation independently of
arrival order. `take()` accepts the oldest unclaimed invocation. Exact-ID
waiters take precedence over generic waiters, so parallel calls may settle in
any deliberate order. Each call may emit serialized progress and then succeed
or fail exactly once. `awaitInterrupted()` completes when transport or
controller interruption wins before terminal settlement.
The program must take and terminally settle every intercepted invocation it
expects. Driver scope closure fails blocked `take` operations, interrupts
unresolved calls, and waits for transport cleanup. The callback-style
`tools(registry)` configuration remains available
for fixed handlers; callback-controlled tools are not also available through
the runtime `tools.control` capability.
## Arbitrary tools use the provider-backed lifecycle
`tools.attach({ tools })` atomically replaces the complete dynamic registration
set for the current run. Registrations use OpenCode's canonical JSON Schema,
permission, namespace, and CodeMode options. Static `shell`, `webfetch`, and
`websearch` adapters remain installed separately.
```ts
yield *
tools.attach({
tools: [
{
name: "lookup",
description: "Look up a value",
inputSchema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
options: { codemode: false },
},
],
})
const invocation = yield * tools.take("call_lookup")
yield *
invocation.progress({
structured: { phase: "searching" },
content: [{ type: "text", text: "Searching" }],
})
yield *
invocation.finish({
structured: { answer: 42 },
content: [{ type: "text", text: "42" }],
})
```
`take(callID)` matches `context.callID`, the model call ID supplied to
`Llm.toolCall`; `invocation.id` is the producer's transport identity. Drive
deduplicates invocation replay after a controller reconnect and retries
progress or terminal operations with the same producer identity and progress
sequence. `awaitCancelled()` observes OpenCode's native interruption. There is
no public cancel operation because cancellation flows from OpenCode to Drive.
Attaching a dynamic effective name that collides with a configured static
adapter fails locally. Calling `attach({ tools: [] })` clears the dynamic set.
Older OpenCode revisions remain compatible with static adapters and ordinary
LLM control; dynamic attachment fails with `Tool.LifecycleError` when the six
tool lifecycle capabilities are unavailable.
## LLM response description is separate from live LLM control
`Llm` is a pure data module. `llm` is the live capability that queues, sends, and serves responses.
```ts
yield *
llm.queue(
Llm.reasoning("Inspecting the file"),
Llm.pause(20),
Llm.text("The value is 1.", {
delay: 2,
chunkSize: 15,
}),
Llm.finish("stop"),
)
```
Each constructor returns an ordinary serializable value. Raw values with the same schema remain accepted.
Tool calls remain atomic when options are omitted. Supplying stream options
serializes the input to JSON and emits provider-neutral partial tool input when
the endpoint advertises that capability. Older endpoints retain the existing
OpenAI-compatible fallback:
```ts
Llm.toolCall(
{
index: 0,
id: "call_patch",
name: "patch",
input: { patchText: "*** Begin Patch\n*** End Patch" },
},
{ delay: 40, chunkSize: 12 },
)
```
The authoritative schema is a manual union of independently named variants:
```ts
export const Text = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
options: Schema.optionalKey(StreamOptions),
})
export interface Text extends Schema.Schema.Type<typeof Text> {}
export const Reasoning = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
options: Schema.optionalKey(StreamOptions),
})
export interface Reasoning extends Schema.Schema.Type<typeof Reasoning> {}
export const Pause = Schema.Struct({
type: Schema.Literal("pause"),
milliseconds: NonNegativeMilliseconds,
})
export interface Pause extends Schema.Schema.Type<typeof Pause> {}
export const Finish = Schema.Struct({
type: Schema.Literal("finish"),
reason: Schema.optionalKey(FinishReason),
})
export interface Finish extends Schema.Schema.Type<typeof Finish> {}
export const Output = Schema.Union([Text, Reasoning, Pause, Finish, ToolCall, Raw, Disconnect])
export type Output = Schema.Schema.Type<typeof Output>
```
Pure constructors delegate to those individual schemas:
```ts
export const text = (text: string, options?: StreamOptions): Text =>
Text.make({
type: "text",
text,
...(options ? { options } : {}),
})
```
No `.cases` interface appears in userland.
## One `queue` call describes one future model response
Multiple outputs in one call are ordered events within one response:
```ts
yield *
llm.queue(
Llm.toolCall({
index: 0,
id: "call_permission_capture",
name: "patch",
input: {
patchText,
},
}),
Llm.finish("tool-calls"),
)
```
A second call queues a response for the next model request:
```ts
yield * llm.queue(Llm.text("The fixture was updated."))
```
Responses without an explicit terminal output finish with `"stop"`. Title requests remain separate and do not consume this queue.
## `defineScript` is Effect-only
`defineScript` does not provide a Promise adapter. Its `setup` and `run`
callbacks return Effects, as do operations on `fs`, `ui`, `llm`, `server`,
and `tuis`. Compose script operations in the same runtime with
`yield*` or Effect operators.
### Primary UI
```ts
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
run: ({ ui, llm }) =>
Effect.gen(function* () {
yield* llm.queue(Llm.text("The value is 1."))
yield* ui.submit("Read src/example.ts")
yield* ui.waitFor("The value is 1.")
}),
})
```
`llm.serve` accepts a handler that returns an Effect `Stream`. The registration
itself is also an Effect:
```ts
import { Stream } from "effect"
import { Llm } from "opencode-drive"
yield * llm.serve((_request, index) => Stream.make(Llm.text(`Response ${index + 1}`)))
```
Predicates passed to `ui.waitFor` may return a boolean or an Effect.
Capability methods expose typed error channels. Concrete tagged errors are
available from the `Errors` namespace.
`ui.snapshot()` returns the endpoint's versioned semantic tree. `ui.getNode()`
polls for one exact match and fails with `UiNodeAmbiguousError` when more than
one node matches. Semantic snapshots and identity-checked semantic clicks are
optional during negotiation, so older OpenCode checkouts retain ordinary UI
control while unsupported semantic operations fail locally with
`UiCapabilityError`.
```ts
const option =
yield *
ui.getNode({
role: "option",
label: "Allow once",
selected: true,
})
yield * ui.click(option)
```
### Additional TUI
```ts
yield * server.launch()
const alice = yield * tuis.launch("alice")
const bob = yield * tuis.launch("bob")
yield * alice.ui.submit("Hello from Alice")
yield * bob.ui.screenshot("bob-view")
```
### TUI configuration
```ts
export default defineScript({
tui: {
viewport: {
cols: 118,
rows: 34,
},
},
run: ({ ui }) => ui.screenshot("home").pipe(Effect.asVoid),
})
```
Script cancellation uses Effect interruption. Interrupting the script or an
operation's fiber interrupts in-flight work and runs its scoped finalizers;
there is no `AbortSignal`, Promise cancellation convention, or compatibility
shim.
## Settled interface
- `OpenCodeDriver.use(run)` and `use(options, run)` are the safe top-level brackets and perform typed settlement.
- `OpenCodeDriver.make(options)` is the primary scoped constructor.
- `opencode` is the generated OpenCode SDK client.
- Programs that call `make` directly call terminal `driver.settle()` before leaving the scope.
- Direct library programs run the same Effect without any export convention.
- The `tui` section configures one primary TUI.
- The primary TUI's UI is exposed as `ui` and `oc.ui`.
- The common case destructures `{ ui, llm }`.
- `oc.tuis.launch(options?)` creates an additional TUI with a generated identity.
- `oc.tuis.launch(name, options?)` creates a TUI with a stable identity.
- Additional TUIs expose their UI as `tui.ui`.
- Drivers and scripts share the same `Tui`, `Tuis`, `Ui`, and option types.
- `Llm` exposes pure constructors over manually composed Effect Schemas.
- Raw schema-compatible LLM output objects remain accepted.
- One `llm.queue(...)` call describes one future model response.
@@ -1,228 +0,0 @@
# OpenCode Driver Architecture
This guide describes the current Effect-native architecture. The public call
sites are documented in [OpenCode Driver API](./open-code-driver-api.md).
## Domain Model
`OpenCodeDriver` composes these resources:
```text
OpenCodeDriver
project isolated files and configuration
opencode generated OpenCode SDK client
tui primary frontend process
tuis additional frontend process factory
ui convenience alias for tui.ui
llm shared simulated-model control
tools runtime control for static adapters and arbitrary tools
```
The names distinguish the two kinds of client involved:
- `opencode` is the generated `@opencode-ai/client` SDK value.
- `Tui` is a launched OpenCode frontend process with `ui`, `close`, and an
optional `recording`.
- `Tuis` launches and supervises additional frontend processes connected to
the same server.
- `tools.control` accepts independently controlled invocations for adapters
declared before OpenCode starts.
- `tools.attach` and `tools.take` control arbitrary native tools through the
canonical provider-backed lifecycle.
- Transport-level JSON-RPC clients remain private implementation details.
`defineScript` consumes these exact capabilities. It adds a branded module
contract, restart behavior, filesystem access, and explicit manual launch. It
does not define another UI, TUI, LLM, or project vocabulary.
## Ownership
```text
Effect Scope
OpenCodeProject
artifact root
isolated project files
OpenCodeInstance
server process
TUI processes
launch descriptors and logs
CLI script ToolController
controlled invocation exchanges
OpenCodeServer
backend simulation connection
reconnecting tool-only backend connection
LLM controller
dynamic ToolProducer
generated OpenCode SDK connection
TUI supervisor
primary TUI scope
additional TUI scopes
Library ToolController
controlled invocation exchanges
```
Library drivers create their ToolController before project preparation and
pass that controller into `OpenCodeInstance`. CLI scripts create the controller
inside `OpenCodeInstance`. Prepared drivers and script contexts combine the
instance's static controller with the server's dynamic producer. The static
controller that wrote plugin configuration remains the one exposed through
`tools.control`.
`OpenCodeDriver.make(options)` requires `Scope.Scope`. It returns once the
server, generated SDK client, primary TUI, and simulation connections are
ready. `OpenCodeDriver.use` supplies that scope and performs terminal
settlement even when the user program fails.
## Settlement
Settlement is one shared terminal operation. It runs in this order:
1. Validate that queued LLM work was consumed.
2. Validate that native dynamic-tool invocations were settled.
3. Shut down the LLM controller.
4. Finish active recording timelines.
5. Close all TUI scopes and processes.
6. Export completed recordings.
7. Decode the schema-validated `RunReport`.
`driver.settle()` is shared and idempotent. Once settlement starts, `tuis`
rejects new launches and `llm` rejects new responses. `OpenCodeDriver.use`
combines a user-program failure with a settlement failure rather than hiding
either cause.
## Tool Control Lifecycle
`ToolController` installs only statically declared or callback-registered
adapters into OpenCode's project configuration. Each runtime-controlled tool
owns one exchange that matches incoming requests to exact-ID or FIFO waiters.
Each accepted call owns a terminal Deferred, an interruption Deferred, and a
one-permit Semaphore that serializes progress with terminal commitment.
Controller scope release closes blocked waiters, marks unresolved calls
interrupted, aborts active HTTP transports, and waits for handler finalizers.
Terminal commitment uses a synchronous first-writer-wins Deferred completion;
Drive guarantees exactly-once acceptance inside the controller, not delivery
across a transport disconnect.
`ToolProducer` owns a separate backend socket because LLM chunks are not
idempotent and an LLM socket closure is terminal to `LlmController`. Dynamic
tool progress and terminal RPCs are idempotent by producer invocation ID and
sequence, so the tool-only connection may reconnect and replay pending
invocations safely. One ordered event stream preserves invocation-before-
cancellation order. The desired registration set survives reconnects and
manual server relaunches; invocation records are scoped to one server
generation because producer IDs may be reused by a new process.
Settlement first clears the dynamic registration set on OpenCode, then drains
the ordered local event stream before checking for unresolved invocations. The
clear acts as the server-side barrier that prevents a native invocation from
appearing after a successful settlement snapshot. Settlement is terminal for
dynamic attachment. Reconnects remain available while the clear is in flight;
the final connection gate drains any reconnect that landed during settlement
before preventing further backend creation. If the server generation has
already ended, its teardown has cleared the generation-scoped invocation
records, so settlement does not wait for a replacement backend.
## TUI Lifecycle
`Tuis.launch(options)` generates an internal identity. `Tuis.launch(name,
options)` uses a stable caller-supplied identity. Both return the same value:
```ts
interface Tui {
readonly ui: Ui
readonly close: () => Effect.Effect<void>
readonly recording?: Recording
}
```
Each TUI owns one frontend process, one negotiated UI connection, and
optionally one recording timeline. Closing a named TUI releases its identity
for reuse. An unexpected process exit fails the owning driver or script.
The primary TUI is not a special interface. `driver.tui` and values returned
by `driver.tuis` have exactly the same `Tui` type. `driver.ui` is only
`driver.tui.ui` exposed for the common single-TUI call site.
## OpenCode SDK
The server process writes an authenticated service registration into its
isolated state directory. `driver/opencode.ts` discovers that registration and
constructs the generated Effect SDK client with the project directory header.
Passwords and registration paths remain internal. The resulting value is
exposed as `driver.opencode` and `ScriptContext.opencode`.
## Canonical Protocol
`@opencode-ai/protocol/simulation` contains the single schema definition for OpenCode's
handshake, frontend, and backend simulation messages. `client/protocol.ts`
publishes those namespaces without redefining their data types.
```text
Frontend protocol schemas
-> driver/ui.ts Effect Ui capability
-> driver/client.ts Tui and Tuis lifecycle
-> driver/index.ts OpenCodeDriver aggregate
-> script/types.ts exact capability reuse
```
CLI `--command.ui.*` names are exhaustively checked against
`Frontend.Capabilities`. The Promise transport under `opencode-drive/client`
is separate from the Effect programmatic model but consumes the same protocol
schemas.
## Transport Seam
`SimulationConnector` owns WebSocket acquisition, handshake negotiation,
schema validation, request correlation, interruption, and connection failure.
The driver receives the connector through an Effect service and does not
expose it in userland.
The UI connection is request-response JSON-RPC. The LLM backend additionally
receives unsolicited `llm.request` notifications. The tool-only backend keeps
ordered `tool.invocation` and `tool.cancel` notifications on one validated
stream and does not call `llm.attach`.
## Project Setup
Neutral project contracts live in `src/project.ts` so neither the driver nor
scripts own the shared vocabulary:
```text
Project
Setup
SetupContext
ProjectFileSystem
OpenCodeConfig
OpenCodeTuiConfig
```
Configuration is applied in this order:
1. Write declared project files.
2. Read fixture `opencode.jsonc` and `tui.jsonc` values.
3. Deep-merge `config` and `tuiConfig`; arrays replace existing arrays.
4. Run Effect-only `setup`, which may mutate both merged objects.
5. Write normalized JSON and optionally commit the Git baseline.
## Dependency Direction
```text
project -> Effect and Schema
simulation -> canonical protocol and Effect RPC
driver -> project + simulation + instance + recording
script -> project + driver capabilities
cli -> script + driver + Promise transport
```
Lower-level modules do not import the package root or the driver/script
barrels. `script/types.ts` may reference driver capabilities; driver modules
must not reference script types.
## Public Entry Points
- `opencode-drive`: Effect driver, scripts, project contracts, LLM constructors.
- `opencode-drive/driver`: complete Effect driver namespace.
- `opencode-drive/script`: `defineScript` and script contracts.
- `opencode-drive/client`: Promise simulation transport.
- `opencode-drive/llm`: pure LLM output constructors and schemas.
- `opencode-drive/recording`: recording decode, replay, and export utilities.
@@ -1,58 +0,0 @@
import { Effect, Stream } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
launch: "manual",
run: ({ server, tuis, llm }) =>
Effect.gen(function* () {
yield* server.launch()
yield* llm.serve((_request, index) => Stream.make(Llm.text(`Response for request ${index + 1}`)))
const [alice, bob] = yield* Effect.all(
[tuis.launch("alice", { recording: true }), tuis.launch("bob", { recording: true })],
{ concurrency: "unbounded" },
)
yield* Effect.all([alice.ui.submit("Reply to Alice"), bob.ui.submit("Reply to Bob")], {
concurrency: "unbounded",
})
yield* Effect.all(
[alice.ui.screenshot("multiple-clients-alice-submitted"), bob.ui.screenshot("multiple-clients-bob-submitted")],
{ concurrency: "unbounded" },
)
yield* Effect.all(
[
alice.ui.waitFor("Response for request", { timeout: 30_000 }),
bob.ui.waitFor("Response for request", { timeout: 30_000 }),
],
{ concurrency: "unbounded" },
)
yield* Effect.all(
[alice.ui.screenshot("multiple-clients-alice-complete"), bob.ui.screenshot("multiple-clients-bob-complete")],
{ concurrency: "unbounded" },
)
yield* server.kill()
yield* Effect.sleep(500)
yield* Effect.all(
[
alice.ui.screenshot("multiple-clients-alice-server-stopped"),
bob.ui.screenshot("multiple-clients-bob-server-stopped"),
],
{ concurrency: "unbounded" },
)
yield* server.launch()
yield* Effect.sleep(1000)
yield* Effect.all(
[
alice.ui.screenshot("multiple-clients-alice-server-relaunched"),
bob.ui.screenshot("multiple-clients-bob-server-relaunched"),
],
{ concurrency: "unbounded" },
)
}),
})
-43
View File
@@ -1,43 +0,0 @@
import { Effect, Stream } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
setup: ({ fs }) =>
fs.writeFile(
"src/greeting.ts",
["export function greeting(name: string) {", " return `Welcome, ${name}!`", "}", ""].join("\n"),
),
run: ({ llm, ui }) =>
Effect.gen(function* () {
let turn = 0
yield* llm.title(() => Effect.succeed("Understanding the greeting"))
yield* llm.serve(() => {
if (turn++ === 0)
return Stream.make(
Llm.reasoning("I should read the implementation before explaining it."),
Llm.toolCall({
index: 0,
id: "call_read_greeting",
name: "read",
input: { filePath: "src/greeting.ts" },
}),
Llm.finish("tool-calls"),
)
return Stream.make(
Llm.text("The function accepts a name, "),
Llm.pause(150),
Llm.text("places it into a welcome message, "),
Llm.pause(150),
Llm.text("and adds an exclamation mark."),
Llm.pause(150),
Llm.finish("stop"),
)
})
yield* ui.submit("Read src/greeting.ts and explain what it does.")
yield* ui.waitFor("adds an exclamation mark")
}),
})
-32
View File
@@ -1,32 +0,0 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
setup: ({ fs }) => fs.writeFile("src/message.ts", 'export const message = "Hello from OpenCode Drive"\n'),
run: ({ llm, ui }) =>
Effect.gen(function* () {
yield* ui.waitFor((state) => state.focused.editor)
const editor = yield* ui.getElement({ editor: true, focused: true })
yield* ui.focus(editor)
yield* ui.submit("What does src/message.ts export?")
yield* llm.send(
Llm.reasoning("I should inspect the small source file first.", {
delay: 5,
chunkSize: 10,
}),
Llm.pause(100),
Llm.text('src/message.ts exports `message` with the value "Hello from OpenCode Drive".', {
delay: 10,
chunkSize: 12,
}),
)
yield* llm.send(Llm.text("Message export"))
yield* ui.waitFor("Hello from OpenCode Drive")
if (!(yield* ui.matches("OpenCode Drive"))) throw new Error("the expected response was not visible")
yield* ui.screenshot("simple-response")
}),
})
@@ -1,43 +0,0 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
export default defineScript({
tui: { viewport: { cols: 120, rows: 36 } },
setup: ({ fs }) => fs.writeFile("src/viewport.ts", `export const viewportSequence = ["120x36", "80x24", "50x18"]\n`),
run: ({ ui, llm }) =>
Effect.gen(function* () {
yield* ui.submit("Show a compact status report for the viewport resize demo.")
yield* llm.send(
Llm.text(
"Viewport demo: starting wide at 120 columns by 36 rows. The file src/viewport.ts lists the planned sequence. This first response should have plenty of horizontal room before the terminal narrows.",
),
)
yield* Effect.sleep(900)
yield* ui.resize({ cols: 80, rows: 24 })
yield* Effect.sleep(900)
yield* ui.submit("Now describe the medium viewport.")
yield* llm.send(
Llm.text(
"Medium viewport: resized to 80 columns by 24 rows. Lines should wrap sooner, the composer has less vertical breathing room, and the conversation should reflow without losing focus.",
),
)
yield* ui.waitFor("resized to 80 columns")
yield* Effect.sleep(900)
yield* ui.resize({ cols: 50, rows: 18 })
yield* Effect.sleep(900)
yield* ui.submit("Finish with the narrow viewport summary.")
yield* llm.send(
Llm.text(
"Narrow viewport: now 50 columns by 18 rows. This final state is intentionally cramped so modal, wrapping, and footer behavior are easy to inspect in the recording.",
),
)
yield* ui.waitFor("now 50 columns")
yield* Effect.sleep(1200)
}),
})
-80
View File
@@ -1,80 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode-drive",
"version": "1.4.3",
"private": true,
"description": "Drive real and simulated OpenCode instances",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/anomalyco/opencode.git",
"directory": "packages/drive"
},
"homepage": "https://github.com/anomalyco/opencode/tree/v2/packages/drive#readme",
"bugs": "https://github.com/anomalyco/opencode/issues",
"type": "module",
"packageManager": "bun@1.3.14",
"engines": {
"bun": ">=1.3.14"
},
"bin": {
"opencode-drive": "bin/opencode-drive"
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/index.ts",
"./driver": "./src/driver/index.ts",
"./frame": "./src/frame/index.ts",
"./llm": "./src/llm/index.ts",
"./recording": "./src/recording/index.ts",
"./script": "./src/script/index.ts"
},
"files": [
"assets/fonts",
"bin/opencode-drive",
"docs",
"src/cli",
"src/client",
"src/driver",
"src/frame",
"src/instance",
"src/llm",
"src/log.ts",
"src/project.ts",
"src/recording",
"src/script",
"src/simulation",
"src/tool",
"src/index.ts",
"LICENSE",
"README.md"
],
"scripts": {
"drive": "bun run src/cli/index.ts",
"lint": "cd ../.. && bun run lint -- packages/drive/src",
"test": "bun run test:effect && bun run test:cli",
"test:effect": "bun run --bun vitest run",
"test:cli": "bun test test/cli/integration.test.ts",
"typecheck": "tsgo --noEmit",
"check": "bun run lint && bun run typecheck",
"release:validate": "bun run check && bun run test && bun pm pack --dry-run"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@napi-rs/canvas": "1.0.2",
"@opencode-ai/client": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"@wterm/core": "0.3.0",
"@wterm/ghostty": "0.3.0",
"effect": "catalog:"
},
"devDependencies": {
"@effect/vitest": "4.0.0-beta.101",
"@tsconfig/bun": "catalog:",
"oxlint": "1.60.0",
"typescript": "catalog:",
"vitest": "4.1.10"
}
}
-74
View File
@@ -1,74 +0,0 @@
import { rm } from "node:fs/promises"
import { initializeInstance } from "../instance/instance.js"
import { checkScript } from "../script/tooling.js"
export async function check(file: string) {
const artifacts = await initializeInstance()
try {
try {
await checkScript(artifacts, file)
} catch (error) {
const source = await Bun.file(file)
.slice(0, 256 * 1024)
.text()
.catch(() => "")
const hint = effectScriptHint(source, message(error))
if (hint !== undefined) throw new Error(`${message(error)}\n\n${hint}`, { cause: error })
throw error
}
} finally {
await rm(artifacts, { recursive: true, force: true })
}
}
export function effectScriptHint(source: string, diagnostics: string) {
if (!/\bPromise(?:Like)?</.test(diagnostics)) return undefined
if (!/\bdefineScript\s*\(/.test(source)) return undefined
const relevant = diagnosticSource(source, diagnostics)
if (/\.waitFor\s*\(/.test(relevant))
return `${heading}
Instead of:
ui.waitFor(async (state) => state.focused.editor)
Use:
ui.waitFor((state) => Effect.succeed(state.focused.editor))`
if (/\bsetup\s*:|\basync\s+setup\s*\(/.test(relevant))
return `${heading}
Instead of:
setup: async ({ fs }) => {
await fs.writeFile("src/example.ts", "export {}")
}
Use:
setup: ({ fs }) => fs.writeFile("src/example.ts", "export {}")`
if (/\brun\s*:|\basync\s+run\s*\(/.test(relevant))
return `${heading}
Instead of:
run: async ({ ui }) => {
await ui.submit("Hello")
}
Use:
run: ({ ui }) =>
Effect.gen(function* () {
yield* ui.submit("Hello")
})`
return undefined
}
const heading = "OpenCode Drive scripts are Effect-only. Promise callbacks are not supported."
function diagnosticSource(source: string, diagnostics: string) {
const lines = source.split("\n")
const numbers = [...diagnostics.matchAll(/(?::(\d+):\d+|\((\d+),\d+\))/g)]
.map((match) => Number(match[1] ?? match[2]))
.filter((line) => Number.isInteger(line) && line > 0)
return numbers.length === 0 ? source : numbers.map((line) => lines[line - 1] ?? "").join("\n")
}
function message(error: unknown) {
return error instanceof Error ? error.message : String(error)
}
-204
View File
@@ -1,204 +0,0 @@
import * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import { Frontend } from "../client/protocol.js"
import { recordLog } from "../log.js"
import * as SimulationConnector from "../simulation/connector.js"
import type { DriveCommand } from "./types.js"
export const commandInfo = {
"ui.type": { value: true, description: "Type text using JSON params" },
"ui.press": { value: true, description: "Press a key using JSON params" },
"ui.enter": { value: false, description: "Press Enter" },
"ui.arrow": {
value: true,
description: "Press an arrow key using JSON params",
},
"ui.focus": {
value: true,
description: "Focus an element using JSON params",
},
"ui.click": { value: true, description: "Click using JSON params" },
"ui.resize": {
value: true,
description: "Resize terminal viewport using JSON params",
},
"ui.screenshot": {
value: "optional",
description: "Take a screenshot with optional JSON params and return its path",
},
"ui.capture": {
value: false,
description: "Capture the terminal frame as JSON",
},
"ui.state": {
value: false,
description: "Return focus, elements, and available UI actions",
},
"ui.snapshot": {
value: false,
description: "Return the semantic UI tree as JSON",
},
"ui.matches": {
value: true,
description: "Check for literal screen text using JSON params",
},
"ui.recording.finish": {
value: false,
description: "Finish recording and return the timeline path",
},
} as const satisfies Record<
Exclude<Frontend.Capability, "ui.click.semantic">,
{ readonly value: boolean | "optional"; readonly description: string }
>
type CommandName = Exclude<Frontend.Capability, "ui.click.semantic">
export function isCommandName(operation: string): operation is CommandName {
return Object.hasOwn(commandInfo, operation)
}
export function commandAcceptsValue(operation: CommandName) {
return commandInfo[operation].value
}
export function commandNames() {
return Object.keys(commandInfo).sort()
}
export class SimulationError extends Error {
constructor(
message: string,
readonly method?: string,
) {
super(message)
this.name = "SimulationError"
}
}
export class CommandBatchError extends Error {
constructor(
readonly results: ReadonlyArray<{
readonly command: string
readonly result: unknown
}>,
readonly reason: unknown,
) {
super(reason instanceof Error ? reason.message : String(reason))
this.name = "CommandBatchError"
}
}
const callTimeout = 30_000
export async function executeCommands(endpoint: string, commands: ReadonlyArray<DriveCommand>) {
const exit = await Effect.runPromiseExit(Effect.scoped(executeBatch(endpoint, commands)))
if (Exit.isSuccess(exit)) return exit.value
const reason = Cause.squash(exit.cause)
throw reason instanceof CommandBatchError ? reason : new CommandBatchError([], reason)
}
const executeBatch = Effect.fn("DriveCli.executeBatch")(function* (
endpoint: string,
commands: ReadonlyArray<DriveCommand>,
) {
const connection = yield* SimulationConnector.ui(endpoint, {
connectTimeout: callTimeout,
}).pipe(
Effect.mapError(
(cause) => new SimulationError(cause instanceof Error ? cause.message : `cannot connect to ${endpoint}`),
),
)
const results: Array<{ readonly command: string; readonly result: unknown }> = []
for (const command of commands) {
const result = yield* execute(connection, command).pipe(
Effect.mapError((error) => new CommandBatchError(results, error)),
)
results.push({ command: command.operation, result })
}
return { results }
})
const execute = (
connection: SimulationConnector.UiConnection,
command: DriveCommand,
): Effect.Effect<unknown, SimulationError> =>
Effect.suspend(() => {
recordLog("INFO", `ui command ${command.operation} params=${command.value ?? "undefined"}`)
return dispatch(connection, decodeCommand(command))
}).pipe(
Effect.timeoutOrElse({
duration: callTimeout,
orElse: () => Effect.fail(new SimulationError(`timed out after ${callTimeout}ms`, command.operation)),
}),
Effect.mapError((cause) =>
cause instanceof SimulationError
? cause
: new SimulationError(cause instanceof Error ? cause.message : String(cause), command.operation),
),
Effect.tap(() => Effect.sync(() => recordLog("INFO", `ui command ${command.operation} completed`))),
Effect.tapError((error) =>
Effect.sync(() => recordLog("ERROR", `ui command ${command.operation} failed: ${error.message}`)),
),
)
function decodeCommand(command: DriveCommand): Frontend.Request {
if (command.value === undefined && commandInfo[command.operation].value === true)
throw new Error(`${command.operation} requires a value`)
return Frontend.decodeRequest(
{
jsonrpc: "2.0",
method: command.operation,
...(command.value === undefined ? {} : { params: JSON.parse(command.value) }),
},
{ onExcessProperty: "error" },
)
}
function dispatch(
connection: SimulationConnector.UiConnection,
request: Frontend.Request,
): Effect.Effect<unknown, unknown> {
if (
request.method === "ui.snapshot" &&
!SimulationConnector.supportsCapability(connection.compatibility, "ui.snapshot")
)
return Effect.fail(new SimulationError("ui.snapshot is not available on this OpenCode endpoint", request.method))
if (
request.method === "ui.click" &&
request.params.semantic !== undefined &&
!SimulationConnector.supportsCapability(connection.compatibility, "ui.click.semantic")
)
return Effect.fail(
new SimulationError("semantic ui.click is not available on this OpenCode endpoint", request.method),
)
switch (request.method) {
case "ui.type":
return connection.rpc["ui.type"](request.params)
case "ui.press":
return connection.rpc["ui.press"](request.params)
case "ui.enter":
return connection.rpc["ui.enter"]()
case "ui.arrow":
return connection.rpc["ui.arrow"](request.params)
case "ui.focus":
return connection.rpc["ui.focus"](request.params)
case "ui.click":
return connection.rpc["ui.click"](request.params)
case "ui.resize":
return connection.rpc["ui.resize"](request.params)
case "ui.screenshot":
return connection.rpc["ui.screenshot"](request.params)
case "ui.capture":
return connection.rpc["ui.capture"]()
case "ui.state":
return connection.rpc["ui.state"]()
case "ui.snapshot":
return connection.rpc["ui.snapshot"]()
case "ui.matches":
return connection.rpc["ui.matches"](request.params)
case "ui.recording.finish":
return connection.rpc["ui.recording.finish"]()
}
throw new Error(`unsupported UI method ${request.method}`)
}

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