Compare commits

..

29 Commits

Author SHA1 Message Date
Kit Langton e99043faf8 feat(tui): use server viewed state 2026-08-15 20:52:18 -04:00
Kit Langton 1f37f829ed fix(session): use numeric event timestamps 2026-08-15 20:29:37 -04:00
Kit Langton c30a6d956b chore(client): regenerate API 2026-08-15 20:27:12 -04:00
Kit Langton d297953384 chore(core): normalize generated schema 2026-08-15 20:26:40 -04:00
Kit Langton 04cf35b304 fix(protocol): stabilize OpenAPI generation 2026-08-15 20:26:40 -04:00
Kit Langton 91daa76e33 refactor(session): remove viewed lock 2026-08-15 20:26:40 -04:00
Kit Langton c316fb7219 refactor(protocol): preserve endpoint ordering 2026-08-15 20:26:40 -04:00
Kit Langton f6b7181916 feat(session): add viewed state 2026-08-15 20:26:39 -04:00
Dax Raad 8251934007 fix(core): batch initial streamed delta 2026-08-15 19:26:16 -04:00
Dax Raad 42e345e1bc fix(cli): resolve Bun canary compile assets 2026-08-15 18:56:03 -04:00
Dax 6a7d6c5adc refactor(core): use numeric event timestamps (#42828) 2026-08-15 18:48:58 -04:00
Dax Raad 9e22c40fff feat: align V2 beta release channels 2026-08-15 18:40:23 -04:00
opencode-agent[bot] 467722c2f9 fix(app): use tree directory picker everywhere (#42820)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-16 08:19:44 +10:00
Luke Parker d9dfceddf8 fix(app): release virtualized timeline elements (#42825) 2026-08-16 08:19:06 +10:00
Dax 4fee4d7d86 fix(core): batch streamed session deltas 2026-08-15 18:16:01 -04:00
Luke Parker 61d7f942b5 fix(app): show new session header immediately (#42822) 2026-08-16 08:04:28 +10:00
Dax Raad d8e1753330 chore(cli): build binaries with Bun 1.4 2026-08-15 17:51:09 -04:00
opencode-agent[bot] d94d520f45 fix(tui): make running subagents clickable (#42797)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 19:54:40 +00:00
Dax Raad fcc1e9c42f docs(core): add MCP setup guidance 2026-08-15 15:13:28 -04:00
opencode-agent[bot] 64b9ba339e docs(console): prohibit abusive multi-account use (#42813)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 15:08:52 -04:00
opencode-agent[bot] 73700065bf chore: generate 2026-08-15 18:35:53 +00:00
Kit Langton 07a82a442e fix(core): keep queued work parked after interrupt (#42682) 2026-08-15 18:34:43 +00:00
Kit Langton 55be895e14 fix(tui): scope session picker by directory (#42804) 2026-08-15 18:30:35 +00:00
Kit Langton 58a36a1560 fix(tui): preserve path suffix in prompt footer (#42800) 2026-08-15 18:21:07 +00:00
Dax 3d782efee4 docs(core): fix opencode skill source link (#42792) 2026-08-15 16:37:34 +00:00
opencode-agent[bot] c054bb183e chore: generate 2026-08-15 16:30:19 +00:00
Dax 0d7904c91f refactor(core): model provider activation explicitly (#42791)
Co-authored-by: Michael Hart <mhart@cloudflare.com>
2026-08-15 16:29:09 +00:00
opencode-agent[bot] 75ec0b454c chore: generate 2026-08-15 12:13:32 +00:00
Luke Parker f4baba2824 fix(app): use fixed message page size (#42743) 2026-08-15 22:12:20 +10:00
132 changed files with 2457 additions and 1342 deletions
+10 -3
View File
@@ -1,6 +1,10 @@
name: "Setup Bun" name: "Setup Bun"
description: "Setup Bun with caching and install dependencies" description: "Setup Bun with caching and install dependencies"
inputs: inputs:
bun-version:
description: "Bun version to install instead of the root packageManager version"
required: false
default: ""
install-flags: install-flags:
description: "Additional flags to pass to 'bun install'" description: "Additional flags to pass to 'bun install'"
required: false required: false
@@ -20,19 +24,22 @@ runs:
shell: bash shell: bash
run: | run: |
if [ "$RUNNER_ARCH" = "X64" ]; then if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]") V="${{ inputs.bun-version }}"
if [ -z "$V" ]; then V=$(node -p "require('./package.json').packageManager.split('@')[1]"); fi
TAG=$([ "$V" = "canary" ] && echo "canary" || echo "bun-v${V}")
case "$RUNNER_OS" in case "$RUNNER_OS" in
macOS) OS=darwin ;; macOS) OS=darwin ;;
Linux) OS=linux ;; Linux) OS=linux ;;
Windows) OS=windows ;; Windows) OS=windows ;;
esac esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT" echo "url=https://github.com/oven-sh/bun/releases/download/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi fi
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with: with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }} bun-version: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }} bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory - name: Get cache directory
-37
View File
@@ -1,37 +0,0 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
+22 -5
View File
@@ -7,6 +7,7 @@ on:
- ci - ci
- dev - dev
- beta - beta
- v2
- fix/npm-native-binary-install - fix/npm-native-binary-install
- snapshot-* - snapshot-*
workflow_dispatch: workflow_dispatch:
@@ -32,7 +33,7 @@ permissions:
packages: write packages: write
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
jobs: jobs:
version: version:
@@ -45,6 +46,13 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Deploy update service
if: github.ref_name == 'v2' || github.ref_name == 'beta'
working-directory: packages/updates
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Setup git committer - name: Setup git committer
id: committer id: committer
uses: ./.github/actions/setup-git-committer uses: ./.github/actions/setup-git-committer
@@ -74,13 +82,15 @@ jobs:
build-cli: build-cli:
needs: version needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with: with:
fetch-tags: true fetch-tags: true
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
with:
bun-version: canary # Bun 1.4 until its stable release is published
- name: Setup git committer - name: Setup git committer
id: committer id: committer
@@ -102,6 +112,7 @@ jobs:
id: build id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env: env:
BUN_COMPILE_RELEASE: canary
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }} GH_REPO: ${{ needs.version.outputs.repo }}
@@ -185,7 +196,7 @@ jobs:
build-node-cli: build-node-cli:
needs: version needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' if: github.repository == 'anomalyco/opencode'
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -335,6 +346,7 @@ jobs:
build-electron: build-electron:
needs: needs:
- version - version
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false continue-on-error: false
env: env:
@@ -373,6 +385,12 @@ jobs:
steps: steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0 - uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
if: runner.os == 'macOS' if: runner.os == 'macOS'
with: with:
@@ -431,6 +449,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
- name: Build - name: Build
run: bun run build run: bun run build
@@ -569,13 +588,11 @@ jobs:
path: packages/opencode/dist path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
name: opencode-preview-cli name: opencode-preview-cli
path: packages/cli/dist path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with: with:
pattern: opencode-node-cli-* pattern: opencode-node-cli-*
path: packages/cli/dist/node path: packages/cli/dist/node
+1 -1
View File
@@ -9,7 +9,7 @@
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions. - Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`. - Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. - The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. - Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories ## V2 TUI Stories
+1
View File
@@ -613,6 +613,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
+3 -3
View File
@@ -15,13 +15,13 @@ Usage: install.sh [options]
Options: Options:
-h, --help Display this help message -h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 0.0.0-next-17236) -v, --version <version> Install a specific version (e.g., 0.0.0-beta-17236)
-b, --binary <path> Install from a local binary instead of downloading -b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples: Examples:
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-next-17236 curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-beta-17236
./install --binary /path/to/opencode2 ./install --binary /path/to/opencode2
EOF EOF
} }
@@ -166,7 +166,7 @@ else
fi fi
if [ -z "$requested_version" ]; then if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/next || true) metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p') specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
if [ -z "$specific_version" ]; then if [ -z "$specific_version" ]; then
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"", "dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -1,60 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -16,9 +16,8 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const messagePageSize = 200
const historyPageSize = 200 const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [ return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }), userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
@@ -26,7 +25,7 @@ const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id, parentID: id,
created: 1700000001000 + index * 2_000, created: 1700000001000 + index * 2_000,
completed: index < initialPageSize, completed: index < messagePageSize / 2,
}), }),
] ]
}).flat() }).flat()
@@ -160,21 +159,18 @@ for (const scenario of scenarios) {
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover() await viewport.hover()
const deadline = Date.now() + 10_000 const deadline = Date.now() + 30_000
while (requests.filter((request) => request.phase === "start").length < 2) { while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary") if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240) await page.mouse.wheel(0, -1_200)
await page.waitForTimeout(20) await page.waitForTimeout(20)
} }
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 3)).toEqual([ expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`messages:start:${messages.at(-initialPageSize)!.info.id}`, `messages:start:${messages.at(-messagePageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
@@ -186,15 +182,12 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([ expect(pages).toEqual([
{ before: undefined, limit: initialPageSize }, { before: undefined, limit: messagePageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, { before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
]) ])
expect(roots).toEqual([]) expect(roots).toEqual([])
@@ -222,7 +222,7 @@ function turn(index: number): Message[] {
return [user, assistantMessage(targetID, index, user.info.id, parts)] return [user, assistantMessage(targetID, index, user.info.id, parts)]
} }
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat() const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120), userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]), assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
@@ -727,7 +727,7 @@ function expectCompleteScroll(
).toEqual([]) ).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length) expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length) expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331) expect(expectedPartIDs.length).toBe(465)
} }
async function selectHomeProject(page: Page, projectName: string) { async function selectHomeProject(page: Page, projectName: string) {
@@ -72,7 +72,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]') const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject) await expectAppVisible(addProject)
await addProject.click() await addProject.click()
await page.locator("[data-directory-path]").click() const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await page.locator('[data-action="home-new-session"]').click() await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
@@ -1,205 +0,0 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
group: "recent" | "folders"
}
function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = displayPickerPath(absolute, "", "")
const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search, group }
}
function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
return true
})
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = createDirectorySearch({
sdk,
home,
base: start,
})
const recentProjects = createMemo(() => {
const projects = serverCtx.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {
if (session.time.archived) continue
const updated = session.time.updated ?? session.time.created
if (updated > at) at = updated
}
}
byProject.set(project.worktree, at)
}
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
return {
...row,
search: `${row.search}\n${name}`,
}
})
})
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
groupBy={(item) => item.group}
sortGroupsBy={(a, b) => {
if (a.category === b.category) return 0
return a.category === "recent" ? -1 : 1
}}
groupHeader={(group) =>
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
}}
>
{(item) => {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -1,9 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/servers" import { ServerConnection } from "@/context/servers"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js" import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy" import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() => const DialogSelectDirectoryV2 = lazy(() =>
@@ -19,7 +17,6 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() { export function useDirectoryPicker() {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog() const dialog = useDialog()
return (input: DirectoryPickerInput) => { return (input: DirectoryPickerInput) => {
@@ -36,10 +33,6 @@ export function useDirectoryPicker() {
const cancel = () => { const cancel = () => {
if (!selected) input.onSelect(null) if (!selected) input.onSelect(null)
} }
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) { dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
} }
} }
@@ -618,7 +618,7 @@ describe("server session", () => {
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }]) expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(ctx.store.data.message.root).toEqual([]) expect(ctx.store.data.message.root).toEqual([])
}) })
@@ -629,8 +629,32 @@ describe("server session", () => {
ctx.store.invalidate() ctx.store.invalidate()
await ctx.store.sync("root") await ctx.store.sync("root")
expect(ctx.store.data.message.root).toEqual([])
expect(ctx.get).toHaveLength(2) expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2) expect(ctx.messages).toEqual([
{ sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 200, order: "desc" },
])
})
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
const client = messageClient(response(), response())
const store = createServerSession(client)
await store.sync("child")
Array.from({ length: 428 }, (_, index) =>
store.apply({
type: "message.updated",
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
}),
)
expect(store.data.message.child).toHaveLength(428)
await store.sync("child", { force: true })
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, order: "desc" },
])
}) })
test("loads current session content through the current message API", async () => { test("loads current session content through the current message API", async () => {
@@ -655,7 +679,7 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
@@ -731,8 +755,8 @@ describe("server session", () => {
await store.sync("root") await store.sync("root")
expect(requests).toEqual([ expect(requests).toEqual([
{ sessionID: "root", limit: 20, order: "desc" }, { sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 20, cursor: "older" }, { sessionID: "root", limit: 200, cursor: "older" },
]) ])
expect(store.data.message.root.map((message) => message.id)).toEqual([ expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id, user.id,
@@ -741,6 +765,26 @@ describe("server session", () => {
expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
}) })
test("loads older messages by cursor with the fixed page size", async () => {
const older = userMessage("message-1")
const latest = userMessage("message-2", { time: { created: 2 } })
const client = messageClient(
response([{ info: latest, parts: [] }], "older"),
response([{ info: older, parts: [] }]),
)
const store = createServerSession(client)
await store.sync("child")
await store.history.loadMore("child")
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, cursor: "older" },
])
expect(store.data.message.child).toEqual([older, latest])
expect(store.history.more("child")).toBe(false)
})
// V2 messages are ordered projections and do not expose V1 assistant parent IDs. // V2 messages are ordered projections and do not expose V1 assistant parent IDs.
describe.skip("V1 assistant parent projections", () => { describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => { test("backfills an assistant-only initial page through its user root", async () => {
@@ -754,7 +798,7 @@ describe("server session", () => {
await store.sync("child") await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }]) expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }]) expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants]) expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false) expect(store.history.more("child")).toBe(false)
+18 -22
View File
@@ -30,8 +30,7 @@ type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const messagePageSize = 200
const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() const emptyIDs: ReadonlySet<string> = new Set()
@@ -242,7 +241,6 @@ export function createServerSession(
return created return created
} }
const [meta, setMeta] = createStore({ const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>, cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>, complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>, loading: {} as Record<string, boolean | undefined>,
@@ -423,7 +421,6 @@ export function createServerSession(
setMeta( setMeta(
produce((draft) => { produce((draft) => {
for (const sessionID of sessionIDs) { for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID] delete draft.cursor[sessionID]
delete draft.complete[sessionID] delete draft.complete[sessionID]
delete draft.loading[sessionID] delete draft.loading[sessionID]
@@ -457,11 +454,13 @@ export function createServerSession(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }), pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
) )
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
const request = (cursor?: string) => const request = (cursor?: string) =>
(options?.retry ?? retry)(() => { (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) return messageApi.list(
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
)
}) })
const first = await request(before) const first = await request(before)
const pages = [first] const pages = [first]
@@ -632,14 +631,13 @@ export function createServerSession(
} }
orphanParts.delete(sessionID) orphanParts.delete(sessionID)
} }
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor) setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete) setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now()) setMeta("at", sessionID, Date.now())
}) })
} }
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => { const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return if (meta.loading[sessionID]) return
const active = generation(sessionID) const active = generation(sessionID)
const load: MessageLoadState = { const load: MessageLoadState = {
@@ -658,7 +656,7 @@ export function createServerSession(
setMeta("loading", sessionID, true) setMeta("loading", sessionID, true)
let applied = false let applied = false
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined, undefined,
@@ -737,32 +735,30 @@ export function createServerSession(
} }
} }
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => { const sync = (sessionID: string, options?: { force?: boolean }) => {
touch(sessionID) touch(sessionID)
return runInflight(inflight, sessionID, async () => { return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
const invalid = invalidated.has(sessionID) const invalid = invalidated.has(sessionID)
const revision = invalidationRevision const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([ await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options), resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
]) ])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID) if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
}) })
} }
const prefetch = async (sessionID: string, limit: number) => { const prefetch = async (sessionID: string, messageCount: number) => {
touch(sessionID) touch(sessionID)
await inflight.get(sessionID) await inflight.get(sessionID)
if ( if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 && Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit) (meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
) )
return return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit)) await runInflight(inflight, sessionID, () => loadMessages(sessionID))
} }
const eventSessionID = (event: { type: string; properties?: unknown }) => { const eventSessionID = (event: { type: string; properties?: unknown }) => {
@@ -1354,11 +1350,11 @@ export function createServerSession(
setMeta("at", {}) setMeta("at", {})
}, },
prefetch, prefetch,
shouldPrefetch(sessionID: string, limit: number) { shouldPrefetch(sessionID: string, messageCount: number) {
if (data.message[sessionID] === undefined) return true if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit return (data.message[sessionID]?.length ?? 0) <= messageCount
}, },
fresh(sessionID: string, ttl: number) { fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
@@ -1440,14 +1436,14 @@ export function createServerSession(
history: { history: {
more: (sessionID: string) => more: (sessionID: string) =>
data.message[sessionID] !== undefined && data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined && meta.complete[sessionID] !== undefined &&
!meta.complete[sessionID] && !meta.complete[sessionID] &&
!!meta.cursor[sessionID], !!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false, loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) { async loadMore(sessionID: string) {
touch(sessionID) touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend") await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
}, },
}, },
evict(sessionID: string) { evict(sessionID: string) {
@@ -94,7 +94,7 @@ export function createTimelineController(input: {
fallback: language.t("command.session.new"), fallback: language.t("command.session.new"),
}) })
}) })
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID())) const showHeader = createMemo(() => !!input.session.identity.sessionID())
const projection = createTimelineProjection({ const projection = createTimelineProjection({
messages: input.session.history.messages, messages: input.session.history.messages,
userMessages: input.userMessages, userMessages: input.userMessages,
@@ -1203,6 +1203,8 @@ function MessageTimelineView(
onCleanup(() => { onCleanup(() => {
if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame)
// Solid runs cleanup before it disconnects the row, so defer TanStack's null-ref cleanup.
queueMicrotask(() => virtualizer.measureElement(null))
}) })
return ( return (
+70 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun" import { $ } from "bun"
import { rm } from "fs/promises" import { mkdir, rm } from "fs/promises"
import path from "path" import path from "path"
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
@@ -27,6 +27,7 @@ const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?
const skipInstall = process.argv.includes("--skip-install") const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui") const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin() const solidPlugin = createSolidTransformPlugin()
const releaseAssets = new Map<string, Promise<Map<string, string>>>()
const allTargets: { const allTargets: {
os: string os: string
@@ -99,6 +100,7 @@ for (const item of targets) {
} }
const target = targetName(item) const target = targetName(item)
const name = target.replace(binary, "cli") const name = target.replace(binary, "cli")
const executablePath = await compileExecutable(item)
console.log(`building ${name}`) console.log(`building ${name}`)
const result = await Bun.build({ const result = await Bun.build({
entrypoints: ["./src/index.ts"], entrypoints: ["./src/index.ts"],
@@ -115,6 +117,7 @@ for (const item of targets) {
autoloadTsconfig: true, autoloadTsconfig: true,
autoloadPackageJson: true, autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget, target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
executablePath,
outfile: path.join(outdir, name, "bin", binary), outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"], execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {}, windows: {},
@@ -154,6 +157,72 @@ for (const item of targets) {
await verifyArtifact(path.join(outdir, name)) await verifyArtifact(path.join(outdir, name))
} }
async function compileExecutable(item: (typeof allTargets)[number]) {
const release = process.env.BUN_COMPILE_RELEASE
if (!release) return
const platform = item.os === "win32" ? "windows" : item.os
const name = [
"bun",
platform,
item.arch === "arm64" ? "aarch64" : item.arch,
item.abi,
item.avx2 === false ? "baseline" : undefined,
]
.filter(Boolean)
.join("-")
const cache = path.join(outdir, ".bun", release)
const executable = path.join(cache, name, item.os === "win32" ? "bun.exe" : "bun")
if (await Bun.file(executable).exists()) return executable
await mkdir(cache, { recursive: true })
const archive = path.join(cache, `${name}.zip`)
const assets = await compileReleaseAssets(release)
const url = assets.get(`${name}.zip`)
if (!url) throw new Error(`Bun release ${release} does not include ${name}.zip`)
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
const response = await fetch(url, {
headers: { Accept: "application/octet-stream", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (!response.ok) throw new Error(`Failed to download ${name} from Bun release ${release}: ${response.status}`)
await Bun.write(archive, response)
await $`unzip -oq ${archive} -d ${cache}`
await rm(archive)
return executable
}
function compileReleaseAssets(release: string) {
const existing = releaseAssets.get(release)
if (existing) return existing
const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`)
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to resolve Bun release ${release}: ${response.status}`)
const data: unknown = await response.json()
if (typeof data !== "object" || data === null || !("assets" in data) || !Array.isArray(data.assets)) {
throw new Error(`Bun release ${release} returned invalid metadata`)
}
return new Map(
data.assets
.filter(
(asset): asset is { name: string; url: string } =>
typeof asset === "object" &&
asset !== null &&
"name" in asset &&
typeof asset.name === "string" &&
"url" in asset &&
typeof asset.url === "string",
)
.map((asset) => [asset.name, asset.url]),
)
})
.catch((error) => {
releaseAssets.delete(release)
throw error
})
releaseAssets.set(release, pending)
return pending
}
function targetName(item: (typeof allTargets)[number]) { function targetName(item: (typeof allTargets)[number]) {
return [ return [
binary, binary,
+7 -3
View File
@@ -14,9 +14,13 @@ async function published(name: string, version: string) {
async function publish(dir: string, name: string, version: string) { async function publish(dir: string, name: string, version: string) {
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir) if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) return console.log(`already published ${name}@${version}`) const exists = await published(name, version)
await $`bun pm pack`.cwd(dir) if (exists) console.log(`already published ${name}@${version}`)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) if (!exists) {
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
if (Script.channel === "beta") await $`npm dist-tag add ${`${name}@${version}`} next`
} }
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) { async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
+1 -1
View File
@@ -84,7 +84,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: { database: {
path: path:
process.env.OPENCODE_DB ?? process.env.OPENCODE_DB ??
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) || (["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" || process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true" process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db" ? "opencode.db"
+2 -2
View File
@@ -25,12 +25,12 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info)) const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = OPENCODE_CHANNEL) { export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json" if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json` return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
} }
export function defaultPort(channel = OPENCODE_CHANNEL) { export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000) return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
} }
+2 -1
View File
@@ -35,7 +35,8 @@ describe("updater", () => {
test("accepts strict release version variants", () => { test("accepts strict release version variants", () => {
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade") expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade") expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade") expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none") expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none") expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
}) })
+4
View File
@@ -11,6 +11,8 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => { test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de) expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de) expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df) expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a")) expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
@@ -37,6 +39,8 @@ test("local channel stores service config with the local service filename", asyn
test("service filenames share release channels and identify preview channels", () => { test("service filenames share release channels and identify preview channels", () => {
expect(ServiceConfig.filename("latest")).toBe("service.json") expect(ServiceConfig.filename("latest")).toBe("service.json")
expect(ServiceConfig.filename("dev")).toBe("service.json")
expect(ServiceConfig.filename("beta")).toBe("service.json")
expect(ServiceConfig.filename("next")).toBe("service.json") expect(ServiceConfig.filename("next")).toBe("service.json")
expect(ServiceConfig.filename("local")).toBe("service-local.json") expect(ServiceConfig.filename("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json") expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
+54 -41
View File
@@ -20,7 +20,6 @@ import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect" import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import type { Shell } from "@opencode-ai/schema/shell" import type { Shell } from "@opencode-ai/schema/shell"
import type { DateTime } from "effect"
import type { Provider } from "@opencode-ai/schema/provider" import type { Provider } from "@opencode-ai/schema/provider"
import type { Integration } from "@opencode-ai/schema/integration" import type { Integration } from "@opencode-ai/schema/integration"
import type { Form } from "@opencode-ai/schema/form" import type { Form } from "@opencode-ai/schema/form"
@@ -316,7 +315,7 @@ export type Endpoint5_31Output =
| ( | (
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.created" readonly type: "session.created"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -336,7 +335,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.agent.selected" readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -349,7 +348,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.model.selected" readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -362,7 +361,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.moved" readonly type: "session.moved"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -376,7 +375,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.renamed" readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -385,7 +384,16 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.viewed"
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 id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.deleted" readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -394,7 +402,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.forked" readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -410,7 +418,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered" readonly type: "session.inbox.delivered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -419,7 +427,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued" readonly type: "session.inbox.enqueued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -432,7 +440,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled" readonly type: "session.inbox.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -441,7 +449,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed" readonly type: "session.inbox.delivery.changed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -454,7 +462,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.started" readonly type: "session.execution.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -463,7 +471,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.succeeded" readonly type: "session.execution.succeeded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -472,7 +480,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.failed" readonly type: "session.execution.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -484,7 +492,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.interrupted" readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -493,7 +501,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.instructions.updated" readonly type: "session.instructions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -506,7 +514,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.synthetic" readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -520,7 +528,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.skill.activated" readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -534,7 +542,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.started" readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -543,7 +551,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.ended" readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -561,7 +569,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.started" readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -576,7 +584,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.ended" readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -598,7 +606,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.failed" readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -622,7 +630,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.started" readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -635,7 +643,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.ended" readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -650,7 +658,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.started" readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -664,7 +672,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.ended" readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -679,7 +687,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.started" readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -693,7 +701,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.ended" readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -707,7 +715,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.called" readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -723,7 +731,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.success" readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -759,7 +767,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.failed" readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -798,7 +806,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.retry.scheduled" readonly type: "session.retry.scheduled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -813,7 +821,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.started" readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -827,7 +835,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.ended" readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -841,7 +849,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.failed" readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -855,7 +863,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.staged" readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -864,7 +872,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.cleared" readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -873,7 +881,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.committed" readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -882,7 +890,7 @@ export type Endpoint5_31Output =
} }
| { | {
readonly id: Event.ID readonly id: Event.ID
readonly created: DateTime.Utc readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.usage.recorded" readonly type: "session.usage.recorded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -915,6 +923,10 @@ export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messa
export type Endpoint5_34Output = SessionMessage.Info export type Endpoint5_34Output = SessionMessage.Info
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E> export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
export type Endpoint5_35Input = { readonly sessionID: Session.ID }
export type Endpoint5_35Output = void
export type SessionViewOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
export interface SessionApi<E = never> { export interface SessionApi<E = never> {
readonly list: SessionListOperation<E> readonly list: SessionListOperation<E>
readonly create: SessionCreateOperation<E> readonly create: SessionCreateOperation<E>
@@ -959,6 +971,7 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E> readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E> readonly background: SessionBackgroundOperation<E>
readonly message: SessionMessageOperation<E> readonly message: SessionMessageOperation<E>
readonly view: SessionViewOperation<E>
} }
export type Endpoint6_0Input = { export type Endpoint6_0Input = {
@@ -86,6 +86,8 @@ import type {
Endpoint5_33Output, Endpoint5_33Output,
Endpoint5_34Input, Endpoint5_34Input,
Endpoint5_34Output, Endpoint5_34Output,
Endpoint5_35Input,
Endpoint5_35Output,
Endpoint6_0Input, Endpoint6_0Input,
Endpoint6_0Output, Endpoint6_0Output,
Endpoint7_0Input, Endpoint7_0Input,
@@ -610,6 +612,11 @@ const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34I
), ),
) )
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
preserveEffect<Endpoint5_35Output>()(
raw["session.view"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup5 = (raw: RawClient["server.session"]) => ({ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
list: Endpoint5_0(raw), list: Endpoint5_0(raw),
create: Endpoint5_1(raw), create: Endpoint5_1(raw),
@@ -639,6 +646,7 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
interrupt: Endpoint5_32(raw), interrupt: Endpoint5_32(raw),
background: Endpoint5_33(raw), background: Endpoint5_33(raw),
message: Endpoint5_34(raw), message: Endpoint5_34(raw),
view: Endpoint5_35(raw),
}) })
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) => const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
@@ -80,6 +80,8 @@ import type {
SessionBackgroundOutput, SessionBackgroundOutput,
SessionMessageInput, SessionMessageInput,
SessionMessageOutput, SessionMessageOutput,
SessionViewInput,
SessionViewOutput,
MessageListInput, MessageListInput,
MessageListOutput, MessageListOutput,
ModelListInput, ModelListInput,
@@ -896,6 +898,17 @@ export function make(options: ClientOptions) {
}, },
requestOptions, requestOptions,
).then((value) => value.data), ).then((value) => value.data),
view: (input: SessionViewInput, requestOptions?: RequestOptions) =>
request<SessionViewOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/view`,
successStatus: 204,
declaredStatuses: [404, 401, 400],
empty: true,
},
requestOptions,
),
}, },
message: { message: {
list: (input: MessageListInput, requestOptions?: RequestOptions) => list: (input: MessageListInput, requestOptions?: RequestOptions) =>
+39 -5
View File
@@ -192,7 +192,7 @@ export type ProviderInfo = {
id: string id: string
integrationID?: string integrationID?: string
name: string name: string
disabled?: boolean activation: "auto" | "enabled" | "disabled"
package: string package: string
settings?: { [x: string]: any } settings?: { [x: string]: any }
headers?: { [x: string]: string } headers?: { [x: string]: string }
@@ -479,6 +479,16 @@ export type SessionRenamed = {
data: { sessionID: string; title: string } data: { sessionID: string; title: string }
} }
export type SessionViewed = {
id: string
created: number
metadata?: { [x: string]: any }
type: "session.viewed"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string }
}
export type SessionDeleted = { export type SessionDeleted = {
id: string id: string
created: number created: number
@@ -1510,7 +1520,7 @@ export type SessionInfo = {
model?: ModelRef model?: ModelRef
cost: MoneyUSD cost: MoneyUSD
tokens: TokenUsageInfo tokens: TokenUsageInfo
time: { created: number; updated: number; archived?: number } time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
title?: string title?: string
location: LocationRef location: LocationRef
subpath?: string subpath?: string
@@ -1923,6 +1933,7 @@ export type SessionEventDurable =
| SessionModelSelected | SessionModelSelected
| SessionMoved | SessionMoved
| SessionRenamed | SessionRenamed
| SessionViewed
| SessionDeleted | SessionDeleted
| SessionForked | SessionForked
| SessionInboxDelivered | SessionInboxDelivered
@@ -2013,6 +2024,7 @@ export type V2Event =
| SessionModelSelected | SessionModelSelected
| SessionMoved | SessionMoved
| SessionRenamed | SessionRenamed
| SessionViewed
| SessionUsageUpdated | SessionUsageUpdated
| SessionDeleted | SessionDeleted
| SessionForked | SessionForked
@@ -2476,7 +2488,13 @@ export type SessionImportInput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string } readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string readonly subpath?: string
@@ -2743,7 +2761,13 @@ export type SessionImportInput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string } readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string readonly subpath?: string
@@ -3010,7 +3034,13 @@ export type SessionImportInput = {
readonly reasoning: number readonly reasoning: number
readonly cache: { readonly read: number; readonly write: number } readonly cache: { readonly read: number; readonly write: number }
} }
readonly time: { readonly created: number; readonly updated: number; readonly archived?: number } readonly time: {
readonly created: number
readonly updated: number
readonly idle?: number
readonly viewed?: number
readonly archived?: number
}
readonly title?: string readonly title?: string
readonly location: { readonly directory: string; readonly workspaceID?: string } readonly location: { readonly directory: string; readonly workspaceID?: string }
readonly subpath?: string readonly subpath?: string
@@ -3936,6 +3966,10 @@ export type SessionMessageInput = {
export type SessionMessageOutput = { data: SessionMessageInfo }["data"] export type SessionMessageOutput = { data: SessionMessageInfo }["data"]
export type SessionViewInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionViewOutput = void
export type MessageListInput = { export type MessageListInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"] readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly limit?: { readonly limit?: {
+12 -4
View File
@@ -136,8 +136,10 @@ test("event.subscribe terminates on Effect protocol decode failures", async () =
test("session methods retain decoded Effect inputs and outputs", async () => { test("session methods retain decoded Effect inputs and outputs", async () => {
const logQueries: Array<Record<string, string>> = [] const logQueries: Array<Record<string, string>> = []
const requests: Array<{ method: string; url: string }> = []
const httpClient = HttpClient.make((request) => { const httpClient = HttpClient.make((request) => {
const url = request.url const url = request.url
requests.push({ method: request.method, url })
if (url.includes("/log")) { if (url.includes("/log")) {
logQueries.push(Object.fromEntries(request.urlParams.params)) logQueries.push(Object.fromEntries(request.urlParams.params))
return Effect.succeed( return Effect.succeed(
@@ -183,6 +185,7 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({ const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }), location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
}) })
yield* client.session.view({ sessionID: Session.ID.make("ses_test") })
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") }) yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({ yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"), sessionID: Session.ID.make("ses_test"),
@@ -207,7 +210,11 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
return { page, active, created, admitted, context, log, message } return { page, active, created, admitted, context, log, message }
}).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise) }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient), Effect.runPromise)
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000) const listed = result.page.data[0]
if (!listed?.time.idle || !listed.time.viewed) throw new Error("Expected attention times")
expect(DateTime.toEpochMillis(listed.time.created)).toBe(1_717_171_717_000)
expect(DateTime.toEpochMillis(listed.time.idle)).toBe(1_717_171_717_002)
expect(DateTime.toEpochMillis(listed.time.viewed)).toBe(1_717_171_717_001)
expect(result.active).toEqual({ ses_test: { type: "running" } }) expect(result.active).toEqual({ ses_test: { type: "running" } })
expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype) expect(Object.getPrototypeOf(result.page.data[0])).toBe(Object.prototype)
expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype) expect(Object.getPrototypeOf(result.created)).toBe(Object.prototype)
@@ -217,11 +224,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000) expect(DateTime.toEpochMillis(result.admitted.timeCreated)).toBe(1_717_171_717_000)
expect(result.context).toEqual([]) expect(result.context).toEqual([])
expect(logQueries[0]).toEqual({ after: "0" }) expect(logQueries[0]).toEqual({ after: "0" })
expect(requests).toContainEqual({ method: "POST", url: "http://localhost:3000/api/session/ses_test/view" })
const logged = Array.from(result.log) const logged = Array.from(result.log)
expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"]) expect(logged.map((item) => item.type)).toEqual(["session.model.selected", "log.synced"])
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe( expect(logged[0]?.type === "session.model.selected" && logged[0].created).toBe(1_717_171_717_000)
1_717_171_717_000,
)
expect(logged.at(-1)).toEqual(synced) expect(logged.at(-1)).toEqual(synced)
expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" })) expect(result.message).toEqual(expect.objectContaining({ id: "msg_model", type: "model-switched" }))
}) })
@@ -260,6 +266,8 @@ const session = {
time: { time: {
created: 1_717_171_717_000, created: 1_717_171_717_000,
updated: 1_717_171_717_000, updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
}, },
title: "Test", title: "Test",
location: { directory: "/tmp/project" }, location: { directory: "/tmp/project" },
+5
View File
@@ -539,6 +539,7 @@ test("session methods use the public HTTP contract", async () => {
const page = await client.session.list({ limit: 10, order: "desc", parentID: null }) const page = await client.session.list({ limit: 10, order: "desc", parentID: null })
const active = await client.session.active() const active = await client.session.active()
const created = await client.session.create({ location: { directory: "/tmp/project" } }) const created = await client.session.create({ location: { directory: "/tmp/project" } })
await client.session.view({ sessionID: "ses_test" })
await client.session.switchAgent({ sessionID: "ses_test", agent: "build" }) await client.session.switchAgent({ sessionID: "ses_test", agent: "build" })
await client.session.switchModel({ await client.session.switchModel({
sessionID: "ses_test", sessionID: "ses_test",
@@ -565,6 +566,7 @@ test("session methods use the public HTTP contract", async () => {
const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" }) const message = await client.session.message({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next") expect(page.cursor.next).toBe("next")
expect(page.data[0].time).toMatchObject({ idle: 1_717_171_717_002, viewed: 1_717_171_717_001 })
expect(active).toEqual({ ses_test: { type: "running" } }) expect(active).toEqual({ ses_test: { type: "running" } })
expect(created.id).toBe("ses_test") expect(created.id).toBe("ses_test")
expect(admitted.id).toBe("msg_test") expect(admitted.id).toBe("msg_test")
@@ -577,6 +579,7 @@ test("session methods use the public HTTP contract", async () => {
["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"], ["GET", "http://localhost:3000/api/session?limit=10&order=desc&parentID=null"],
["GET", "http://localhost:3000/api/session/active"], ["GET", "http://localhost:3000/api/session/active"],
["POST", "http://localhost:3000/api/session"], ["POST", "http://localhost:3000/api/session"],
["POST", "http://localhost:3000/api/session/ses_test/view"],
["POST", "http://localhost:3000/api/session/ses_test/agent"], ["POST", "http://localhost:3000/api/session/ses_test/agent"],
["POST", "http://localhost:3000/api/session/ses_test/model"], ["POST", "http://localhost:3000/api/session/ses_test/model"],
["POST", "http://localhost:3000/api/session/ses_test/prompt"], ["POST", "http://localhost:3000/api/session/ses_test/prompt"],
@@ -651,6 +654,8 @@ const session = {
time: { time: {
created: 1_717_171_717_000, created: 1_717_171_717_000,
updated: 1_717_171_717_000, updated: 1_717_171_717_000,
idle: 1_717_171_717_002,
viewed: 1_717_171_717_001,
}, },
title: "Test", title: "Test",
location: { directory: "/tmp/project" }, location: { directory: "/tmp/project" },
@@ -21,7 +21,7 @@ export default function TermsOfService() {
<section data-component="brand-content"> <section data-component="brand-content">
<article data-component="terms-of-service"> <article data-component="terms-of-service">
<h1>Terms of Use</h1> <h1>Terms of Use</h1>
<p class="effective-date">Effective date: Mar 6, 2026</p> <p class="effective-date">Effective date: Aug 15, 2026</p>
<p> <p>
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
@@ -154,6 +154,11 @@ export default function TermsOfService() {
is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or
otherwise objectionable; otherwise objectionable;
</li> </li>
<li>
creates, maintains, or uses accounts in bulk, or creates, maintains, or uses multiple accounts to
circumvent usage limits, access restrictions, billing obligations, promotions, suspensions, or any
other restriction or policy applicable to the Services;
</li>
<li>automatically or programmatically extracts data or Output (defined below);</li> <li>automatically or programmatically extracts data or Output (defined below);</li>
<li>Represent that the Output was human-generated when it was not;</li> <li>Represent that the Output was human-generated when it was not;</li>
<li> <li>
+22 -2
View File
@@ -1,8 +1,8 @@
{ {
"version": "7", "version": "7",
"dialect": "sqlite", "dialect": "sqlite",
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936", "id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"], "prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
"ddl": [ "ddl": [
{ {
"name": "account_state", "name": "account_state",
@@ -1350,6 +1350,26 @@
"entityType": "columns", "entityType": "columns",
"table": "session_v2" "table": "session_v2"
}, },
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_idle",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_viewed",
"entityType": "columns",
"table": "session_v2"
},
{ {
"type": "integer", "type": "integer",
"notNull": false, "notNull": false,
+10 -10
View File
@@ -1,6 +1,6 @@
export * as Bus from "./bus.js" export * as Bus from "./bus.js"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect" import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log" import type { EventLog } from "@opencode-ai/schema/event-log"
import { and, asc, eq, gt, lte, sql } from "drizzle-orm" import { and, asc, eq, gt, lte, sql } from "drizzle-orm"
@@ -47,7 +47,7 @@ export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
export type SerializedEvent = { export type SerializedEvent = {
readonly id: Event.ID readonly id: Event.ID
readonly type: string readonly type: string
readonly created?: DateTime.Utc readonly created?: number
readonly seq: number readonly seq: number
readonly aggregateID: string readonly aggregateID: string
readonly data: Record<string, unknown> readonly data: Record<string, unknown>
@@ -74,7 +74,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
} }
return { return {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0), created: event.created ?? 0,
type: definition.type, type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version), durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
@@ -283,7 +283,7 @@ export function configured(options?: Options) {
if ( if (
stored?.id === event.id && stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) && stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) && stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded) isDeepStrictEqual(stored.data, encoded)
) { ) {
if (input.ownerID && row?.ownerID == null) { if (input.ownerID && row?.ownerID == null) {
@@ -358,7 +358,7 @@ export function configured(options?: Options) {
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)), created: event.created ?? 0,
type: versionedType(definition.type, durable.version), type: versionedType(definition.type, durable.version),
data: encoded, data: encoded,
}, },
@@ -455,7 +455,7 @@ export function configured(options?: Options) {
definition, definition,
{ {
id: options?.id ?? Event.ID.create(), id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now, created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -491,7 +491,7 @@ export function configured(options?: Options) {
commit: options?.commit, commit: options?.commit,
event: { event: {
id: options?.id ?? Event.ID.create(), id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now, created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}), ...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type, type: definition.type,
...(location ? { location } : {}), ...(location ? { location } : {}),
@@ -571,7 +571,7 @@ export function configured(options?: Options) {
id: event.id, id: event.id,
aggregate_id: aggregateID, aggregate_id: aggregateID,
seq, seq,
created: DateTime.toEpochMillis(event.created), created: event.created,
type: versionedType(item.definition.type, item.definition.durable.version), type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded, data: encoded,
}) })
@@ -619,7 +619,7 @@ export function configured(options?: Options) {
Effect.gen(function* () { Effect.gen(function* () {
const payload = { const payload = {
id: event.id, id: event.id,
created: event.created ?? DateTime.makeUnsafe(0), created: event.created ?? 0,
type: definition.type, type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data), data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload } as Event.Payload
@@ -733,7 +733,7 @@ export function configured(options?: Options) {
return [ return [
decodeSerializedEvent({ decodeSerializedEvent({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
+2 -2
View File
@@ -65,8 +65,8 @@ const layer = Layer.effect(
const integrations = yield* Integration.Service const integrations = yield* Integration.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => { const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false if (provider.activation === "disabled") return false
if (typeof provider.settings?.apiKey === "string") return true if (provider.activation === "enabled") return true
if (integration?.connections.length) return true if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration return provider.integrationID === undefined && !integration
} }
+2 -2
View File
@@ -13,7 +13,7 @@ import { ConfigAgentV1 } from "../../v1/config/agent.js"
import { ConfigMigrateV1 } from "../../v1/config/migrate.js" import { ConfigMigrateV1 } from "../../v1/config/migrate.js"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import type { LocationPath } from "../../location-path.js" import type { LocationMutation } from "../../location-mutation.js"
import type { ReadTool } from "../../tool/plugin/read.js" import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js" import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js" import { AbsolutePath } from "../../schema.js"
@@ -28,7 +28,7 @@ const decodeAgent = Schema.decodeUnknownOption(ConfigAgent.Info)
const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info) const decodeLegacyAgent = Schema.decodeUnknownOption(ConfigAgentV1.Info)
const decodeConfig = Schema.decodeUnknownOption(Info) const decodeConfig = Schema.decodeUnknownOption(Info)
type PathAction = type PathAction =
| LocationPath.ExternalDirectoryAuthorization["action"] | LocationMutation.ExternalDirectoryAuthorization["action"]
| typeof ReadTool.name | typeof ReadTool.name
| typeof EditTool.name | typeof EditTool.name
const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[] const pathActions = ["external_directory", "read", "edit"] as const satisfies readonly PathAction[]
@@ -40,6 +40,7 @@ export const Plugin = define({
for (const [id, item] of configuredProviders(loaded.entries)) { for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id const providerID = id
catalog.provider.update(providerID, (provider) => { catalog.provider.update(providerID, (provider) => {
provider.activation = "enabled"
if (item.name !== undefined) provider.name = item.name if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings) if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
+2
View File
@@ -43,6 +43,7 @@ import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js" import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js" import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js" import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260815182818_session_viewed_state.js"
export const migrations = [ export const migrations = [
m00, m00,
@@ -89,4 +90,5 @@ export const migrations = [
m41, m41,
m42, m42,
m43, m43,
m44,
] satisfies DatabaseMigration.Migration[] ] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260815182818_session_viewed_state",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_idle\` integer;`)
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_viewed\` integer;`)
})
},
}
export default migration
+2
View File
@@ -209,6 +209,8 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`model\` text, \`model\` text,
\`time_created\` integer NOT NULL, \`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL, \`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`time_compacting\` integer, \`time_compacting\` integer,
\`time_archived\` integer, \`time_archived\` integer,
\`time_suspended\` integer, \`time_suspended\` integer,
@@ -1,4 +1,4 @@
export * as LocationPath from "./location-path.js" export * as LocationMutation from "./location-mutation.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path" import path from "path"
@@ -12,8 +12,8 @@ export const Kind = Schema.Literals(["file", "directory"])
export type Kind = typeof Kind.Type export type Kind = typeof Kind.Type
/** /**
* Tool paths do not accept project references. Relative paths resolve from * Mutation paths do not accept project references. Relative paths resolve
* the active Location. Paths outside its project require separate * from the active Location. Paths outside it require separate
* `external_directory` approval. * `external_directory` approval.
*/ */
export const ResolveInput = Schema.Struct({ export const ResolveInput = Schema.Struct({
@@ -49,13 +49,13 @@ export interface Target {
export interface Interface { export interface Interface {
/** /**
* Resolve a path and derive its permission resources. Relative paths resolve * Resolve a path and derive its permission resources. Relative paths resolve
* from the Location. Paths outside its project require separate * from the Location. Paths outside it require separate `external_directory`
* `external_directory` approval. This does not approve access. * approval. This does not approve the mutation.
*/ */
readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error> readonly resolve: (input: ResolveInput) => Effect.Effect<Target, FSUtil.Error>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LocationMutation") {}
const slash = (value: string) => value.replaceAll("\\", "/") const slash = (value: string) => value.replaceAll("\\", "/")
@@ -65,13 +65,9 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
const location = yield* Location.Service const location = yield* Location.Service
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) { const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path) const absolute = path.resolve(location.directory, input.path)
const projectRoot = path.parse(location.project.directory).root if (FSUtil.contains(location.directory, absolute)) {
if (
FSUtil.contains(location.directory, absolute) ||
(location.project.directory !== projectRoot && FSUtil.contains(location.project.directory, absolute))
) {
return { return {
absolute, absolute,
resource: slash(path.relative(location.directory, absolute) || "."), resource: slash(path.relative(location.directory, absolute) || "."),
+2 -2
View File
@@ -19,7 +19,7 @@ import { Image } from "./image.js"
import { LocationWatcher } from "./filesystem/location-watcher.js" import { LocationWatcher } from "./filesystem/location-watcher.js"
import { Integration } from "./integration.js" import { Integration } from "./integration.js"
import { Location } from "./location.js" import { Location } from "./location.js"
import { LocationPath } from "./location-path.js" import { LocationMutation } from "./location-mutation.js"
import { LocationServiceMap } from "./location-service-map.js" import { LocationServiceMap } from "./location-service-map.js"
import { ModelResolver } from "./model-resolver.js" import { ModelResolver } from "./model-resolver.js"
import { MCP } from "./mcp/index.js" import { MCP } from "./mcp/index.js"
@@ -76,7 +76,7 @@ const locationServiceNodes = [
Skill.node, Skill.node,
InstructionBuiltIns.node, InstructionBuiltIns.node,
InstructionDiscovery.node, InstructionDiscovery.node,
LocationPath.node, LocationMutation.node,
FileMutation.node, FileMutation.node,
Formatter.node, Formatter.node,
MCP.node, MCP.node,
+43 -10
View File
@@ -358,17 +358,21 @@ export const layer = Layer.effect(
const connection = yield* integrations.connection.active( const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID), provider?.integrationID ?? Integration.ID.make(selected.providerID),
) )
const model = yield* resolveModel( const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
selected, const runtimeInfo = yield* withVariant(selected, variant)
variant, const model = yield* fromCatalogModel(runtimeInfo, credential, {
connection ? yield* integrations.connection.resolve(connection) : undefined, loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
{ loadAISDK: (model) => aisdk.model(model),
loadPackage: (specifier) => Provider.loadPackage(specifier, npm), })
loadAISDK: (model) => aisdk.model(model), const runtime =
}, provider?.activation === "enabled" &&
) credential === undefined &&
!hasConfiguredAuth(runtimeInfo) &&
usesAPIKeyAuth(runtimeInfo.package)
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
: model
return { return {
model, model: runtime,
ref: Ref.make({ ref: Ref.make({
id: selected.id, id: selected.id,
providerID: selected.providerID, providerID: selected.providerID,
@@ -399,6 +403,35 @@ export const layer = Layer.effect(
}), }),
) )
function hasConfiguredAuth(model: Info) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
}
function usesAPIKeyAuth(packageName: string | undefined) {
const name = Provider.packageName(packageName)
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
name === "@opencode-ai/ai/providers/openai" ||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
name === "@opencode-ai/ai/providers/azure" ||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
)
}
export const node = makeLocationNode({ export const node = makeLocationNode({
service: Service, service: Service,
layer, layer,
+1
View File
@@ -86,6 +86,7 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
const info = { const info = {
id: providerID, id: providerID,
name: item.name, name: item.name,
activation: "auto",
package: Provider.aisdk(item.npm), package: Provider.aisdk(item.npm),
...(item.api ? { settings: { baseURL: item.api } } : {}), ...(item.api ? { settings: { baseURL: item.api } } : {}),
} satisfies Provider.Info } satisfies Provider.Info
+4 -4
View File
@@ -32,7 +32,7 @@ import { InstructionDiscovery } from "../instruction-discovery.js"
import { Integration } from "../integration.js" import { Integration } from "../integration.js"
import { KV } from "../kv.js" import { KV } from "../kv.js"
import { Location } from "../location.js" import { Location } from "../location.js"
import { LocationPath } from "../location-path.js" import { LocationMutation } from "../location-mutation.js"
import { ModelsDev } from "../models-dev.js" import { ModelsDev } from "../models-dev.js"
import { Npm } from "@opencode-ai/util/npm" import { Npm } from "@opencode-ai/util/npm"
import { Permission } from "../permission.js" import { Permission } from "../permission.js"
@@ -92,7 +92,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const integration = yield* Integration.Service const integration = yield* Integration.Service
const kv = yield* KV.Service const kv = yield* KV.Service
const location = yield* Location.Service const location = yield* Location.Service
const locationMutation = yield* LocationPath.Service const locationMutation = yield* LocationMutation.Service
const models = yield* ModelsDev.Service const models = yield* ModelsDev.Service
const npm = yield* Npm.Service const npm = yield* Npm.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
@@ -129,7 +129,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Integration.Service, integration), Context.make(Integration.Service, integration),
Context.make(KV.Service, kv), Context.make(KV.Service, kv),
Context.make(Location.Service, location), Context.make(Location.Service, location),
Context.make(LocationPath.Service, locationMutation), Context.make(LocationMutation.Service, locationMutation),
Context.make(ModelsDev.Service, models), Context.make(ModelsDev.Service, models),
Context.make(Npm.Service, npm), Context.make(Npm.Service, npm),
Context.make(Permission.Service, permission), Context.make(Permission.Service, permission),
@@ -173,7 +173,7 @@ export const requirements = LayerNode.group([
Integration.node, Integration.node,
KV.node, KV.node,
Location.node, Location.node,
LocationPath.node, LocationMutation.node,
ModelsDev.node, ModelsDev.node,
Npm.node, Npm.node,
Permission.node, Permission.node,
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
const configured = new Set((yield* integrations.list()).map((integration) => integration.id)) const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
yield* ctx.catalog.transform((evt) => { yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) { for (const item of evt.provider.list()) {
if (item.provider.disabled) continue if (item.provider.activation === "disabled") continue
if (!Provider.isAISDK(item.provider.package)) continue if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
@@ -178,7 +178,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
if (!item) return if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey) const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => { catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" } if (!hasKey) {
provider.activation = "enabled"
provider.settings = { ...provider.settings, apiKey: "public" }
}
}) })
if (hasKey) return if (hasKey) return
for (const model of item.models.values()) { for (const model of item.models.values()) {
+28 -1
View File
@@ -63,12 +63,39 @@ examples, but do not fetch it to determine the V2 configuration shape.
See the [full configuration guide](https://opencode.ai/v2/docs/config) for See the [full configuration guide](https://opencode.ai/v2/docs/config) for
every field, examples, config locations, and links to dedicated feature guides. every field, examples, config locations, and links to dedicated feature guides.
## [MCP servers](https://opencode.ai/v2/docs/mcp-servers)
Configure MCP servers under `mcp.servers`. Prefer the CLI because it preserves
unrelated configuration. Use `--global` when the user asks to set up a service
for themselves without limiting it to the current project; omit it when they
explicitly want project-local configuration.
```sh
opencode2 mcp add <name> --global --url <remote-url>
opencode2 mcp list
```
Remote servers use OAuth by default. If `mcp list` reports that a server needs
authentication, run the OAuth flow and then verify the connection:
```sh
opencode2 mcp auth <name>
opencode2 mcp list
```
The auth command prints an authorization URL, waits for the browser redirect,
and stores credentials outside the OpenCode configuration. Do not ask for or
store an API key when the server supports OAuth. Use header-based credentials
only when OAuth is unavailable or the user explicitly requires them, and use an
environment substitution such as `{env:MCP_API_KEY}` instead of writing a
secret into configuration.
## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1) ## [V1 to V2 migration](https://opencode.ai/v2/docs/migrate-v1)
For any request to migrate OpenCode configuration, agents, commands, skills, For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In [migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`. the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible. V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native The only intentional breaking changes are the server API and plugin API. Native
+17 -7
View File
@@ -165,6 +165,7 @@ export interface Interface {
input: ForkInput, input: ForkInput,
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError> ) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError> readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
readonly view: (input: { sessionID: SessionSchema.ID }) => Effect.Effect<void, NotFoundError>
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError> readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
readonly messages: (input: { readonly messages: (input: {
sessionID: SessionSchema.ID sessionID: SessionSchema.ID
@@ -447,6 +448,17 @@ const layer = Layer.effect(
if (!session) return yield* new NotFoundError({ sessionID }) if (!session) return yield* new NotFoundError({ sessionID })
return session return session
}), }),
view: Effect.fn("Session.view")(function* (input) {
const row = yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, input.sessionID))
.get()
.pipe(Effect.orDie)
if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
}),
remove: Effect.fn("Session.remove")(function* (sessionID) { remove: Effect.fn("Session.remove")(function* (sessionID) {
const session = yield* result.get(sessionID) const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID) yield* execution.interrupt(sessionID)
@@ -789,7 +801,10 @@ const layer = Layer.effect(
return false return false
}), }),
) )
if (recovered) return if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID) yield* execution.wake(input.sessionID)
}), }),
compact: Effect.fn("Session.compact")(function* (input) { compact: Effect.fn("Session.compact")(function* (input) {
@@ -873,12 +888,7 @@ const layer = Layer.effect(
), ),
), ),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) => interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible( Effect.uninterruptible(execution.interrupt(sessionID, options)),
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
), ),
revert: { revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) { stage: Effect.fn("Session.revert.stage")(function* (input) {
+22 -11
View File
@@ -1,7 +1,8 @@
export * as SessionExecution from "./execution.js" 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 { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js" import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js" import { SessionEvent } from "./event.js"
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js" import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js" import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js" import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface { export interface Interface {
/** Snapshots active execution owned by this process. */ /** Snapshots active execution owned by this process. */
@@ -19,8 +21,10 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError> readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */ /** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Wakes only an active execution, preserving its current input eligibility. */
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */ /** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */ /** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void> readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
} }
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) => const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe( effect.pipe(
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
force: boolean, force: boolean,
continuation?: SessionRunner.Continuation, continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> { ): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () { return Effect.gen(function* () {
const session = yield* store.get(sessionID) const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`)) if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) => const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }), runner.drain({ sessionID, force, continuation, promotable }),
).pipe( ).pipe(
Effect.provide(locations.get(session.location)), Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) => Effect.tapCause((cause) =>
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
), ),
) )
if (result.type === "complete") return if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation) return yield* drain(sessionID, false, result.continuation, promotable)
}) })
} }
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({ const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
sessionID, sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)), bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
), ),
drain: (sessionID, force) => drain(sessionID, force), drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain. // One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) => settled: (sessionID, exit, reason) =>
reportLifecycle( reportLifecycle(
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
}), }),
), ),
}) })
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({ return Service.of({
active: coordinator.active, active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"), interrupt: (sessionID, options) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({ export const node = makeGlobalNode({
service: Service, service: Service,
layer, layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node], deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
}) })
/** Low-level compatibility layer for callers that only need durable Session recording. */ /** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: () => Effect.void, resume: () => Effect.void,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
+11 -3
View File
@@ -161,7 +161,7 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
const base = { const base = {
id: request.id, id: request.id,
sessionID: request.sessionID, sessionID: request.sessionID,
timeCreated: event.created, timeCreated: DateTime.makeUnsafe(event.created),
} }
return Effect.succeed(Info.make({ ...base, ...request.item })) return Effect.succeed(Info.make({ ...base, ...request.item }))
}), }),
@@ -196,7 +196,7 @@ export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(functio
readonly id: SessionMessage.ID readonly id: SessionMessage.ID
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly item: Item readonly item: Item
readonly timeCreated: DateTime.Utc readonly timeCreated: number
}, },
) { ) {
const message = yield* db const message = yield* db
@@ -222,7 +222,7 @@ export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(functio
: encodeMove(request.item.payload), : encodeMove(request.item.payload),
delivery: request.item.delivery, delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq, enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated), time_created: request.timeCreated,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ id: SessionInboxTable.id }) .returning({ id: SessionInboxTable.id })
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
return row ? fromRow(row) : undefined return row ? fromRow(row) : undefined
}) })
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
})
/** /**
* Which pending rows count: "any" counts every row, while "input" means any * Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode. * item in either delivery mode.
+2
View File
@@ -53,6 +53,8 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: { time: {
created: DateTime.makeUnsafe(row.time_created), created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated), updated: DateTime.makeUnsafe(row.time_updated),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined, archived: row.time_archived ? DateTime.makeUnsafe(row.time_archived) : undefined,
}, },
}) })
+23 -21
View File
@@ -26,6 +26,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftTool = WritableDraft<SessionMessage.AssistantTool> type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText> type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning> type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) => const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast( assistant?.content.findLast(
@@ -59,6 +60,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
Match.type<SessionEvent.DurableEvent>(), Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({ Match.discriminatorsExhaustive("type")({
"session.created": () => Effect.void, "session.created": () => Effect.void,
"session.viewed": () => Effect.void,
"session.usage.recorded": () => Effect.void, "session.usage.recorded": () => Effect.void,
"session.agent.selected": (event) => { "session.agent.selected": (event) => {
return Effect.gen(function* () { return Effect.gen(function* () {
@@ -70,7 +72,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata, metadata: event.metadata,
agent: event.data.agent, agent: event.data.agent,
previous, previous,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -85,7 +87,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata, metadata: event.metadata,
model: event.data.model, model: event.data.model,
previous, previous,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -101,7 +103,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
projectID: event.data.projectID, projectID: event.data.projectID,
subpath: event.data.subpath, subpath: event.data.subpath,
previous: yield* adapter.getLocation(), previous: yield* adapter.getLocation(),
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -126,7 +128,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
text: event.data.text, text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`, description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -138,7 +140,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.data.metadata, metadata: event.data.metadata,
id: SessionMessage.ID.fromEvent(event.id), id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic", type: "synthetic",
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -151,7 +153,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
name: event.data.name, name: event.data.name,
text: event.data.text, text: event.data.text,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -164,7 +166,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
shellID: event.data.shell.id, shellID: event.data.shell.id,
command: event.data.shell.command, command: event.data.shell.command,
status: event.data.shell.status, status: event.data.shell.status,
time: { created: event.created }, time: { created },
}), }),
) )
}, },
@@ -177,7 +179,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.status = event.data.shell.status draft.status = event.data.shell.status
draft.exit = event.data.shell.exit draft.exit = event.data.shell.exit
draft.output = event.data.output draft.output = event.data.output
draft.time.completed = event.created draft.time.completed = created
}), }),
) )
} }
@@ -205,7 +207,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateAssistant( yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => { produce(currentAssistant, (draft) => {
draft.retry = undefined draft.retry = undefined
draft.time.completed = event.created draft.time.completed = created
}), }),
) )
} }
@@ -216,7 +218,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
metadata: event.metadata, metadata: event.metadata,
time: { created: event.created }, time: { created },
content: [], content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}), }),
@@ -225,7 +227,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}, },
"session.step.ended": (event) => { "session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = created
draft.finish = event.data.finish draft.finish = event.data.finish
draft.cost = event.data.cost draft.cost = event.data.cost
draft.tokens = event.data.tokens draft.tokens = event.data.tokens
@@ -239,7 +241,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
}, },
"session.step.failed": (event) => { "session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created draft.time.completed = created
draft.finish = "error" draft.finish = "error"
draft.error = castDraft(event.data.error) draft.error = castDraft(event.data.error)
draft.retry = undefined draft.retry = undefined
@@ -277,7 +279,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "tool", type: "tool",
id: event.data.id, id: event.data.id,
name: event.data.name, name: event.data.name,
time: { created: event.created }, time: { created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }), state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
}), }),
), ),
@@ -296,7 +298,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) { if (match) {
match.executed = event.data.executed match.executed = event.data.executed
match.providerState = event.data.state match.providerState = event.data.state
match.time.ran = event.created match.time.ran = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateRunning.make({ SessionMessage.ToolStateRunning.make({
status: "running", status: "running",
@@ -315,7 +317,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && match.state.status === "running") { if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
match.time.completed = event.created match.time.completed = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateCompleted.make({ SessionMessage.ToolStateCompleted.make({
status: "completed", status: "completed",
@@ -333,7 +335,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && (match.state.status === "streaming" || match.state.status === "running")) { if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState match.providerResultState = event.data.resultState
match.time.completed = event.created match.time.completed = created
match.state = castDraft( match.state = castDraft(
SessionMessage.ToolStateError.make({ SessionMessage.ToolStateError.make({
status: "error", status: "error",
@@ -354,7 +356,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "reasoning", type: "reasoning",
text: "", text: "",
state: event.data.state, state: event.data.state,
time: { created: event.created }, time: { created },
}), }),
), ),
) )
@@ -365,7 +367,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestReasoning(draft) const match = latestReasoning(draft)
if (match) { if (match) {
match.text = event.data.text match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created } match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state if (event.data.state !== undefined) match.state = event.data.state
} }
}) })
@@ -389,7 +391,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason, reason: event.data.reason,
summary: "", summary: "",
recent: event.data.recent ?? "", recent: event.data.recent ?? "",
time: { created: event.created }, time: { created },
}), }),
), ),
"session.compaction.ended": (event) => { "session.compaction.ended": (event) => {
@@ -414,7 +416,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason, reason: event.data.reason,
summary: event.data.text, summary: event.data.text,
recent: event.data.recent, recent: event.data.recent,
time: { created: event.created }, time: { created },
}), }),
) )
}) })
@@ -429,7 +431,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: current?.metadata ?? event.metadata, metadata: current?.metadata ?? event.metadata,
reason: event.data.reason, reason: event.data.reason,
error: event.data.error, error: event.data.error,
time: current?.time ?? { created: event.created }, time: current?.time ?? { created },
}) })
if (current?.status === "running") return yield* adapter.updateCompaction(failed) if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed) yield* adapter.appendMessage(failed)
+52 -17
View File
@@ -162,8 +162,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
tokens_reasoning: 0, tokens_reasoning: 0,
tokens_cache_read: 0, tokens_cache_read: 0,
tokens_cache_write: 0, tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.created), time_created: event.created,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ sessionID: SessionTable.id }) .returning({ sessionID: SessionTable.id })
@@ -391,6 +391,30 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.pipe(Effect.orDie) .pipe(Effect.orDie)
} }
function projectIdle(
db: DatabaseService,
event:
| typeof SessionEvent.Execution.Succeeded.Type
| typeof SessionEvent.Execution.Failed.Type
| typeof SessionEvent.Execution.Interrupted.Type,
) {
return Effect.gen(function* () {
yield* run(db, event)
if (event.type === SessionEvent.Execution.Interrupted.type && event.data.reason === "shutdown") return
const time = event.created
yield* db
.update(SessionTable)
.set({
// Unread uses a strict timestamp comparison, so every terminal must advance even within one millisecond.
time_idle: sql`max(${time}, coalesce(${SessionTable.time_idle} + 1, ${time}))`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
const layer = Layer.effectDiscard( const layer = Layer.effectDiscard(
Effect.gen(function* () { Effect.gen(function* () {
const bus = yield* Bus.Service const bus = yield* Bus.Service
@@ -411,8 +435,8 @@ const layer = Layer.effectDiscard(
agent: event.data.agent, agent: event.data.agent,
model: event.data.model, model: event.data.model,
version: event.data.version, version: event.data.version,
time_created: DateTime.toEpochMillis(event.created), time_created: event.created,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning({ sessionID: SessionTable.id }) .returning({ sessionID: SessionTable.id })
@@ -431,7 +455,7 @@ const layer = Layer.effectDiscard(
path: event.data.subpath, path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}), ...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null, workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -487,7 +511,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event) yield* run(db, event)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) }) .set({ agent: event.data.agent, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -498,7 +522,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event) yield* run(db, event)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) }) .set({ model: event.data.model, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -507,7 +531,18 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Renamed, (event) => yield* bus.project(SessionEvent.Renamed, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) }) .set({ title: event.data.title, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Viewed, (event) =>
db
.update(SessionTable)
.set({
time_viewed: sql`${SessionTable.time_idle}`,
time_updated: sql`${SessionTable.time_updated}`,
})
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie), .pipe(Effect.orDie),
@@ -535,7 +570,7 @@ const layer = Layer.effectDiscard(
files: input.payload.files, files: input.payload.files,
agents: input.payload.agents, agents: input.payload.agents,
skills: input.payload.skills, skills: input.payload.skills,
time: { created: event.created }, time: { created: DateTime.makeUnsafe(event.created) },
} }
: { : {
id: input.id, id: input.id,
@@ -543,7 +578,7 @@ const layer = Layer.effectDiscard(
text: input.payload.text, text: input.payload.text,
description: input.payload.description, description: input.payload.description,
metadata: input.payload.metadata, metadata: input.payload.metadata,
time: { created: event.created }, time: { created: DateTime.makeUnsafe(event.created) },
}, },
) )
}), }),
@@ -561,7 +596,7 @@ const layer = Layer.effectDiscard(
}) })
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) }) .set({ time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
@@ -580,9 +615,9 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery, delivery: event.data.delivery,
}), }),
) )
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) => yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* run(db, event) yield* run(db, event)
@@ -640,7 +675,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable) .update(SessionTable)
.set({ .set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined }, revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created), time_updated: event.created,
}) })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
@@ -650,7 +685,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) => yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
db db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie, Effect.asVoid), .pipe(Effect.orDie, Effect.asVoid),
@@ -685,7 +720,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie) .pipe(Effect.orDie)
yield* db yield* db
.update(SessionTable) .update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) }) .set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID)) .where(eq(SessionTable.id, event.data.sessionID))
.run() .run()
.pipe(Effect.orDie) .pipe(Effect.orDie)
+82 -23
View File
@@ -1,6 +1,7 @@
export * as SessionRunCoordinator from "./run-coordinator.js" export * as SessionRunCoordinator from "./run-coordinator.js"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect" import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import type { Promotable } from "./inbox.js"
/** Serializes execution for each key while allowing different keys to run concurrently. */ /** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> { export interface Coordinator<Key, E, Reason = never> {
@@ -9,26 +10,41 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */ /** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E> readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */ /** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void> readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */ /** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void> readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */ /** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void> readonly awaitIdle: (key: Key) => Effect.Effect<void>
} }
export type Request = Promotable
/** /**
* One execution is a busy period for one key: one fiber that drains from the first wake * One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the * until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell * execution rings it with its eligibility request, and the execution loop drains again
* closes the gap between a drain's last eligibility check and the idle transition, since * instead of ending. The doorbell closes the gap between a drain's last eligibility check
* those cannot be one atomic step. `done` resolves joiners with this execution's exit. * and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit.
*/ */
type Execution<E, Reason> = { type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E> readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void> owner?: Fiber.Fiber<void>
pendingWake: boolean request: Request
pendingWake?: Request
stopping: boolean stopping: boolean
interruptionReason?: Reason interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
} }
/** /**
@@ -43,7 +59,7 @@ type Execution<E, Reason> = {
* ``` * ```
*/ */
export const make = <Key, E, Reason = never>(options: { export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E> readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */ /** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void> readonly started?: (key: Key) => Effect.Effect<void>
/** /**
@@ -57,21 +73,22 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>() const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> => const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe( Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() => Effect.flatMap(() =>
Effect.suspend(() => { Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.pendingWake = false execution.request = execution.pendingWake
execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack. // Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false))) return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}), }),
), ),
) )
const start = (key: Key, force: boolean) => { const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = { const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(), done: Deferred.makeUnsafe<void, E>(),
pendingWake: false, request,
stopping: false, stopping: false,
} }
executions.set(key, execution) executions.set(key, execution)
@@ -87,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)), }).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
), ),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))), Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit, Effect.exit,
Effect.asVoid, Effect.asVoid,
), ),
@@ -97,12 +114,22 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or // A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work. // during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => { const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
if (execution.pendingWake) start(key, false) if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key) else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit) Deferred.doneUnsafe(execution.done, exit)
} }
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
return execution.continuation.when.pipe(
Effect.flatMap((ready) =>
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
),
)
}
const run = (key: Key): Effect.Effect<void, E> => const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
@@ -111,26 +138,58 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key))) if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done) return Deferred.await(execution.done)
} }
return Deferred.await(start(key, true).done) return Deferred.await(start(key, true, "input").done)
}) })
const wake = (key: Key) => const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => { Effect.sync(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution !== undefined) { if (execution !== undefined) {
execution.pendingWake = true if (execution.stopping) {
if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
// Coalesced wakes keep the widest request: "input" subsumes "steer".
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
return return
} }
start(key, false) start(key, false, request)
}) })
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> => const wakeActive = (key: Key) =>
Effect.suspend(() => { Effect.suspend(() => {
const execution = executions.get(key) const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
execution.stopping = true execution.stopping = true
execution.pendingWake = false execution.pendingWake = undefined
execution.interruptionReason = reason execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner) return Fiber.interrupt(execution.owner)
}) })
@@ -143,5 +202,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key))) return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
}) })
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle } return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
}) })
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai" import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect" import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js" import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js" import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js" import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js" import type { Instructions } from "../../instructions/index.js"
@@ -29,6 +30,8 @@ export interface Interface {
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
}) => Effect.Effect<DrainResult, RunError> }) => Effect.Effect<DrainResult, RunError>
} }
+16 -14
View File
@@ -128,22 +128,25 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID readonly sessionID: SessionSchema.ID
readonly force: boolean readonly force: boolean
readonly continuation?: Continuation readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) { }) {
let force = input.force let force = input.force
let continuation = input.continuation let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any"))) const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID) yield* settleStaleToolCalls(input.sessionID)
while (true) { while (true) {
if (yield* runPendingCompaction(input.sessionID)) { if (yield* runPendingCompaction(input.sessionID, promotable)) {
force = false force = false
continue continue
} }
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const } if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input"))) if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const } return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation) const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false force = false
continuation = undefined continuation = undefined
} }
@@ -155,14 +158,15 @@ const layer = Layer.effect(
*/ */
const runSteps = Effect.fn("SessionRunner.runSteps")(function* ( const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
continuation?: Continuation, continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) { ) {
// Fresh work may promote queued input; later steps absorb steers only. // Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input" let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1 let step = continuation?.step ?? 1
let next = continuation let next = continuation
while (true) { while (true) {
if (yield* runPendingCompaction(sessionID)) continue if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next } if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step) const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -515,14 +519,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */ /** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* ( const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID, sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) { ) {
return yield* Effect.uninterruptibleMask((restore) => return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () { Effect.gen(function* () {
const pending = yield* SessionInbox.serialized( const pending = yield* SessionInbox.serialized(
sessionID, sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const selected = const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return if (selected?.type !== "compaction") return
yield* bus.publishAll([ yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }], [SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -564,9 +568,7 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized( return yield* SessionInbox.serialized(
sessionID, sessionID,
Effect.gen(function* () { Effect.gen(function* () {
const pending = const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
if (pending?.type !== "move") return false if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID) yield* modelTransport.close(sessionID)
yield* bus.publishAll([ yield* bus.publishAll([
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai" import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect" import { Clock, Effect } from "effect"
import { Bus } from "../../bus.js" import { Bus } from "../../bus.js"
import { Model } from "../../model.js" import { Model } from "../../model.js"
import { SessionEvent } from "../event.js" import { SessionEvent } from "../event.js"
@@ -81,6 +81,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* and consumers fold by id/ordinal rather than global position. * and consumers fold by id/ordinal rather than global position.
*/ */
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => { export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
const tools = new Map< const tools = new Map<
string, string,
{ {
@@ -123,32 +124,56 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const fragments = ( const fragments = (
name: string, name: string,
ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>, ended: (id: string, value: string, ordinal: number, state?: Record<string, unknown>) => Effect.Effect<void>,
delta?: (id: string, value: string, ordinal: number) => Effect.Effect<void>,
single = false, single = false,
) => { ) => {
const chunks = new Map< type Fragment = {
string, readonly ordinal: number
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> } readonly values: string[]
>() pending: string
publishedAt?: number
state?: Record<string, unknown>
}
const chunks = new Map<string, Fragment>()
let nextOrdinal = 0 let nextOrdinal = 0
const start = (id: string, state?: Record<string, unknown>) => const start = (id: string, state?: Record<string, unknown>) =>
Effect.suspend(() => { Effect.suspend(() => {
if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`)) if (chunks.has(id)) return Effect.die(new Error(`Duplicate ${name} start: ${id}`))
if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`)) if (single && chunks.size > 0) return Effect.die(new Error(`${name} start before end: ${id}`))
const ordinal = nextOrdinal++ const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [], state }) chunks.set(id, { ordinal, values: [], pending: "", state })
return Effect.succeed(ordinal) return Effect.succeed(ordinal)
}) })
const append = (id: string, value: string, state?: Record<string, unknown>) => const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
Effect.suspend(() => { if (!delta) return undefined
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value) if (!current.pending) return undefined
if (state !== undefined) current.state = { ...current.state, ...state } const now = yield* Clock.currentTimeMillis
return Effect.succeed(current.ordinal) if (!force && current.publishedAt === undefined) {
}) current.publishedAt = now
return undefined
}
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
return undefined
yield* delta(id, current.pending, current.ordinal)
current.pending = ""
current.publishedAt = now
return undefined
})
const append = Effect.fnUntraced(function* (id: string, value: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value)
if (delta) current.pending += value
if (state !== undefined) current.state = { ...current.state, ...state }
yield* publishDelta(id)
return current.ordinal
})
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) { const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
const current = chunks.get(id) const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`)) if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* publishDelta(id, true)
yield* ended( yield* ended(
id, id,
value ?? current.values.join(""), value ?? current.values.join(""),
@@ -156,9 +181,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state === undefined ? current.state : { ...current.state, ...state }, state === undefined ? current.state : { ...current.state, ...state },
) )
chunks.delete(id) chunks.delete(id)
return undefined
}) })
const flush = Effect.fnUntraced(function* () { const flush = Effect.fnUntraced(function* () {
for (const id of chunks.keys()) yield* end(id) for (const id of Array.from(chunks.keys())) yield* end(id)
}) })
return { start, append, end, flush, has: (id: string) => chunks.has(id) } return { start, append, end, flush, has: (id: string) => chunks.has(id) }
} }
@@ -175,6 +201,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state, state,
}) })
}), }),
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true, true,
) )
const reasoning = fragments( const reasoning = fragments(
@@ -189,6 +224,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state, state,
}) })
}), }),
(_reasoningID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true, true,
) )
const toolInput = fragments("tool input", (id, value) => const toolInput = fragments("tool input", (id, value) =>
@@ -351,13 +395,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}) })
return return
case "text-delta": case "text-delta":
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata)) yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaTextOrdinal,
delta: event.text,
})
return return
case "text-end": case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata)) yield* text.end(event.id, providerState(event.providerMetadata))
@@ -373,17 +411,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}) })
return return
case "reasoning-delta": case "reasoning-delta":
const deltaReasoningOrdinal = yield* reasoning.append( yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
event.id,
event.text,
providerState(event.providerMetadata),
)
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaReasoningOrdinal,
delta: event.text,
})
return return
case "reasoning-end": case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata)) yield* reasoning.end(event.id, providerState(event.providerMetadata))
@@ -399,12 +427,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`)) return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`)) if (!toolInput.has(event.id)) return yield* Effect.die(new Error(`Tool input delta after end: ${event.id}`))
yield* toolInput.append(event.id, event.text) yield* toolInput.append(event.id, event.text)
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
delta: event.text,
})
return return
} }
case "tool-input-end": case "tool-input-end":
+2
View File
@@ -56,6 +56,8 @@ export const SessionTable = sqliteTable(
variant?: string variant?: string
}>(), }>(),
...Timestamps, ...Timestamps,
time_idle: integer(),
time_viewed: integer(),
time_compacting: integer(), time_compacting: integer(),
time_archived: integer(), time_archived: integer(),
/** The execution claim timestamp (historical column name; see SessionStore.claim). */ /** The execution claim timestamp (historical column name; see SessionStore.claim). */
+4
View File
@@ -117,6 +117,10 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write, tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created), time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: DateTime.toEpochMillis(input.data.info.time.updated), time_updated: DateTime.toEpochMillis(input.data.info.time.updated),
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed: input.data.info.time.viewed
? DateTime.toEpochMillis(input.data.info.time.viewed)
: null,
time_archived: input.data.info.time.archived time_archived: input.data.info.time.archived
? DateTime.toEpochMillis(input.data.info.time.archived) ? DateTime.toEpochMillis(input.data.info.time.archived)
: null, : null,
+3 -3
View File
@@ -16,7 +16,7 @@ import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js" import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js" import { Formatter } from "../../formatter.js"
import { Location } from "../../location.js" import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js" import { fileDiff } from "./file-diff.js"
@@ -110,7 +110,7 @@ const findLineOccurrences = (content: string, search: string) => {
export const Plugin = { export const Plugin = {
id: "opencode.tool.edit", id: "opencode.tool.edit",
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service const environment = yield* Environment.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
@@ -148,7 +148,7 @@ export const Plugin = {
const external = target.externalDirectory const external = target.externalDirectory
if (external) { if (external) {
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(external), ...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source: permissionSource, source: permissionSource,
+3 -3
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js" import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js" import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js" import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Ripgrep } from "../../ripgrep.js" import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js" import { RelativePath } from "../../schema.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
@@ -45,7 +45,7 @@ export const Plugin = {
const environment = yield* Environment.Service const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -64,7 +64,7 @@ export const Plugin = {
const external = target.externalDirectory const external = target.externalDirectory
if (external) if (external)
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(external), ...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
+3 -3
View File
@@ -7,7 +7,7 @@ import path from "path"
import { Environment } from "../../environment/index.js" import { Environment } from "../../environment/index.js"
import { FileSystem } from "../../filesystem.js" import { FileSystem } from "../../filesystem.js"
import { Location } from "../../location.js" import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { Ripgrep } from "../../ripgrep.js" import { Ripgrep } from "../../ripgrep.js"
import { RelativePath } from "../../schema.js" import { RelativePath } from "../../schema.js"
@@ -61,7 +61,7 @@ export const Plugin = {
const environment = yield* Environment.Service const environment = yield* Environment.Service
const ripgrep = yield* Ripgrep.Service const ripgrep = yield* Ripgrep.Service
const location = yield* Location.Service const location = yield* Location.Service
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
yield* ctx.tool yield* ctx.tool
@@ -79,7 +79,7 @@ export const Plugin = {
const target = yield* mutation.resolve({ path: input.path ?? "." }) const target = yield* mutation.resolve({ path: input.path ?? "." })
if (target.externalDirectory) if (target.externalDirectory)
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(target.externalDirectory), ...LocationMutation.externalDirectoryPermission(target.externalDirectory),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
+7 -7
View File
@@ -10,7 +10,7 @@ import { Environment } from "../../environment/index.js"
import { Formatter } from "../../formatter.js" import { Formatter } from "../../formatter.js"
import { FileMutation } from "../../file-mutation.js" import { FileMutation } from "../../file-mutation.js"
import { Location } from "../../location.js" import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Patch } from "@opencode-ai/util/patch" import { Patch } from "@opencode-ai/util/patch"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import DESCRIPTION from "../patch.txt" import DESCRIPTION from "../patch.txt"
@@ -46,29 +46,29 @@ export const toModelOutput = (output: Output) =>
type Prepared = type Prepared =
| (Extract<Patch.Hunk, { readonly type: "add" }> & { | (Extract<Patch.Hunk, { readonly type: "add" }> & {
readonly target: LocationPath.Target readonly target: LocationMutation.Target
readonly content: string readonly content: string
readonly before: string readonly before: string
readonly after: string readonly after: string
}) })
| (Extract<Patch.Hunk, { readonly type: "delete" }> & { | (Extract<Patch.Hunk, { readonly type: "delete" }> & {
readonly target: LocationPath.Target readonly target: LocationMutation.Target
readonly before: string readonly before: string
readonly after: string readonly after: string
}) })
| (Extract<Patch.Hunk, { readonly type: "update" }> & { | (Extract<Patch.Hunk, { readonly type: "update" }> & {
readonly target: LocationPath.Target readonly target: LocationMutation.Target
readonly content: string readonly content: string
readonly before: string readonly before: string
readonly after: string readonly after: string
readonly moveTarget?: LocationPath.Target readonly moveTarget?: LocationMutation.Target
}) })
export const Plugin = { export const Plugin = {
id: "opencode.tool.patch", id: "opencode.tool.patch",
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
const environment = yield* Environment.Service const environment = yield* Environment.Service
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service const fileMutation = yield* FileMutation.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
const location = yield* Location.Service const location = yield* Location.Service
@@ -116,7 +116,7 @@ export const Plugin = {
const target = yield* mutation.resolve({ path: value, kind: "file" }) const target = yield* mutation.resolve({ path: value, kind: "file" })
if (!target.externalDirectory) return target if (!target.externalDirectory) return target
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(target.externalDirectory), ...LocationMutation.externalDirectoryPermission(target.externalDirectory),
metadata: { metadata: {
filepath: target.absolute, filepath: target.absolute,
parentDir: target.externalDirectory.directory, parentDir: target.externalDirectory.directory,
+3 -3
View File
@@ -6,7 +6,7 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "../../location.js" import { Location } from "../../location.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { SessionInstructions } from "../../session/instructions.js" import { SessionInstructions } from "../../session/instructions.js"
import { AbsolutePath } from "../../schema.js" import { AbsolutePath } from "../../schema.js"
@@ -32,7 +32,7 @@ export const Plugin = {
id: "opencode.tool.read", id: "opencode.tool.read",
effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("ReadTool.Plugin")(function* (ctx: PluginContext) {
const reader = yield* ReadToolFileSystem.Service const reader = yield* ReadToolFileSystem.Service
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const sessionInstructions = yield* SessionInstructions.Service const sessionInstructions = yield* SessionInstructions.Service
const fs = yield* FSUtil.Service const fs = yield* FSUtil.Service
@@ -58,7 +58,7 @@ export const Plugin = {
const external = target.externalDirectory const external = target.externalDirectory
if (external) if (external)
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(external), ...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
+2 -2
View File
@@ -7,7 +7,7 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { Deferred, Effect, Schema, Scope } from "effect" import { Deferred, Effect, Schema, Scope } from "effect"
import { Config } from "../../config.js" import { Config } from "../../config.js"
import { Environment } from "../../environment/index.js" import { Environment } from "../../environment/index.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { PluginRuntime } from "../../plugin/runtime.js" import { PluginRuntime } from "../../plugin/runtime.js"
import { NonNegativeInt } from "../../schema.js" import { NonNegativeInt } from "../../schema.js"
@@ -84,7 +84,7 @@ export const Plugin = {
const runtime = yield* PluginRuntime.Service const runtime = yield* PluginRuntime.Service
const scope = yield* Scope.Scope const scope = yield* Scope.Scope
const environment = yield* Environment.Service const environment = yield* Environment.Service
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const shell = yield* Shell.Service const shell = yield* Shell.Service
const permission = yield* Permission.Service const permission = yield* Permission.Service
const config = yield* Config.Service const config = yield* Config.Service
+1 -3
View File
@@ -181,9 +181,7 @@ export const Plugin = {
) )
const background = input.background === true const background = input.background === true
yield* context.progress({ yield* context.progress({ sessionID: child.id, status: "running" })
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () { const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input. // The child session owns its agent/model (set at create); prompt only admits input.
+3 -3
View File
@@ -13,7 +13,7 @@ import { Bom } from "@opencode-ai/util/bom"
import { Environment } from "../../environment/index.js" import { Environment } from "../../environment/index.js"
import { FileMutation } from "../../file-mutation.js" import { FileMutation } from "../../file-mutation.js"
import { Formatter } from "../../formatter.js" import { Formatter } from "../../formatter.js"
import { LocationPath } from "../../location-path.js" import { LocationMutation } from "../../location-mutation.js"
import { Permission } from "../../permission.js" import { Permission } from "../../permission.js"
import { fileDiff } from "./file-diff.js" import { fileDiff } from "./file-diff.js"
@@ -46,7 +46,7 @@ export const toModelOutput = (output: Output) =>
export const Plugin = { export const Plugin = {
id: "opencode.tool.write", id: "opencode.tool.write",
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) { effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const fileMutation = yield* FileMutation.Service const fileMutation = yield* FileMutation.Service
const environment = yield* Environment.Service const environment = yield* Environment.Service
const formatter = yield* Formatter.Service const formatter = yield* Formatter.Service
@@ -72,7 +72,7 @@ export const Plugin = {
const external = target.externalDirectory const external = target.externalDirectory
if (external) if (external)
yield* permission.assert({ yield* permission.assert({
...LocationPath.externalDirectoryPermission(external), ...LocationMutation.externalDirectoryPermission(external),
sessionID: context.sessionID, sessionID: context.sessionID,
agent: context.agent, agent: context.agent,
source, source,
+25 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus" import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session" import { Session } from "@opencode-ai/schema/session"
@@ -422,7 +422,7 @@ describe("Bus", () => {
const { db } = yield* Database.Service const { db } = yield* Database.Service
const aggregateID = Event.ID.create() const aggregateID = Event.ID.create()
yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" }) const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
const rows = yield* db const rows = yield* db
.select() .select()
.from(EventTable) .from(EventTable)
@@ -433,6 +433,7 @@ describe("Bus", () => {
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1)) expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1))
expect(rows[0]?.aggregate_id).toBe(aggregateID) expect(rows[0]?.aggregate_id).toBe(aggregateID)
expect(rows[0]?.created).toBe(event.created)
}), }),
) )
@@ -706,7 +707,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -726,7 +727,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -763,7 +764,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID: envelopeAggregateID, aggregateID: envelopeAggregateID,
@@ -797,7 +798,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -806,7 +807,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 5, seq: 5,
aggregateID, aggregateID,
@@ -831,14 +832,14 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2), type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
seq: 0, seq: 0,
aggregateID, aggregateID,
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } }, data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
}) })
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0)) expect(received[0]?.created).toBe(0)
}), }),
) )
@@ -867,7 +868,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: "unknown.event.1", type: "unknown.event.1",
seq: 0, seq: 0,
aggregateID: Event.ID.create(), aggregateID: Event.ID.create(),
@@ -895,7 +896,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -915,7 +916,7 @@ describe("Bus", () => {
const id = Event.ID.create() const id = Event.ID.create()
const replayed = { const replayed = {
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -972,7 +973,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1001,7 +1002,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1012,7 +1013,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 2, seq: 2,
aggregateID, aggregateID,
@@ -1045,7 +1046,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1058,7 +1059,7 @@ describe("Bus", () => {
.replay( .replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1080,7 +1081,7 @@ describe("Bus", () => {
yield* bus.listen((event) => Effect.sync(() => received.push(event))) yield* bus.listen((event) => Effect.sync(() => received.push(event)))
const replayed = { const replayed = {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1101,7 +1102,7 @@ describe("Bus", () => {
const aggregateID = Session.ID.create() const aggregateID = Session.ID.create()
const replayed = { const replayed = {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1126,7 +1127,7 @@ describe("Bus", () => {
const id = Event.ID.create() const id = Event.ID.create()
yield* bus.replay({ yield* bus.replay({
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1136,7 +1137,7 @@ describe("Bus", () => {
const exit = yield* bus const exit = yield* bus
.replay({ .replay({
id, id,
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1159,7 +1160,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
@@ -1170,7 +1171,7 @@ describe("Bus", () => {
yield* bus.replay( yield* bus.replay(
{ {
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 1, seq: 1,
aggregateID, aggregateID,
@@ -1232,7 +1233,7 @@ describe("Bus", () => {
yield* bus.replay({ yield* bus.replay({
id: Event.ID.create(), id: Event.ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Bus.versionedType(DurableMessage.type, 1), type: Bus.versionedType(DurableMessage.type, 1),
seq: 0, seq: 0,
aggregateID, aggregateID,
+30 -1
View File
@@ -102,6 +102,35 @@ describe("Catalog", () => {
}).pipe(Effect.provide(localCatalogLayer)) }).pipe(Effect.provide(localCatalogLayer))
}) })
it.effect("makes an explicitly enabled provider available without a connection", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = Provider.ID.make("remote")
const localCatalogLayer = Layer.fresh(
AppNodeBuilder.build(LayerNode.group([Catalog.node, Credential.node, Integration.node]), [
[Location.node, locationLayer],
]),
)
return Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* (yield* Integration.Service).transform((editor) => editor.update(integrationID, () => {}))
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.integrationID = integrationID
provider.settings = { baseURL: "https://gateway.example.com/v1" }
}),
)
expect(yield* catalog.provider.available()).toEqual([])
yield* catalog.transform((editor) =>
editor.provider.update(providerID, (provider) => {
provider.activation = "enabled"
}),
)
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([providerID])
}).pipe(Effect.provide(localCatalogLayer))
})
it.effect("projects environment connections without a catalog plugin", () => it.effect("projects environment connections without a catalog plugin", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.sync(() => { Effect.sync(() => {
@@ -278,7 +307,7 @@ describe("Catalog", () => {
const fallbackModel = Model.ID.make("fallback") const fallbackModel = Model.ID.make("fallback")
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => { catalog.provider.update(disabledProvider, (provider) => {
provider.disabled = true provider.activation = "disabled"
}) })
catalog.model.update(disabledProvider, disabledModel, () => {}) catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, () => {}) catalog.provider.update(enabledProvider, () => {})
+1 -1
View File
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
names: ["CUSTOM_API_KEY"], names: ["CUSTOM_API_KEY"],
}) })
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined() expect(provider.activation).toBe("enabled")
expect(provider.package).toBe("aisdk:custom-sdk") expect(provider.package).toBe("aisdk:custom-sdk")
expect(provider.settings).toEqual({ baseURL: "https://example.test" }) expect(provider.settings).toEqual({ baseURL: "https://example.test" })
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" }) expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
@@ -13,6 +13,7 @@ import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient" import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree" import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260815182818_session_viewed_state"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
const run = <A, E>( const run = <A, E>(
@@ -73,6 +74,27 @@ describe("DatabaseMigration", () => {
) )
}) })
test("adds nullable attention state to existing sessions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, title text)`)
yield* db.run(sql`INSERT INTO session_v2 (id, title) VALUES ('ses_existing', 'Existing')`)
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
yield* DatabaseMigration.applyOnly(db, [sessionViewedStateMigration])
expect(yield* db.get(sql`SELECT id, title, time_idle, time_viewed FROM session_v2`)).toEqual({
id: "ses_existing",
title: "Existing",
time_idle: null,
time_viewed: null,
})
expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: 1 })
}),
)
})
test("rejects a non-empty database without a session table", async () => { test("rejects a non-empty database without a session table", async () => {
await expect( await expect(
run( run(
+9 -9
View File
@@ -7,7 +7,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Environment } from "@opencode-ai/core/environment/index" import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment" import { type EnvironmentFilesTransform, transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location" import { location } from "./fixture/location"
@@ -20,7 +20,7 @@ function provide(directory: string, transformFiles: EnvironmentFilesTransform =
Location.Service.of(location({ directory: AbsolutePath.make(directory) })), Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
) )
return Effect.provide( return Effect.provide(
AppNodeBuilder.build(LayerNode.group([LocationPath.node, FileMutation.node]), [ AppNodeBuilder.build(LayerNode.group([LocationMutation.node, FileMutation.node]), [
[Location.node, activeLocation], [Location.node, activeLocation],
[Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)], [Environment.node, transformEnvironmentFiles(activeLocation, transformFiles)],
]), ]),
@@ -40,7 +40,7 @@ describe("FileMutation", () => {
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt") const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "before")) yield* Effect.promise(() => fs.writeFile(targetPath, "before"))
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({ expect(yield* (yield* FileMutation.Service).write({ target, content: "after" })).toEqual({
operation: "write", operation: "write",
@@ -56,7 +56,7 @@ describe("FileMutation", () => {
it.live("writes a prospective internal file and creates parent directories", () => it.live("writes a prospective internal file and creates parent directories", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({ const target = yield* (yield* LocationMutation.Service).resolve({
path: path.join("src", "nested", "hello.txt"), path: path.join("src", "nested", "hello.txt"),
}) })
const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" }) const result = yield* (yield* FileMutation.Service).write({ target, content: "hello" })
@@ -77,8 +77,8 @@ describe("FileMutation", () => {
Effect.gen(function* () { Effect.gen(function* () {
const preservedPath = path.join(directory, "preserved.txt") const preservedPath = path.join(directory, "preserved.txt")
yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore")) yield* Effect.promise(() => fs.writeFile(preservedPath, "\uFEFFbefore"))
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" }) const preserved = yield* (yield* LocationMutation.Service).resolve({ path: "preserved.txt" })
const created = yield* (yield* LocationPath.Service).resolve({ path: "created.txt" }) const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" }) yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
@@ -95,7 +95,7 @@ describe("FileMutation", () => {
withTmp((outside) => withTmp((outside) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "external.txt") const targetPath = path.join(outside, "external.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const result = yield* (yield* FileMutation.Service).write({ target, content: "external" }) const result = yield* (yield* FileMutation.Service).write({ target, content: "external" })
expect(result).toEqual({ expect(result).toEqual({
@@ -133,7 +133,7 @@ describe("FileMutation", () => {
) )
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "shared.txt" }) const firstPlan = yield* mutation.resolve({ path: "shared.txt" })
const secondPlan = yield* mutation.resolve({ path: "shared.txt" }) const secondPlan = yield* mutation.resolve({ path: "shared.txt" })
@@ -222,7 +222,7 @@ describe("FileMutation", () => {
) )
yield* Effect.gen(function* () { yield* Effect.gen(function* () {
const mutation = yield* LocationPath.Service const mutation = yield* LocationMutation.Service
const files = yield* FileMutation.Service const files = yield* FileMutation.Service
const firstPlan = yield* mutation.resolve({ path: "first.txt" }) const firstPlan = yield* mutation.resolve({ path: "first.txt" })
const secondPlan = yield* mutation.resolve({ path: "second.txt" }) const secondPlan = yield* mutation.resolve({ path: "second.txt" })
@@ -4,26 +4,18 @@ import { describe, expect, test } from "bun:test"
import { Effect, Layer, Schema } from "effect" import { Effect, Layer, Schema } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { tmpdir } from "./fixture/tmpdir" import { tmpdir } from "./fixture/tmpdir"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { it } from "./lib/effect" import { it } from "./lib/effect"
function provide(directory: string, projectDirectory = directory) { function provide(directory: string) {
return Effect.provide( return Effect.provide(
LayerNode.compile(LocationPath.node, [ LayerNode.compile(LocationMutation.node, [
[ [
Location.node, Location.node,
Layer.succeed( Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
Location.Service,
Location.Service.of(
location(
{ directory: AbsolutePath.make(directory) },
{ projectDirectory: AbsolutePath.make(projectDirectory) },
),
),
),
], ],
]), ]),
) )
@@ -36,13 +28,13 @@ function withTmp<A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) {
).pipe(Effect.flatMap((tmp) => f(tmp.path))) ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
} }
describe("LocationPath", () => { describe("LocationMutation", () => {
it.live("resolves an active relative existing file target", () => it.live("resolves an active relative existing file target", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(directory, "hello.txt") const targetPath = path.join(directory, "hello.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "hello")) yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: "hello.txt" })
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: targetPath, absolute: targetPath,
@@ -57,7 +49,7 @@ describe("LocationPath", () => {
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) yield* Effect.promise(() => fs.mkdir(path.join(directory, "src")))
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("src", "new.txt") }) const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: path.join(directory, "src", "new.txt"), absolute: path.join(directory, "src", "new.txt"),
resource: "src/new.txt", resource: "src/new.txt",
@@ -66,43 +58,10 @@ describe("LocationPath", () => {
), ),
) )
it.live("does not require external authorization inside the project but outside the active directory", () =>
withTmp((project) =>
Effect.gen(function* () {
const directory = path.join(project, "packages", "app")
const targetPath = path.join(project, "README.md")
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() => fs.writeFile(targetPath, "hello"))
const locationPath = yield* LocationPath.Service
const target = yield* locationPath.resolve({ path: targetPath })
expect(target).toMatchObject({
absolute: targetPath,
resource: "../../README.md",
})
expect(target.externalDirectory).toBeUndefined()
}).pipe(provide(path.join(project, "packages", "app"), project)),
),
)
it.live("does not treat a filesystem-root project fallback as internal", () =>
withTmp((directory) =>
withTmp((outside) =>
Effect.gen(function* () {
const locationPath = yield* LocationPath.Service
const target = yield* locationPath.resolve({ path: path.join(outside, "target.txt") })
expect(target.externalDirectory).toBeDefined()
}).pipe(provide(directory, path.parse(directory).root)),
),
),
)
it.live("requires external-directory authorization for a relative lexical escape", () => it.live("requires external-directory authorization for a relative lexical escape", () =>
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({ path: "../outside.txt" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: "../outside.txt" })
const root = path.dirname(directory) const root = path.dirname(directory)
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: path.join(root, "outside.txt"), absolute: path.join(root, "outside.txt"),
@@ -125,7 +84,7 @@ describe("LocationPath", () => {
await fs.mkdir(outside) await fs.mkdir(outside)
await fs.symlink(outside, path.join(directory, "escape")) await fs.symlink(outside, path.join(directory, "escape"))
}) })
const target = yield* (yield* LocationPath.Service).resolve({ path: path.join("escape", "new.txt") }) const target = yield* (yield* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: path.join(directory, "escape", "new.txt"), absolute: path.join(directory, "escape", "new.txt"),
resource: "escape/new.txt", resource: "escape/new.txt",
@@ -145,7 +104,7 @@ describe("LocationPath", () => {
await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked")) await fs.symlink(path.join(directory, "actual"), path.join(directory, "linked"))
}) })
expect(yield* (yield* LocationPath.Service).resolve({ path: "linked/new.txt" })).toMatchObject({ expect(yield* (yield* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
absolute: path.join(directory, "linked", "new.txt"), absolute: path.join(directory, "linked", "new.txt"),
resource: "linked/new.txt", resource: "linked/new.txt",
}) })
@@ -157,7 +116,7 @@ describe("LocationPath", () => {
withTmp((directory) => withTmp((directory) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(directory, "new.txt") const targetPath = path.join(directory, "new.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: targetPath, absolute: targetPath,
resource: "new.txt", resource: "new.txt",
@@ -172,7 +131,7 @@ describe("LocationPath", () => {
withTmp((outside) => withTmp((outside) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "new.txt") const targetPath = path.join(outside, "new.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const root = outside const root = outside
expect(target).toMatchObject({ expect(target).toMatchObject({
absolute: path.join(root, "new.txt"), absolute: path.join(root, "new.txt"),
@@ -193,7 +152,7 @@ describe("LocationPath", () => {
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "existing.txt") const targetPath = path.join(outside, "existing.txt")
yield* Effect.promise(() => fs.writeFile(targetPath, "existing")) yield* Effect.promise(() => fs.writeFile(targetPath, "existing"))
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
expect(target).toMatchObject({ absolute: targetPath }) expect(target).toMatchObject({ absolute: targetPath })
expect(target.externalDirectory?.directory).toBe(outside) expect(target.externalDirectory?.directory).toBe(outside)
}).pipe(provide(directory)), }).pipe(provide(directory)),
@@ -205,7 +164,7 @@ describe("LocationPath", () => {
withTmp((directory) => withTmp((directory) =>
withTmp((outside) => withTmp((outside) =>
Effect.gen(function* () { Effect.gen(function* () {
const target = yield* (yield* LocationPath.Service).resolve({ path: outside, kind: "file" }) const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
expect(target.externalDirectory).toMatchObject({ expect(target.externalDirectory).toMatchObject({
directory: path.dirname(outside), directory: path.dirname(outside),
resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"), resource: path.join(path.dirname(outside), "*").replaceAll("\\", "/"),
@@ -220,7 +179,7 @@ describe("LocationPath", () => {
withTmp((outside) => withTmp((outside) =>
Effect.gen(function* () { Effect.gen(function* () {
const targetPath = path.join(outside, "new", "nested", "file.txt") const targetPath = path.join(outside, "new", "nested", "file.txt")
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath }) const target = yield* (yield* LocationMutation.Service).resolve({ path: targetPath })
const parent = path.dirname(targetPath) const parent = path.dirname(targetPath)
expect(target.externalDirectory).toMatchObject({ expect(target.externalDirectory).toMatchObject({
directory: parent, directory: parent,
@@ -231,9 +190,9 @@ describe("LocationPath", () => {
), ),
) )
test("ignores unknown path input fields", () => { test("ignores unknown mutation input fields", () => {
expect(Object.keys(LocationPath.ResolveInput.fields)).toEqual(["path", "kind"]) expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
expect(Schema.decodeUnknownSync(LocationPath.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({ expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
path: "README.md", path: "README.md",
}) })
}) })
+1 -1
View File
@@ -1002,7 +1002,7 @@ test("reconciles only changed MCP server config", async () => {
const publishUpdate = () => const publishUpdate = () =>
PubSub.publish(updates, { PubSub.publish(updates, {
id: ID.create(), id: ID.create(),
created: DateTime.makeUnsafe(0), created: 0,
type: Event.Updated.type, type: Event.Updated.type,
data: {}, data: {},
} satisfies Payload<typeof Event.Updated>) } satisfies Payload<typeof Event.Updated>)
+107 -1
View File
@@ -2,13 +2,16 @@ import { describe, expect } from "bun:test"
import { LLM, LanguageModel } from "@opencode-ai/ai" import { LLM, LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols" import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client" import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential" import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration" import { Integration } from "@opencode-ai/core/integration"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model" import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { ModelResolver } from "@opencode-ai/core/model-resolver" import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { Catalog } from "@opencode-ai/core/catalog"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Npm } from "@opencode-ai/util/npm"
import { it } from "./lib/effect" import { it } from "./lib/effect"
interface ModelOptions { interface ModelOptions {
@@ -269,6 +272,109 @@ describe("ModelResolver", () => {
}), }),
) )
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
const selected = model(Provider.aisdk("@ai-sdk/google"), {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://gateway.example.com/v1" },
headers: { "cf-access-token": "access-token" },
})
const provider = Provider.Info.make({
...Provider.Info.empty(selected.providerID),
activation: "enabled",
package: selected.package ?? "",
settings: selected.settings,
headers: selected.headers,
})
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(provider),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.die("unused"),
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(selected)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: "https://gateway.example.com/v1",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
})
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example.com/v1" },
}),
)
const exit = yield* Effect.exit(
resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://google.example.com/v1",
body: "{}",
headers: Headers.empty,
}),
)
expect(exit._tag).toBe("Failure")
}),
),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () => it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel( const resolved = yield* ModelResolver.fromCatalogModel(
+2
View File
@@ -47,6 +47,7 @@ const fixtureSnapshot = [
info: { info: {
id: Provider.ID.make("acme"), id: Provider.ID.make("acme"),
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
models: [ models: [
@@ -109,6 +110,7 @@ const fixture2Snapshot = [
info: { info: {
id: Provider.ID.make("beta"), id: Provider.ID.make("beta"),
name: "Beta", name: "Beta",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
models: [ models: [
@@ -64,6 +64,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" }, settings: { baseURL: "https://api.acme.test/v1" },
}, },
@@ -239,6 +240,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
}, },
environment: [], environment: [],
@@ -330,6 +332,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: providerID, id: providerID,
name: "Acme", name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"), package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" }, settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
}, },
@@ -385,6 +388,7 @@ describe("ModelsDevPlugin", () => {
info: { info: {
id: Provider.ID.make(id), id: Provider.ID.make(id),
name, name,
activation: "auto",
package: Provider.aisdk(packageName), package: Provider.aisdk(packageName),
}, },
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment], environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
}) })
yield* catalog.transform((catalog) => { yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => { catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
provider.disabled = true provider.activation = "disabled"
provider.package = Provider.aisdk("@ai-sdk/openai-compatible") provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.llmgateway.io/v1" } provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
}) })
}) })
yield* addPlugin() yield* addPlugin()
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true) expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined() expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
}), }),
) )
@@ -347,6 +347,8 @@ describe("OpencodePlugin", () => {
}) })
yield* addPlugin() yield* addPlugin()
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public") expect(required(yield* catalog.provider.get(Provider.ID.opencode)).settings?.apiKey).toBe("public")
expect(required(yield* catalog.provider.get(Provider.ID.opencode)).activation).toBe("enabled")
expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true) expect(required(yield* catalog.model.get(Provider.ID.opencode, Model.ID.make("free"))).enabled).toBe(true)
}), }),
), ),
+17 -4
View File
@@ -599,7 +599,7 @@ describe("Session.create", () => {
.all() .all()
.pipe(Effect.orDie)).map((event) => ({ .pipe(Effect.orDie)).map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -840,7 +840,15 @@ describe("SessionTransfer", () => {
const imported = yield* transfer.import({ const imported = yield* transfer.import({
data: { data: {
info: { ...template, id: sessionID }, info: {
...template,
id: sessionID,
time: {
...template.time,
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
},
messages: [ messages: [
{ {
id: sourceMessageID, id: sourceMessageID,
@@ -863,13 +871,18 @@ describe("SessionTransfer", () => {
const messages = yield* session.messages({ sessionID, order: "asc" }) const messages = yield* session.messages({ sessionID, order: "asc" })
expect(imported).toMatchObject({ id: sessionID, title: "Exported", location }) expect(imported).toMatchObject({ id: sessionID, title: "Exported", location })
expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(messages).toMatchObject([ expect(messages).toMatchObject([
{ id: sourceMessageID, type: "user", text: "Imported message" }, { id: sourceMessageID, type: "user", text: "Imported message" },
{ id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } }, { id: errorMessageID, type: "compaction", error: { type: "test_error", message: "Original error" } },
]) ])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2) expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages) const exported = yield* transfer.export({ sessionID })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([ expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(sanitized.messages).toMatchObject([
{ id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` }, { id: sourceMessageID, text: `[redacted:text:${sourceMessageID}]` },
{ id: errorMessageID, error: { type: "test_error", message: "Original error" } }, { id: errorMessageID, error: { type: "test_error", message: "Original error" } },
]) ])
@@ -13,7 +13,7 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Image } from "@opencode-ai/core/image" import { Image } from "@opencode-ai/core/image"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
@@ -43,7 +43,7 @@ const readToolNode = makeLocationNode({
deps: [ deps: [
Tool.node, Tool.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
LocationPath.node, LocationMutation.node,
Image.node, Image.node,
Permission.node, Permission.node,
SessionInstructions.node, SessionInstructions.node,
@@ -65,7 +65,7 @@ const testLayer = AppNodeBuilder.build(
Session.node, Session.node,
Location.node, Location.node,
FSUtil.node, FSUtil.node,
LocationPath.node, LocationMutation.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
readToolNode, readToolNode,
Tool.node, Tool.node,
+8 -18
View File
@@ -31,6 +31,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = [] const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = [] const interruptCalls: Session.ID[] = []
const interruptContinuations: Array<boolean | undefined> = []
const wakeCalls: Session.ID[] = [] const wakeCalls: Session.ID[] = []
const activeSessions = new Set<Session.ID>() const activeSessions = new Set<Session.ID>()
const execution = Layer.succeed( const execution = Layer.succeed(
@@ -41,14 +42,16 @@ const execution = Layer.succeed(
Effect.sync(() => { Effect.sync(() => {
executionCalls.push(sessionID) executionCalls.push(sessionID)
}), }),
interrupt: (sessionID) => interrupt: (sessionID, options) =>
Effect.sync(() => { Effect.sync(() => {
interruptCalls.push(sessionID) interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
}), }),
wake: (sessionID) => wake: (sessionID) =>
Effect.sync(() => { Effect.sync(() => {
wakeCalls.push(sessionID) wakeCalls.push(sessionID)
}), }),
wakeActive: () => Effect.void,
awaitIdle: () => Effect.void, awaitIdle: () => Effect.void,
}), }),
) )
@@ -177,31 +180,18 @@ describe("Session.prompt", () => {
}), }),
) )
it.effect("continues after interruption when pending work remains", () => it.effect("forwards interrupt continuation policy", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
interruptCalls.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("does not continue after interruption without pending work", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* setup yield* setup
const session = yield* Session.Service const session = yield* Session.Service
interruptCalls.length = 0 interruptCalls.length = 0
interruptContinuations.length = 0
wakeCalls.length = 0 wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true }) yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID]) expect(interruptCalls).toEqual([sessionID])
expect(interruptContinuations).toEqual([true])
expect(wakeCalls).toEqual([]) expect(wakeCalls).toEqual([])
}), }),
) )
@@ -770,7 +760,7 @@ describe("Session.prompt", () => {
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -269,6 +269,35 @@ describe("SessionRunCoordinator", () => {
), ),
) )
it.effect("replaces a settlement-window wake with a steer continuation", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(settling)
yield* coordinator.wake("session", "input")
const interrupted = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(true) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () => it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -342,6 +371,193 @@ describe("SessionRunCoordinator", () => {
), ),
) )
it.effect("coalesces drain requests with input taking precedence", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "input"])
}),
),
)
it.effect("does not carry a completed input request into a steer drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("an active wake inherits scope without starting idle work", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wakeActive("session")
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wakeActive("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "steer"])
}),
),
)
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session", "input")
const continuing = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("does not start a conditional continuation without eligible work", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.sync(() => requests.push(request)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input"])
}),
),
)
it.effect("starts a resume registered during interruption cleanup", () => it.effect("starts a resume registered during interruption cleanup", () =>
Effect.scoped( Effect.scoped(
Effect.gen(function* () { Effect.gen(function* () {
@@ -126,7 +126,8 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
active: coordinator.active, active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -13,6 +13,8 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema" import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot" import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event" import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { it } from "./lib/effect"
import { TestClock } from "effect/testing"
const sessionID = Session.ID.make("ses_tool_event_test") const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB" const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -201,6 +203,82 @@ test("reasoning state from start, empty delta, and end is merged", async () => {
}) })
}) })
it.effect("batches text deltas and flushes pending text before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "one" }),
LLMEvent.textDelta({ id: "text", text: " two" }),
LLMEvent.textDelta({ id: "text", text: " three" }),
],
publisher.publish,
{ discard: true },
)
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("99 millis")
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("1 millis")
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " four" }))
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
{ delta: "one two three four" },
])
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " five" }))
yield* publisher.publish(LLMEvent.textEnd({ id: "text" }))
expect(published.slice(-2).map((event) => event.type)).toEqual(["session.text.delta", "session.text.ended.1"])
expect(published.at(-2)?.data).toMatchObject({ delta: " five" })
}),
)
it.effect("batches reasoning deltas and flushes pending reasoning before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "one" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " two" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " three" }),
LLMEvent.reasoningEnd({ id: "reasoning" }),
],
publisher.publish,
{ discard: true },
)
expect(
published.filter((event) => event.type === "session.reasoning.delta").map((event) => event.data),
).toMatchObject([{ delta: "one two three" }])
expect(published.slice(-2).map((event) => event.type)).toEqual([
"session.reasoning.delta",
"session.reasoning.ended.1",
])
}),
)
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
LLMEvent.toolInputEnd({ id: "call", name: "read" }),
],
publisher.publish,
{ discard: true },
),
)
expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
text: '{"path":"file.txt"}',
})
})
test("provider-executed tool metadata is flattened using the route key", async () => { test("provider-executed tool metadata is flattened using the route key", async () => {
const { published, publisher } = capture("openai") const { published, publisher } = capture("openai")
await Effect.runPromise( await Effect.runPromise(
+92 -16
View File
@@ -75,7 +75,7 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model" import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect" import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing" import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
@@ -413,7 +413,8 @@ const execution = Layer.effect(
active: coordinator.active, active: coordinator.active,
resume: coordinator.run, resume: coordinator.run,
wake: coordinator.wake, wake: coordinator.wake,
interrupt: coordinator.interrupt, wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
awaitIdle: coordinator.awaitIdle, awaitIdle: coordinator.awaitIdle,
}) })
}), }),
@@ -671,7 +672,7 @@ const replaySessionProjection = (id: Session.ID) =>
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -685,7 +686,7 @@ const replaySessionProjection = (id: Session.ID) =>
type FragmentKind = "text" | "reasoning" | "tool input" type FragmentKind = "text" | "reasoning" | "tool input"
type FragmentFixture = { type FragmentFixture = {
readonly delta: Event.Definition readonly delta?: Event.Definition
readonly completeEvents: LLMEvent[] readonly completeEvents: LLMEvent[]
readonly partialEvents: LLMEvent[] readonly partialEvents: LLMEvent[]
readonly expectedAssistant: unknown readonly expectedAssistant: unknown
@@ -747,7 +748,6 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
] ]
const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } } const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } }
return { return {
delta: SessionEvent.Tool.Input.Delta,
partialEvents, partialEvents,
completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })], completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
expectedAssistant: { type: "assistant", content: [expectedContent] }, expectedAssistant: { type: "assistant", content: [expectedContent] },
@@ -766,20 +766,37 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant] const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
yield* admit(session, prompt) yield* admit(session, prompt)
const bus = yield* Bus.Service const bus = yield* Bus.Service
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped) const live = fixture.delta
? yield* bus.subscribe(fixture.delta).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
: undefined
yield* Effect.yieldNow yield* Effect.yieldNow
yield* TestLLM.push(fixture.completeEvents) yield* TestLLM.push(fixture.completeEvents)
yield* session.resume(sessionID) yield* session.resume(sessionID)
const { db } = yield* Database.Service const { db } = yield* Database.Service
const deltas = yield* db const deltas = fixture.delta
.select({ type: EventTable.type }) ? yield* db
.from(EventTable) .select({ type: EventTable.type })
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1))) .from(EventTable)
.all() .where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
.pipe(Effect.orDie) .all()
expect(Array.from(yield* Fiber.join(live))).toHaveLength(32) .pipe(Effect.orDie)
: []
if (live) {
const streamed = Array.from(yield* Fiber.join(live))
expect(streamed).toHaveLength(1)
expect(
streamed
.map((event) => {
if (!event.data || typeof event.data !== "object" || !("delta" in event.data))
throw new Error("Expected delta event")
if (typeof event.data.delta !== "string") throw new Error("Expected string delta")
return event.data.delta
})
.join(""),
).toBe(chunks.join(""))
}
expect(deltas).toHaveLength(0) expect(deltas).toHaveLength(0)
expect(yield* session.context(sessionID)).toMatchObject(expectedContext) expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
@@ -1383,6 +1400,44 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("keeps queued input parked across a mid-turn 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"),
TestLLM.text("Handled queue", "text-after-queue"),
)
const tools = yield* blockTools()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
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)
// The resumed turn absorbs steers only; queued input waits for the turn to end.
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])).not.toContain("Queued for later")
expect(userTexts(requests[2])).toContain("Queued for later")
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () => it.effect("seeds a fork with the parent's newest instruction values", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -1424,7 +1479,7 @@ describe("SessionRunnerLLM", () => {
yield* Effect.forEach( yield* Effect.forEach(
recorded.map((event) => ({ recorded.map((event) => ({
id: event.id, id: event.id,
created: DateTime.makeUnsafe(event.created), created: event.created,
aggregateID: event.aggregate_id, aggregateID: event.aggregate_id,
seq: event.seq, seq: event.seq,
type: event.type, type: event.type,
@@ -3088,6 +3143,24 @@ describe("SessionRunnerLLM", () => {
}), }),
) )
it.effect("stops a steer-scoped drain before queued input", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
yield* TestLLM.push(TestLLM.stop())
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toEqual(["Steer now"])
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
}),
)
it.effect("promotes queued input after steering continuation ends", () => it.effect("promotes queued input after steering continuation ends", () =>
Effect.gen(function* () { Effect.gen(function* () {
const session = yield* setup const session = yield* setup
@@ -5162,8 +5235,11 @@ describe("SessionRunnerLLM", () => {
) )
for (const kind of fragmentKinds) { for (const kind of fragmentKinds) {
it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () => it.effect(
verifyEphemeralDeltas(kind), kind === "tool input"
? "does not broadcast provider tool input deltas"
: `batches provider ${kind} deltas without storing projection rewrites`,
() => verifyEphemeralDeltas(kind),
) )
it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind)) it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind))
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect } from "bun:test"
import path from "path"
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"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
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 { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { DateTime, Effect, Layer } from "effect"
import { asc, eq } from "drizzle-orm"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
import { globalProjectLayer } from "./lib/project"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]),
[
[Bus.node, Bus.configured({ persist: true })],
[Project.node, globalProjectLayer],
[SessionExecution.node, SessionExecution.noopLayer],
],
),
)
const location = Location.Ref.make({ directory: AbsolutePath.make("/project") })
describe("Session.view", () => {
it.effect("copies the latest idle time without changing session recency", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const { db } = yield* Database.Service
const created = yield* session.create({ location })
expect(created.time.idle).toBeUndefined()
expect(created.time.viewed).toBeUndefined()
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toBeUndefined()
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
const idle = yield* session.get(created.id)
expect(idle.time.idle).toBeDefined()
expect(idle.time.viewed).toBeUndefined()
expect(idle.time.updated).toEqual(created.time.updated)
yield* session.view({ sessionID: created.id })
const viewed = yield* session.get(created.id)
if (!viewed.time.idle || !viewed.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(viewed.time.viewed).toEqual(viewed.time.idle)
expect(viewed.time.updated).toEqual(created.time.updated)
expect(
yield* db
.select({ idle: SessionTable.time_idle, viewed: SessionTable.time_viewed })
.from(SessionTable)
.where(eq(SessionTable.id, created.id))
.get(),
).toEqual({
idle: DateTime.toEpochMillis(viewed.time.idle),
viewed: DateTime.toEpochMillis(viewed.time.viewed),
})
expect((yield* session.list()).data.find((item) => item.id === created.id)?.time).toEqual(viewed.time)
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time).toEqual(viewed.time)
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const unread = yield* session.get(created.id)
if (!unread.time.idle || !unread.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(unread.time.idle)).toBeGreaterThan(DateTime.toEpochMillis(unread.time.viewed))
yield* session.view({ sessionID: created.id })
expect((yield* session.get(created.id)).time.viewed).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "shutdown" })
expect((yield* session.get(created.id)).time.idle).toEqual(unread.time.idle)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID: created.id, reason: "user" })
const interrupted = yield* session.get(created.id)
if (!interrupted.time.idle || !interrupted.time.viewed)
return yield* Effect.die(new Error("Expected attention times"))
expect(DateTime.toEpochMillis(interrupted.time.idle)).toBeGreaterThan(
DateTime.toEpochMillis(interrupted.time.viewed),
)
expect(
(yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.all()).filter((event) => event.type === "session.viewed.1"),
).toHaveLength(2)
}),
)
it.effect("rejects an unknown session", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const sessionID = Session.ID.make("ses_missing_view")
expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
}),
)
it.effect("replays viewed state into a fresh database", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const bus = yield* Bus.Service
const sourceDb = (yield* Database.Service).db
const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
yield* session.view({ sessionID: created.id })
yield* bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
const expected = yield* session.get(created.id)
if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
const serialized = (yield* sourceDb
.select()
.from(EventTable)
.where(eq(EventTable.aggregate_id, created.id))
.orderBy(asc(EventTable.seq))
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
}))
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const targetLayer = AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
[
[Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
[Bus.node, Bus.configured({ persist: true })],
],
)
yield* Effect.gen(function* () {
const db = (yield* Database.Service).db
const targetBus = yield* Bus.Service
const store = yield* SessionStore.Service
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
expect((yield* store.get(created.id))?.time).toEqual(expected.time)
expect(expected.time.updated).toEqual(created.time.updated)
expect(expectedIdle).toBeGreaterThan(expectedViewed)
}).pipe(Effect.provide(Layer.fresh(targetLayer)))
}),
)
})
+3 -3
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Formatter } from "@opencode-ai/core/formatter" import { Formatter } from "@opencode-ai/core/formatter"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const editToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
deps: [ deps: [
Tool.node, Tool.node,
LocationPath.node, LocationMutation.node,
FileMutation.node, FileMutation.node,
Environment.node, Environment.node,
Formatter.node, Formatter.node,
@@ -84,7 +84,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]), LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, editToolNode]),
[ [
[ [
Environment.node, Environment.node,
+7 -6
View File
@@ -8,7 +8,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { Formatter } from "@opencode-ai/core/formatter" import { Formatter } from "@opencode-ai/core/formatter"
import { FileMutation } from "@opencode-ai/core/file-mutation" import { FileMutation } from "@opencode-ai/core/file-mutation"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
@@ -27,7 +27,7 @@ const patchToolNode = makeLocationNode({
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
deps: [ deps: [
Tool.node, Tool.node,
LocationPath.node, LocationMutation.node,
FileMutation.node, FileMutation.node,
Environment.node, Environment.node,
Formatter.node, Formatter.node,
@@ -99,7 +99,7 @@ const withTool = <A, E, R>(
return yield* body(yield* Tool.Service) return yield* body(yield* Tool.Service)
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.node, FileMutation.node, patchToolNode]), [ AppNodeBuilder.build(LayerNode.group([Tool.node, LocationMutation.node, FileMutation.node, patchToolNode]), [
[ [
Environment.node, Environment.node,
transformEnvironmentFiles(activeLocation, (files) => ({ transformEnvironmentFiles(activeLocation, (files) => ({
@@ -920,7 +920,7 @@ describe("PatchTool", () => {
), ),
) )
it.live("treats a sibling path inside the project worktree as internal", () => it.live("treats a sibling path inside the project worktree as external to the Location", () =>
Effect.acquireUseRelease( Effect.acquireUseRelease(
Effect.promise(() => tmpdir()), Effect.promise(() => tmpdir()),
(tmp) => { (tmp) => {
@@ -939,8 +939,9 @@ describe("PatchTool", () => {
call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"), call("*** Begin Patch\n*** Update File: ../sibling.txt\n@@\n-before\n+after\n*** End Patch"),
), ),
).toMatchObject({ status: "completed" }) ).toMatchObject({ status: "completed" })
expect(assertions.map((input) => input.action)).toEqual(["edit"]) expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
expect(assertions[0]?.resources).toEqual(["../sibling.txt"]) expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
expect(assertions[1]?.resources).toEqual([target.replaceAll("\\", "/")])
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n") expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\n")
}), }),
tmp.path, tmp.path,
+5 -5
View File
@@ -14,7 +14,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { location } from "./fixture/location" import { location } from "./fixture/location"
import { Tool } from "@opencode-ai/core/tool" import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read" import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
@@ -32,7 +32,7 @@ const readToolNode = makeLocationNode({
deps: [ deps: [
Tool.node, Tool.node,
ReadToolFileSystem.node, ReadToolFileSystem.node,
LocationPath.node, LocationMutation.node,
Image.node, Image.node,
Permission.node, Permission.node,
SessionInstructions.node, SessionInstructions.node,
@@ -107,8 +107,8 @@ const locationLayer = Layer.succeed(
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
) )
const mutation = Layer.succeed( const mutation = Layer.succeed(
LocationPath.Service, LocationMutation.Service,
LocationPath.Service.of({ LocationMutation.Service.of({
resolve: (input) => { resolve: (input) => {
const absolute = path.resolve(process.cwd(), input.path) const absolute = path.resolve(process.cwd(), input.path)
const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute) const external = path.isAbsolute(input.path) && !FSUtil.contains(process.cwd(), absolute)
@@ -141,7 +141,7 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
[Permission.node, permission], [Permission.node, permission],
[Config.node, config], [Config.node, config],
[Image.node, imageLayer], [Image.node, imageLayer],
[LocationPath.node, mutation], [LocationMutation.node, mutation],
[FSUtil.node, testFileSystem], [FSUtil.node, testFileSystem],
[Location.node, locationLayer], [Location.node, locationLayer],
[Global.node, Global.layerWith({ data: Global.Path.data })], [Global.node, Global.layerWith({ data: Global.Path.data })],
+3 -3
View File
@@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index" import { Environment } from "@opencode-ai/core/environment/index"
import { FileSystem } from "@opencode-ai/core/filesystem" import { FileSystem } from "@opencode-ai/core/filesystem"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Ripgrep } from "@opencode-ai/core/ripgrep"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
@@ -25,12 +25,12 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const globToolNode = makeLocationNode({ const globToolNode = makeLocationNode({
name: "test/glob-tool-plugin", name: "test/glob-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GlobTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node], deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
}) })
const grepToolNode = makeLocationNode({ const grepToolNode = makeLocationNode({
name: "test/grep-tool-plugin", name: "test/grep-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(GrepTool.Plugin)),
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node], deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationMutation.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_search_tool_test") const sessionID = Session.ID.make("ses_search_tool_test")
+3 -2
View File
@@ -16,7 +16,7 @@ import { Environment } from "@opencode-ai/core/environment/index"
import { FSUtil } from "@opencode-ai/util/fs-util" import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { Model } from "@opencode-ai/core/model" import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider" import { Provider } from "@opencode-ai/core/provider"
@@ -115,6 +115,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: complete, resume: complete,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid), awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
}) })
@@ -132,7 +133,7 @@ const shellPluginSupervisor = makeLocationNode({
deps: [ deps: [
Config.node, Config.node,
Environment.node, Environment.node,
LocationPath.node, LocationMutation.node,
Permission.node, Permission.node,
PluginRuntime.node, PluginRuntime.node,
Shell.node, Shell.node,
+2 -1
View File
@@ -86,6 +86,7 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()), active: Effect.succeed(new Set()),
resume: complete, resume: complete,
wake: () => Effect.void, wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void, interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid), awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
}) })
@@ -297,7 +298,7 @@ describe("SubagentTool", () => {
}) })
const child = yield* sessions.get(outputSessionID(settled.metadata)) const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" }) expect(progress[0]).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({ expect(child).toMatchObject({
parentID: parent.id, parentID: parent.id,
location: parent.location, location: parent.location,
+3 -3
View File
@@ -8,7 +8,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Environment } from "@opencode-ai/core/environment/index" import { Environment } from "@opencode-ai/core/environment/index"
import { Location } from "@opencode-ai/core/location" import { Location } from "@opencode-ai/core/location"
import { LocationPath } from "@opencode-ai/core/location-path" import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { Permission } from "@opencode-ai/core/permission" import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session" import { Session } from "@opencode-ai/core/session"
@@ -25,7 +25,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
const writeToolNode = makeLocationNode({ const writeToolNode = makeLocationNode({
name: "test/write-tool-plugin", name: "test/write-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)), layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
deps: [Tool.node, LocationPath.node, FileMutation.node, Environment.node, Formatter.node, Permission.node], deps: [Tool.node, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
}) })
const sessionID = Session.ID.make("ses_write_tool_test") const sessionID = Session.ID.make("ses_write_tool_test")
@@ -72,7 +72,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
}).pipe( }).pipe(
Effect.provide( Effect.provide(
AppNodeBuilder.build( AppNodeBuilder.build(
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]), LayerNode.group([Tool.node, Tool.node, LocationMutation.node, FileMutation.node, writeToolNode]),
[ [
[ [
Environment.node, Environment.node,
+2
View File
@@ -60,6 +60,8 @@ const session = (
model: null, model: null,
time_created: 1, time_created: 1,
time_updated: 2, time_updated: 2,
time_idle: null,
time_viewed: null,
time_compacting: 3, time_compacting: 3,
time_archived: null, time_archived: null,
time_suspended: null, time_suspended: null,
+3 -2
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { $ } from "bun" import { $ } from "bun"
import { downloadCliToResources, resolveChannel } from "./utils" import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel() const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources() if (channel === "dev") await downloadCliToResources()
if (channel === "beta") await downloadCliToResources("next") if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
+13 -3
View File
@@ -3,7 +3,7 @@ import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
const CLI_VERSION = "0.0.0-next-16365" const CLI_VERSION = "dev"
export type Channel = "dev" | "beta" | "prod" export type Channel = "dev" | "beta" | "prod"
@@ -74,18 +74,28 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
try { try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile( await copyCliToResources(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest, dest,
) )
} finally { } finally {
await rm(directory, { recursive: true, force: true }) await rm(directory, { recursive: true, force: true })
} }
await prepareCli(dest)
console.log(`Copied ${cli.package}@${version} to ${dest}`) console.log(`Copied ${cli.package}@${version} to ${dest}`)
} }
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
const cli = getCurrentCli()
const directory = cli.package.replace("@opencode-ai/", "")
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), dest)
}
async function copyCliToResources(source: string, dest: string) {
await copyFile(source, dest)
await prepareCli(dest)
}
async function prepareCli(dest: string) { async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
@@ -27,7 +27,7 @@ test("installs and verifies the bundled CLI version", async () => {
await controller.installOpencode("Debian") await controller.installOpencode("Debian")
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]]) expect(installs).toEqual([["Debian", "0.0.0-dev-16365"]])
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true) expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
}) })
@@ -37,12 +37,12 @@ test("rejects a WSL CLI version that differs from the bundled version", async ()
testControllerOptions({ testControllerOptions({
installCli: async () => undefined, installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2", resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-next-older", readCliVersion: async () => "0.0.0-dev-older",
}), }),
) )
await expect(controller.installOpencode("Debian")).rejects.toThrow( await expect(controller.installOpencode("Debian")).rejects.toThrow(
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365", "OpenCode update finished but Debian still reports 0.0.0-dev-older; expected 0.0.0-dev-16365",
) )
}) })
@@ -147,7 +147,7 @@ async function waitFor(check: () => boolean) {
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions { function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
return { return {
cli: { version: "0.0.0-next-16365" }, cli: { version: "0.0.0-dev-16365" },
spawnSidecar: async () => ({ spawnSidecar: async () => ({
stop: async () => undefined, stop: async () => undefined,
onExit: () => undefined, onExit: () => undefined,
@@ -159,7 +159,7 @@ function testControllerOptions(overrides: Partial<ControllerOptions> = {}): Cont
writeServers: (servers: WslServerConfig[]) => { writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers persistedServers = servers
}, },
readCliVersion: async () => "0.0.0-next-16365", readCliVersion: async () => "0.0.0-dev-16365",
resolveCli: async () => "/home/me/.opencode/bin/opencode2", resolveCli: async () => "/home/me/.opencode/bin/opencode2",
...overrides, ...overrides,
} }
+141 -4
View File
@@ -3964,7 +3964,7 @@
} }
} }
}, },
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.", "description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"summary": "Interrupt session execution" "summary": "Interrupt session execution"
} }
}, },
@@ -4127,6 +4127,65 @@
"summary": "Get session message" "summary": "Get session message"
} }
}, },
"/api/session/{sessionID}/view": {
"post": {
"tags": ["session"],
"operationId": "v2.session.view",
"parameters": [
{
"name": "sessionID",
"in": "path",
"schema": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
},
"required": true
}
],
"security": [],
"responses": {
"204": {
"description": "<No Content>"
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
},
"404": {
"description": "SessionNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SessionNotFoundError"
}
}
}
}
},
"description": "Mark the latest recorded idle transition as viewed.",
"summary": "View session"
}
},
"/api/session/{sessionID}/message": { "/api/session/{sessionID}/message": {
"get": { "get": {
"tags": ["session"], "tags": ["session"],
@@ -12135,6 +12194,12 @@
"updated": { "updated": {
"type": "number" "type": "number"
}, },
"idle": {
"type": "number"
},
"viewed": {
"type": "number"
},
"archived": { "archived": {
"type": "number" "type": "number"
} }
@@ -14276,6 +14341,71 @@
"required": ["id", "created", "type", "durable", "data"], "required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false "additionalProperties": false
}, },
"session.viewed": {
"type": "object",
"properties": {
"id": {
"type": "string",
"allOf": [
{
"pattern": "^evt_"
}
]
},
"created": {
"type": "number"
},
"metadata": {
"type": "object"
},
"type": {
"type": "string",
"enum": ["session.viewed"]
},
"durable": {
"type": "object",
"properties": {
"aggregateID": {
"type": "string"
},
"seq": {
"type": "integer",
"allOf": [
{
"minimum": 0
}
]
},
"version": {
"type": "number",
"enum": [1]
}
},
"required": ["aggregateID", "seq", "version"],
"additionalProperties": false
},
"location": {
"$ref": "#/components/schemas/Location.Ref"
},
"data": {
"type": "object",
"properties": {
"sessionID": {
"type": "string",
"allOf": [
{
"pattern": "^ses"
}
]
}
},
"required": ["sessionID"],
"additionalProperties": false
}
},
"required": ["id", "created", "type", "durable", "data"],
"additionalProperties": false
},
"session.deleted": { "session.deleted": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -17296,6 +17426,9 @@
{ {
"$ref": "#/components/schemas/session.renamed" "$ref": "#/components/schemas/session.renamed"
}, },
{
"$ref": "#/components/schemas/session.viewed"
},
{ {
"$ref": "#/components/schemas/session.deleted" "$ref": "#/components/schemas/session.deleted"
}, },
@@ -17728,8 +17861,9 @@
"name": { "name": {
"type": "string" "type": "string"
}, },
"disabled": { "activation": {
"type": "boolean" "type": "string",
"enum": ["auto", "enabled", "disabled"]
}, },
"package": { "package": {
"type": "string" "type": "string"
@@ -17747,7 +17881,7 @@
"type": "object" "type": "object"
} }
}, },
"required": ["id", "name", "package"], "required": ["id", "name", "activation", "package"],
"additionalProperties": false "additionalProperties": false
}, },
"ProviderNotFoundError": { "ProviderNotFoundError": {
@@ -22663,6 +22797,9 @@
{ {
"$ref": "#/components/schemas/session.renamed" "$ref": "#/components/schemas/session.renamed"
}, },
{
"$ref": "#/components/schemas/session.viewed"
},
{ {
"$ref": "#/components/schemas/session.usage.updated" "$ref": "#/components/schemas/session.usage.updated"
}, },
+1
View File
@@ -33,6 +33,7 @@
"@tsconfig/bun": "catalog:", "@tsconfig/bun": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"prettier": "3.6.2",
"typescript": "catalog:" "typescript": "catalog:"
} }
} }

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