mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-12 20:50:01 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c478ad52a9 |
@@ -10,7 +10,6 @@ kitlangton
|
||||
kommander
|
||||
ludvigrask
|
||||
MrMushrooooom
|
||||
neriousy
|
||||
nexxeln
|
||||
R44VC0RP
|
||||
rekram1-node
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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' }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||
const TEAM = {
|
||||
tui: ["kommander", "simonklee"],
|
||||
desktop_web: ["Hona", "Brendonovich"],
|
||||
core: ["jlongster", "rekram1-node", "neriousy", "nexxeln", "kitlangton"],
|
||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||
windows: ["Hona"],
|
||||
} as const
|
||||
|
||||
+7
-10
@@ -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:"
|
||||
@@ -174,7 +172,6 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
},
|
||||
submit: {
|
||||
stopping,
|
||||
pending: submission.stopping,
|
||||
working,
|
||||
onSubmit: () => void submission.handleSubmit(new Event("submit")),
|
||||
onStop: () => void submission.abort(),
|
||||
|
||||
@@ -261,6 +261,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
() => prompt.capture(),
|
||||
Math.floor(Math.random() * EXAMPLES.length),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!working()) setStore("stopping", false)
|
||||
})
|
||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||
const motion = (value: number) => ({
|
||||
opacity: value,
|
||||
@@ -283,9 +286,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
.join("")
|
||||
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||
})
|
||||
const stopping = createMemo(() => working() && blank())
|
||||
const stopAction = createMemo(() => working() && blank())
|
||||
const tip = () => {
|
||||
if (stopping()) {
|
||||
if (store.stopping) return <span>{language.t("prompt.action.stop")}...</span>
|
||||
if (stopAction()) {
|
||||
return (
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t("prompt.action.stop")}</span>
|
||||
@@ -1198,36 +1202,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return permission.isAutoAccepting(id, sdk().directory)
|
||||
})
|
||||
|
||||
const { abort, handleSubmit } =
|
||||
props.submission ??
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) return closePopover()
|
||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
const {
|
||||
abort: requestAbort,
|
||||
handleSubmit,
|
||||
stopping: requestStopping,
|
||||
} = props.submission ??
|
||||
createPromptSubmit({
|
||||
prompt,
|
||||
info,
|
||||
imageAttachments,
|
||||
commentCount,
|
||||
autoAccept: () => accepting(),
|
||||
mode: () => store.mode,
|
||||
working,
|
||||
editor: () => editorRef,
|
||||
queueScroll,
|
||||
promptLength,
|
||||
addToHistory,
|
||||
resetHistoryNavigation: () => {
|
||||
resetHistoryNavigation(true)
|
||||
},
|
||||
setMode: (mode) => setStore("mode", mode),
|
||||
setPopover: (popover) => {
|
||||
if (!popover) return closePopover()
|
||||
setStore({ popover, slashMenu: false, slashMenuQuery: "" })
|
||||
},
|
||||
newSessionWorktree: () => props.newSessionWorktree,
|
||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||
shouldQueue: props.shouldQueue,
|
||||
onQueue: props.onQueue,
|
||||
onAbort: props.onAbort,
|
||||
onSubmit: props.onSubmit,
|
||||
model: props.controls.model.selection,
|
||||
})
|
||||
const abort = () => {
|
||||
if (store.stopping || requestStopping?.()) return Promise.resolve()
|
||||
setStore("stopping", true)
|
||||
return Promise.resolve(requestAbort()).finally(() => {
|
||||
if (working()) setStore("stopping", false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") {
|
||||
@@ -1579,12 +1593,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<IconButton
|
||||
data-action="prompt-submit"
|
||||
type="submit"
|
||||
disabled={!working() && blank()}
|
||||
disabled={store.stopping || (!working() && blank())}
|
||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
icon={stopAction() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="size-8"
|
||||
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
aria-label={stopAction() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type PromptInputState = ReturnType<typeof usePrompt>
|
||||
export type PromptInputSubmission = {
|
||||
abort: () => Promise<void> | void
|
||||
handleSubmit: (event: Event) => Promise<void> | void
|
||||
stopping?: () => boolean
|
||||
}
|
||||
|
||||
export type PromptInputControls = {
|
||||
|
||||
@@ -46,6 +46,9 @@ let selected = "/repo/worktree-a"
|
||||
let variant: string | undefined
|
||||
let permissionServer = "server-a"
|
||||
let createSessionGate: Promise<void> | undefined
|
||||
let interruptGate: Promise<void> | undefined
|
||||
let interruptCalls = 0
|
||||
let interruptFailure = false
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
const [promptStore, setPromptStore] = createStore<PromptStore>({
|
||||
@@ -121,6 +124,11 @@ const clientFor = (directory: string) => {
|
||||
shell: async (input: { sessionID: string; id?: string; command: string }) => {
|
||||
sentShell.push(input)
|
||||
},
|
||||
interrupt: async () => {
|
||||
interruptCalls++
|
||||
await interruptGate
|
||||
if (interruptFailure) throw new Error("interrupt failed")
|
||||
},
|
||||
},
|
||||
},
|
||||
session: {
|
||||
@@ -310,11 +318,74 @@ beforeEach(() => {
|
||||
variant = undefined
|
||||
permissionServer = "server-a"
|
||||
createSessionGate = undefined
|
||||
interruptGate = undefined
|
||||
interruptCalls = 0
|
||||
interruptFailure = false
|
||||
serverSessionSyncs = 0
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
describe("prompt submit worktree selection", () => {
|
||||
test("reports stopping immediately and suppresses duplicate interrupts", async () => {
|
||||
params = { id: "session-1" }
|
||||
let release = () => {}
|
||||
interruptGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let working = true
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => working,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: () => 0,
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
const first = submit.abort()
|
||||
const second = submit.abort()
|
||||
|
||||
expect(submit.stopping()).toBe(true)
|
||||
expect(interruptCalls).toBe(1)
|
||||
release()
|
||||
await Promise.all([first, second])
|
||||
working = false
|
||||
expect(submit.stopping()).toBe(false)
|
||||
})
|
||||
|
||||
test("clears stopping when the interrupt request fails", async () => {
|
||||
params = { id: "session-1" }
|
||||
interruptFailure = true
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
info: () => ({ id: "session-1" }),
|
||||
imageAttachments: () => [],
|
||||
commentCount: () => 0,
|
||||
autoAccept: () => false,
|
||||
mode: () => "normal",
|
||||
working: () => true,
|
||||
editor: () => undefined,
|
||||
queueScroll: () => undefined,
|
||||
promptLength: () => 0,
|
||||
addToHistory: () => undefined,
|
||||
resetHistoryNavigation: () => undefined,
|
||||
setMode: () => undefined,
|
||||
setPopover: () => undefined,
|
||||
})
|
||||
|
||||
await submit.abort()
|
||||
|
||||
expect(submit.stopping()).toBe(false)
|
||||
})
|
||||
|
||||
test("reads the latest worktree accessor value per submit", async () => {
|
||||
const submit = createPromptSubmit({
|
||||
prompt,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { batch, createSignal, startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -263,6 +263,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string }>()
|
||||
const tabs = useTabs()
|
||||
const [stopping, setStopping] = createSignal(false)
|
||||
const isStopping = () => {
|
||||
if (input.working()) return stopping()
|
||||
setStopping(false)
|
||||
return false
|
||||
}
|
||||
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
|
||||
|
||||
const errorMessage = (err: unknown) => {
|
||||
@@ -276,8 +282,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const abort = async () => {
|
||||
if (isStopping()) return
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
setStopping(true)
|
||||
|
||||
serverSync().session.set("todo", sessionID, [])
|
||||
|
||||
@@ -289,11 +297,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
queued.abort.abort()
|
||||
queued.cleanup()
|
||||
pending.delete(key)
|
||||
setStopping(false)
|
||||
return Promise.resolve()
|
||||
}
|
||||
return sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
.catch(() => setStopping(false))
|
||||
}
|
||||
|
||||
const restoreCommentItems = (
|
||||
@@ -649,5 +658,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return {
|
||||
abort,
|
||||
handleSubmit,
|
||||
stopping: isStopping,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type PromptInputTransientState = {
|
||||
draggingType: "image" | "@mention" | null
|
||||
mode: "normal" | "shell"
|
||||
applyingHistory: boolean
|
||||
stopping: boolean
|
||||
}
|
||||
|
||||
function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTransientState>) {
|
||||
@@ -24,6 +25,7 @@ function resetPromptInputTransientState(setStore: SetStoreFunction<PromptInputTr
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
stopping: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function createPromptInputTransientState(identity: Accessor<unknown>, pla
|
||||
draggingType: null,
|
||||
mode: "normal",
|
||||
applyingHistory: false,
|
||||
stopping: false,
|
||||
})
|
||||
|
||||
createComputed(on(identity, () => resetPromptInputTransientState(setStore), { defer: true }))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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_")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
@@ -812,6 +814,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 +953,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: {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
@@ -643,6 +659,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 +863,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
|
||||
@@ -1146,36 +1162,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 +1527,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 +1564,7 @@ export type SessionMessageUser = {
|
||||
type: "user"
|
||||
}
|
||||
|
||||
export type SessionInboxUserPayload = {
|
||||
export type SessionPendingUserData = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
@@ -1578,7 +1572,7 @@ export type SessionInboxUserPayload = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionInboxUserPayload1 = {
|
||||
export type SessionPendingUserData1 = {
|
||||
text: string
|
||||
files?: Array<PromptFileAttachment>
|
||||
agents?: Array<PromptAgentAttachment>
|
||||
@@ -1890,20 +1884,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,17 +1914,9 @@ 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
|
||||
@@ -1960,47 +1942,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 +1979,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 +2051,11 @@ export type V2Event =
|
||||
| SessionUsageUpdated
|
||||
| SessionDeleted
|
||||
| SessionForked
|
||||
| SessionInboxDelivered
|
||||
| SessionInboxEnqueued
|
||||
| SessionInboxCancelled
|
||||
| SessionInboxDeliveryChanged
|
||||
| SessionInputPromoted
|
||||
| SessionInputAdmitted
|
||||
| SessionInputCancelled
|
||||
| SessionInputSteered
|
||||
| SessionInputQueued
|
||||
| SessionExecutionStarted
|
||||
| SessionExecutionSucceeded
|
||||
| SessionExecutionFailed
|
||||
@@ -2089,6 +2082,7 @@ export type V2Event =
|
||||
| SessionToolSuccess
|
||||
| SessionToolFailed
|
||||
| SessionRetryScheduled
|
||||
| SessionCompactionAdmitted
|
||||
| SessionCompactionStarted
|
||||
| SessionCompactionDelta
|
||||
| SessionCompactionEnded
|
||||
@@ -2103,7 +2097,6 @@ export type V2Event =
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectDirectoriesUpdated
|
||||
| ProjectDirectoryResolved
|
||||
| CommandUpdated
|
||||
| ConfigUpdated
|
||||
| SkillUpdated
|
||||
@@ -2134,6 +2127,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"
|
||||
@@ -3366,21 +3361,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 +3387,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 +3408,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 +3429,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 +3450,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 +3471,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 +3492,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 +3513,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 +3534,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 +3563,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 +3586,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 +3609,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 +3632,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 +3655,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 +3678,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 +3701,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 +3724,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 +3747,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 +3770,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 +3805,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 +3813,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 +3821,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 +3829,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 +3837,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 +3845,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 +3862,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 +3891,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"] }
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })
|
||||
@@ -36,31 +36,26 @@
|
||||
"default": "./src/database/sqlite.node.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"workerd": "./src/pty/pty.workerd.ts",
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
},
|
||||
"#fff": {
|
||||
"workerd": "./src/filesystem/fff.workerd.ts",
|
||||
"bun": "./src/filesystem/fff.bun.ts",
|
||||
"node": "./src/filesystem/fff.node.ts",
|
||||
"default": "./src/filesystem/fff.bun.ts"
|
||||
},
|
||||
"#photon-wasm": {
|
||||
"workerd": "./src/image/photon-wasm.workerd.ts",
|
||||
"bun": "./src/image/photon-wasm.bun.ts",
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
"node": "./src/util/process-lock-ffi.node.ts",
|
||||
"default": "./src/util/process-lock-ffi.bun.ts"
|
||||
|
||||
+2
-134
@@ -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
@@ -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,
|
||||
|
||||
@@ -65,13 +65,4 @@ export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
}
|
||||
|
||||
/** `configured`, but over an injected SqlClient layer instead of a filesystem path. */
|
||||
export function configuredClient(client: Layer.Layer<SqlClient.SqlClient>) {
|
||||
return makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layerFromClient.pipe(Layer.provide(client)),
|
||||
deps: [Global.node],
|
||||
})
|
||||
}
|
||||
|
||||
export const node = configured({ path: ":memory:" })
|
||||
|
||||
@@ -28,20 +28,6 @@ export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
|
||||
readonly context: never
|
||||
}
|
||||
|
||||
/**
|
||||
* A SqlClient whose runtime rejects SQL transaction statements (BEGIN/COMMIT/SAVEPOINT) and
|
||||
* manages transactions natively instead — Cloudflare Durable Object SQLite. Producers set
|
||||
* `transactionStatements: false` (see `database/sqlite.workerd.ts`) and the session delegates
|
||||
* to the client's `withTransaction` rather than issuing transaction statements.
|
||||
*/
|
||||
export interface NativeTransactionSqlClient extends SqlClient {
|
||||
readonly transactionStatements: false
|
||||
}
|
||||
|
||||
export function managesTransactionsNatively(client: SqlClient): client is NativeTransactionSqlClient {
|
||||
return (client as SqlClient & { readonly transactionStatements?: boolean }).transactionStatements === false
|
||||
}
|
||||
|
||||
export type EffectSQLiteRunResult = readonly never[]
|
||||
|
||||
export interface EffectSQLiteSessionOptions {
|
||||
@@ -134,7 +120,6 @@ export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLite
|
||||
}
|
||||
|
||||
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
|
||||
if (managesTransactionsNatively(this.client)) return this.client.withTransaction(effect)
|
||||
return Effect.uninterruptibleMask((restore) =>
|
||||
Effect.withFiber<A, E | SqlError, R>((fiber) => {
|
||||
const services = fiber.context
|
||||
|
||||
-2
@@ -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
|
||||
@@ -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\`);`,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Reactivity } from "effect/unstable/reactivity"
|
||||
import { SqlClient, Statement } from "effect/unstable/sql"
|
||||
import type { Connection } from "effect/unstable/sql/SqlConnection"
|
||||
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
|
||||
import type { NativeTransactionSqlClient } from "./drizzle/effect-sqlite/session.js"
|
||||
import { Sqlite } from "./sqlite.js"
|
||||
|
||||
const ATTR_DB_SYSTEM_NAME = "db.system.name"
|
||||
@@ -216,10 +215,10 @@ const make = (options: Config) =>
|
||||
config: options,
|
||||
withTransaction: makeWithTransaction(native, connection, semaphore),
|
||||
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
|
||||
// as the drizzle session detect this and route through withTransaction.
|
||||
// as the drizzle session must route through withTransaction instead.
|
||||
transactionStatements: false,
|
||||
} as const,
|
||||
) satisfies NativeTransactionSqlClient
|
||||
},
|
||||
)
|
||||
|
||||
return client
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
@@ -1,12 +0,0 @@
|
||||
import { bind } from "./fff.js"
|
||||
|
||||
export type { Directory, DirSearch, File, Init, Mixed, MixedSearch, Picker, Result, Search } from "./fff.js"
|
||||
|
||||
// No fff backend exists on workerd; every create reports unavailability and
|
||||
// FileSystemSearch degrades the same way it does on a runtime without the native module.
|
||||
const adapter = bind(undefined, "fff unavailable on workerd runtime")
|
||||
|
||||
export const available = adapter.available
|
||||
export const create = adapter.create
|
||||
|
||||
export * as Fff from "./fff.workerd.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 ??
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// workerd has no filesystem path to a photon wasm artifact. Image.Photon only
|
||||
// reads this lazily and surfaces a typed ResizerUnavailableError when loading
|
||||
// fails, so an empty path degrades cleanly instead of breaking module load.
|
||||
export default ""
|
||||
@@ -2,6 +2,7 @@ export * as MCPOAuth from "./oauth.js"
|
||||
|
||||
import { auth, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import type { OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"
|
||||
import { createServer } from "node:http"
|
||||
import { Deferred, Effect } from "effect"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
||||
@@ -151,8 +152,6 @@ export const authorize = (input: {
|
||||
const redirectPath = oauth?.redirect_uri ? new URL(oauth.redirect_uri).pathname : "/callback"
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== redirectPath) {
|
||||
|
||||
@@ -544,24 +544,6 @@ const Cache = Schema.Struct({
|
||||
})
|
||||
const defaultSource = "https://models.opencode.ai"
|
||||
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
// packages/core/src/models-dev/snapshot.txt and refreshed via
|
||||
// `bun run script/update-models-snapshot.ts`. Decoded and normalized once per
|
||||
// isolate: the snapshot is a multi-MB module-level constant and one isolate can
|
||||
// host many runtimes (Cloudflare colocates Durable Object instances), so
|
||||
// per-runtime decoding would multiply the cost.
|
||||
let bundledCache: readonly Snapshot[] | undefined
|
||||
const bundledSnapshot = Effect.suspend(() =>
|
||||
bundledCache
|
||||
? Effect.succeed(bundledCache)
|
||||
: decodeCatalog(snapshotText).pipe(
|
||||
Effect.map((catalog) => {
|
||||
bundledCache = normalize(catalog)
|
||||
return bundledCache
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
function cacheKey(source: string) {
|
||||
if (source === defaultSource) return "models-dev:catalog"
|
||||
return `models-dev:catalog:${Hash.fast(source)}`
|
||||
@@ -625,9 +607,11 @@ export const layer = (options?: Options) =>
|
||||
)
|
||||
: Effect.succeed(undefined)
|
||||
|
||||
// The bundled snapshot is the boot-time floor for the catalog; the
|
||||
// periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : bundledSnapshot
|
||||
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
|
||||
// packages/core/src/models-dev/snapshot.txt and refreshed via
|
||||
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
|
||||
// for the catalog; the periodic fetch below still refreshes on top.
|
||||
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
|
||||
|
||||
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
|
||||
const text = yield* fetchApi()
|
||||
@@ -651,7 +635,7 @@ export const layer = (options?: Options) =>
|
||||
const cached = options?.file ? undefined : yield* loadFromCache()
|
||||
if (cached) return normalize(cached.catalog)
|
||||
const bundled = yield* loadSnapshot
|
||||
if (bundled) return bundled
|
||||
if (bundled) return normalize(bundled)
|
||||
if (!fetch) return []
|
||||
const catalog = yield* lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createServer } from "node:http"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode-ai/plugin/effect/integration"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
@@ -57,8 +58,6 @@ const browser = (app: App.Info) =>
|
||||
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
const code = yield* Deferred.make<string, Error>()
|
||||
const redirect = `http://localhost:${callbackPort}/auth/callback`
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`)
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Proc } from "./pty.js"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty.js"
|
||||
|
||||
// workerd cannot spawn processes; the Pty service surfaces this as a defect if
|
||||
// a terminal is ever requested on this runtime.
|
||||
export function spawn(): Proc {
|
||||
throw new Error("Pseudo-terminals are unavailable on the workerd runtime")
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
@@ -379,6 +380,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
}
|
||||
})
|
||||
},
|
||||
"session.compaction.admitted": () => Effect.void,
|
||||
"session.compaction.started": (event) =>
|
||||
adapter.appendMessage(
|
||||
SessionMessage.CompactionRunning.make({
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
@@ -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 })
|
||||
@@ -501,75 +482,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,
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
export * as ShellParse from "./parse.js"
|
||||
|
||||
import { Effect, Exit } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
@@ -153,15 +153,8 @@ const ARITY: Record<string, number> = {
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const loaded = yield* Effect.promise(load).pipe(Effect.exit)
|
||||
// Workerd has no filesystem-backed tree-sitter assets. Preserve execution
|
||||
// with one conservative permission resource instead of disabling shell.
|
||||
if (Exit.isFailure(loaded)) {
|
||||
const tokens = command.trim().split(/\s+/)
|
||||
return { commands: [{ resource: command, save: `${prefix(tokens).join(" ")} *` }], directories: [] }
|
||||
}
|
||||
const parsers = loaded.value
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
|
||||
// loads these lazily and degrades when initialization fails, so empty paths
|
||||
// keep module load side-effect free instead of resolving from disk.
|
||||
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
|
||||
@@ -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* () {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export type LockResult =
|
||||
| { readonly acquired: true }
|
||||
| { readonly acquired: false; readonly held: true }
|
||||
| { readonly acquired: false; readonly held: false; readonly code: number }
|
||||
|
||||
// workerd has no FFI and no cross-process file locking; a Durable Object is
|
||||
// already single-threaded per instance, so nothing on this runtime should
|
||||
// reach these.
|
||||
const unavailable = (_fd: number): LockResult => {
|
||||
throw new Error("Process locks are unavailable on the workerd runtime")
|
||||
}
|
||||
|
||||
export const lockDarwin = unavailable
|
||||
export const lockLinux = unavailable
|
||||
@@ -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,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()
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the workerd
|
||||
// adapter and the workerd server profile can be verified without workerd or
|
||||
// Cloudflare runtime dependencies. The real runtime is covered by the
|
||||
// workerd-spike package, which boots inside an actual isolate.
|
||||
export const makeDurableObjectStorage = (): DurableObjectStorage => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
return {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
@@ -81,7 +81,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 +89,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 +247,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 +320,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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,11 +28,11 @@ import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { AbsolutePath, RelativePath } 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"
|
||||
@@ -355,6 +355,18 @@ const pluginSupervisor = Layer.succeed(
|
||||
flush: Effect.suspend(() => pluginFlushHook),
|
||||
}),
|
||||
)
|
||||
let snapshotCaptureHook: () => Effect.Effect<Snapshot.ID | undefined> = () => Effect.succeed(undefined)
|
||||
let snapshotFilesHook: (input: Snapshot.CompareInput) => Effect.Effect<readonly RelativePath[], Snapshot.Error> = () =>
|
||||
Effect.succeed([])
|
||||
const snapshots = Layer.succeed(
|
||||
Snapshot.Service,
|
||||
Snapshot.Service.of({
|
||||
capture: () => snapshotCaptureHook(),
|
||||
files: (input) => snapshotFilesHook(input),
|
||||
diff: () => Effect.succeed([]),
|
||||
restore: () => Effect.void,
|
||||
}),
|
||||
)
|
||||
const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
@@ -370,7 +382,7 @@ const promptCatalog = Layer.mock(Catalog.Service, {
|
||||
},
|
||||
})
|
||||
const runnerLayer = AppNodeBuilder.build(SessionRunnerLLM.node, [
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[Snapshot.node, snapshots],
|
||||
[LayerNodePlatform.llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
[InstructionBuiltIns.node, systemContext],
|
||||
@@ -387,21 +399,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,
|
||||
@@ -450,7 +449,7 @@ const it = testEffect(
|
||||
[Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })],
|
||||
[SkillInstructions.node, skillInstructions],
|
||||
[ReferenceInstructions.node, referenceInstructions],
|
||||
[Snapshot.node, Snapshot.noopLayer],
|
||||
[Snapshot.node, snapshots],
|
||||
[SessionExecution.node, execution],
|
||||
[Config.node, config],
|
||||
[PluginSupervisor.node, pluginSupervisor],
|
||||
@@ -506,6 +505,8 @@ const setup = Effect.gen(function* () {
|
||||
systemLoadHook = Effect.void
|
||||
modelResolveHook = Effect.void
|
||||
pluginFlushHook = Effect.void
|
||||
snapshotCaptureHook = () => Effect.succeed(undefined)
|
||||
snapshotFilesHook = () => Effect.succeed([])
|
||||
currentModel = model
|
||||
skillBaselines.clear()
|
||||
toolBarrier = undefined
|
||||
@@ -642,7 +643,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 +1200,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 +1224,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 +1234,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 +1839,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 +1855,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 +1870,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 +1915,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 +1937,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 +1952,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 +1960,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 +2029,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 +2050,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 +2878,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 +2908,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 +3061,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 +3118,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 +3169,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 +3222,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 +3244,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,
|
||||
)
|
||||
@@ -3824,6 +3755,89 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for the end snapshot before interrupted settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const endCaptureStarted = yield* Deferred.make<void>()
|
||||
const releaseEndCapture = yield* Deferred.make<void>()
|
||||
const runSettled = yield* Deferred.make<void>()
|
||||
const interruptSettled = yield* Deferred.make<void>()
|
||||
let captures = 0
|
||||
snapshotCaptureHook = () => {
|
||||
captures++
|
||||
if (captures === 1) return Effect.succeed(Snapshot.ID.make("snapshot-start"))
|
||||
return Deferred.succeed(endCaptureStarted, undefined).pipe(
|
||||
Effect.andThen(Deferred.await(releaseEndCapture)),
|
||||
Effect.as(Snapshot.ID.make("snapshot-end")),
|
||||
)
|
||||
}
|
||||
snapshotFilesHook = () => Effect.succeed([RelativePath.make("changed.txt")])
|
||||
yield* admit(session, "Interrupt during end snapshot")
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session
|
||||
.resume(sessionID)
|
||||
.pipe(Effect.ensuring(Deferred.succeed(runSettled, undefined)), Effect.forkChild)
|
||||
yield* stream.started
|
||||
const interrupt = yield* session
|
||||
.interrupt(sessionID)
|
||||
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
|
||||
yield* Deferred.await(endCaptureStarted)
|
||||
|
||||
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
|
||||
expect(yield* Deferred.isDone(runSettled)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseEndCapture, undefined)
|
||||
yield* Fiber.join(interrupt)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
finish: "error",
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
snapshot: {
|
||||
start: "snapshot-start",
|
||||
end: "snapshot-end",
|
||||
files: ["changed.txt"],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("waits for unrelated database transactions before interrupted settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
const { db } = yield* Database.Service
|
||||
const transactionStarted = yield* Deferred.make<void>()
|
||||
const releaseTransaction = yield* Deferred.make<void>()
|
||||
const interruptSettled = yield* Deferred.make<void>()
|
||||
yield* admit(session, "Interrupt during database contention")
|
||||
const stream = yield* TestLLM.gate
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
|
||||
yield* stream.started
|
||||
const transaction = yield* db
|
||||
.transaction(() =>
|
||||
Deferred.succeed(transactionStarted, undefined).pipe(Effect.andThen(Deferred.await(releaseTransaction))),
|
||||
)
|
||||
.pipe(Effect.forkChild)
|
||||
yield* Deferred.await(transactionStarted)
|
||||
const interrupt = yield* session
|
||||
.interrupt(sessionID)
|
||||
.pipe(Effect.ensuring(Deferred.succeed(interruptSettled, undefined)), Effect.forkChild)
|
||||
yield* Effect.yieldNow
|
||||
|
||||
expect(yield* Deferred.isDone(interruptSettled)).toBe(false)
|
||||
|
||||
yield* Deferred.succeed(releaseTransaction, undefined)
|
||||
yield* Fiber.join(transaction)
|
||||
yield* Fiber.join(interrupt)
|
||||
expect(yield* Fiber.await(run)).toMatchObject({ _tag: "Failure" })
|
||||
expect(requireAssistant(yield* session.context(sessionID))).toMatchObject({
|
||||
finish: "error",
|
||||
error: { type: "aborted", message: "Step interrupted" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("durably fails blocked local tools when interrupted while awaiting settlement", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { SqlClient } from "effect/unstable/sql"
|
||||
import { SqlError } from "effect/unstable/sql/SqlError"
|
||||
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
|
||||
import { makeDurableObjectStorage } from "./fixture/durable-object-storage"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
|
||||
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
|
||||
// be verified without workerd or Cloudflare runtime dependencies.
|
||||
const makeFakeStorage = () => {
|
||||
const native = new Database(":memory:")
|
||||
const toSqlStorageValue = (value: unknown) => {
|
||||
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
|
||||
const buffer = new ArrayBuffer(value.byteLength)
|
||||
new Uint8Array(buffer).set(value)
|
||||
return buffer
|
||||
}
|
||||
const storage: DurableObjectStorage = {
|
||||
sql: {
|
||||
exec(query: string, ...bindings: Array<unknown>) {
|
||||
const statement = native.query(query)
|
||||
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
|
||||
const columnNames = statement.columnNames
|
||||
return {
|
||||
columnNames,
|
||||
raw: () => rows[Symbol.iterator](),
|
||||
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
|
||||
}
|
||||
},
|
||||
},
|
||||
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
|
||||
native.run("BEGIN")
|
||||
let rolledBack = false
|
||||
return closure({ rollback: () => (rolledBack = true) }).then(
|
||||
(result) => {
|
||||
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
|
||||
return result
|
||||
},
|
||||
(error) => {
|
||||
native.run("ROLLBACK")
|
||||
throw error
|
||||
},
|
||||
)
|
||||
},
|
||||
transactionSync<T>(closure: () => T): T {
|
||||
return native.transaction(closure)()
|
||||
},
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
|
||||
|
||||
describe("sqlite.workerd", () => {
|
||||
test("executes statements with bindings and maps rows to records", async () => {
|
||||
const rows = await run(
|
||||
makeDurableObjectStorage(),
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
|
||||
@@ -29,7 +73,7 @@ describe("sqlite.workerd", () => {
|
||||
|
||||
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
|
||||
const rows = await run(
|
||||
makeDurableObjectStorage(),
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
|
||||
@@ -42,7 +86,7 @@ describe("sqlite.workerd", () => {
|
||||
})
|
||||
|
||||
test("withTransaction commits on success and rolls back on failure", async () => {
|
||||
const storage = makeDurableObjectStorage()
|
||||
const storage = makeFakeStorage()
|
||||
const count = await run(
|
||||
storage,
|
||||
Effect.gen(function* () {
|
||||
@@ -65,7 +109,7 @@ describe("sqlite.workerd", () => {
|
||||
|
||||
test("nested withTransaction fails with SqlError", async () => {
|
||||
const error = await run(
|
||||
makeDurableObjectStorage(),
|
||||
makeFakeStorage(),
|
||||
Effect.gen(function* () {
|
||||
const sql = yield* SqlClient.SqlClient
|
||||
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
|
||||
@@ -78,7 +122,7 @@ describe("sqlite.workerd", () => {
|
||||
})
|
||||
|
||||
test("boots the full database layer with migrations over injected storage", async () => {
|
||||
const storage = makeDurableObjectStorage()
|
||||
const storage = makeFakeStorage()
|
||||
const core = await import("@opencode-ai/core/database/database")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"`)
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user