mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 01:19:19 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81d771e47 |
@@ -1,10 +1,6 @@
|
|||||||
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
|
||||||
@@ -24,22 +20,19 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
if [ "$RUNNER_ARCH" = "X64" ]; then
|
if [ "$RUNNER_ARCH" = "X64" ]; then
|
||||||
V="${{ inputs.bun-version }}"
|
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
|
||||||
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/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
|
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/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: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
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
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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
|
||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
- ci
|
- ci
|
||||||
- dev
|
- dev
|
||||||
- beta
|
- beta
|
||||||
- v2
|
|
||||||
- fix/npm-native-binary-install
|
- fix/npm-native-binary-install
|
||||||
- snapshot-*
|
- snapshot-*
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -33,7 +32,7 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version:
|
version:
|
||||||
@@ -46,13 +45,6 @@ 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
|
||||||
@@ -82,15 +74,13 @@ 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'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||||
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
|
||||||
@@ -112,7 +102,6 @@ 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 }}
|
||||||
@@ -196,7 +185,7 @@ jobs:
|
|||||||
|
|
||||||
build-node-cli:
|
build-node-cli:
|
||||||
needs: version
|
needs: version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -346,7 +335,6 @@ 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:
|
||||||
@@ -385,12 +373,6 @@ 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:
|
||||||
@@ -449,7 +431,6 @@ 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
|
||||||
@@ -588,11 +569,13 @@ 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
|
||||||
|
|||||||
@@ -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 `dev` 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 `next` 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
|
||||||
|
|||||||
@@ -613,7 +613,6 @@
|
|||||||
"@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:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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-beta-17236)
|
-v, --version <version> Install a specific version (e.g., 0.0.0-next-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-beta-17236
|
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-next-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/beta || true)
|
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/next || 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
@@ -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=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
|
"dev:live": "OPENCODE_TUI_CHANNEL=next 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",
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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,8 +16,9 @@ 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 messagePageSize = 200
|
const initialPageSize = 20
|
||||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
const historyPageSize = 200
|
||||||
|
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 }),
|
||||||
@@ -25,7 +26,7 @@ const messages = Array.from({ length: messagePageSize / 2 + 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 < messagePageSize / 2,
|
completed: index < initialPageSize,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
}).flat()
|
}).flat()
|
||||||
@@ -159,18 +160,21 @@ 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() + 30_000
|
const deadline = Date.now() + 10_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, -1_200)
|
await page.mouse.wheel(0, -240)
|
||||||
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(-messagePageSize)!.info.id}`,
|
`messages:start:${messages.at(-initialPageSize)!.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 & {
|
||||||
@@ -182,12 +186,15 @@ 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: messagePageSize },
|
{ before: undefined, limit: initialPageSize },
|
||||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||||
])
|
])
|
||||||
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: 101 }, (_, index) => turn(index)).flat()
|
const targetMessages = Array.from({ length: 72 }, (_, 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(465)
|
expect(expectedPartIDs.length).toBe(331)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectHomeProject(page: Page, projectName: string) {
|
async function selectHomeProject(page: Page, projectName: string) {
|
||||||
|
|||||||
@@ -72,12 +72,7 @@ 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()
|
||||||
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
|
await page.locator("[data-directory-path]").click()
|
||||||
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"]'))
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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,7 +1,9 @@
|
|||||||
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(() =>
|
||||||
@@ -17,6 +19,7 @@ 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) => {
|
||||||
@@ -33,6 +36,10 @@ export function useDirectoryPicker() {
|
|||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (!selected) input.onSelect(null)
|
if (!selected) input.onSelect(null)
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
|
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
|
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: 200, order: "desc" }])
|
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
|
||||||
expect(ctx.store.data.message.root).toEqual([])
|
expect(ctx.store.data.message.root).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -629,32 +629,8 @@ 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).toEqual([
|
expect(ctx.messages).toHaveLength(2)
|
||||||
{ 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 () => {
|
||||||
@@ -679,7 +655,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
|
|
||||||
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
|
expect(requests).toEqual([{ sessionID: "root", limit: 20, 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])
|
||||||
})
|
})
|
||||||
@@ -755,8 +731,8 @@ describe("server session", () => {
|
|||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
|
|
||||||
expect(requests).toEqual([
|
expect(requests).toEqual([
|
||||||
{ sessionID: "root", limit: 200, order: "desc" },
|
{ sessionID: "root", limit: 20, order: "desc" },
|
||||||
{ sessionID: "root", limit: 200, cursor: "older" },
|
{ sessionID: "root", limit: 20, 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,
|
||||||
@@ -765,26 +741,6 @@ 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 () => {
|
||||||
@@ -798,7 +754,7 @@ describe("server session", () => {
|
|||||||
|
|
||||||
await store.sync("child")
|
await store.sync("child")
|
||||||
|
|
||||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, 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)
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ 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 messagePageSize = 200
|
const initialMessagePageSize = 20
|
||||||
|
const historyMessagePageSize = 200
|
||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
@@ -241,6 +242,7 @@ 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>,
|
||||||
@@ -421,6 +423,7 @@ 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]
|
||||||
@@ -454,13 +457,11 @@ 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, before?: string, onAttempt?: () => void) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
const request = (cursor?: string) =>
|
const request = (cursor?: string) =>
|
||||||
(options?.retry ?? retry)(() => {
|
(options?.retry ?? retry)(() => {
|
||||||
onAttempt?.()
|
onAttempt?.()
|
||||||
return messageApi.list(
|
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||||
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]
|
||||||
@@ -631,13 +632,14 @@ 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, before?: string, mode?: "replace" | "prepend") => {
|
const loadMessages = async (sessionID: string, limit: number, 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 = {
|
||||||
@@ -656,7 +658,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, before, () => resetMessageLoad(sessionID, load))
|
const page = await fetchMessages(sessionID, limit, 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,
|
||||||
@@ -735,30 +737,32 @@ export function createServerSession(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sync = (sessionID: string, options?: { force?: boolean }) => {
|
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||||
touch(sessionID)
|
touch(sessionID)
|
||||||
return runInflight(inflight, sessionID, async () => {
|
return runInflight(inflight, sessionID, async () => {
|
||||||
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
|
const cached = data.message[sessionID] !== undefined && meta.limit[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 ? Promise.resolve() : loadMessages(sessionID),
|
cached && !invalid && !options?.force
|
||||||
|
? 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, messageCount: number) => {
|
const prefetch = async (sessionID: string, limit: 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) >= messageCount)
|
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
|
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
const eventSessionID = (event: { type: string; properties?: unknown }) => {
|
||||||
@@ -1350,11 +1354,11 @@ export function createServerSession(
|
|||||||
setMeta("at", {})
|
setMeta("at", {})
|
||||||
},
|
},
|
||||||
prefetch,
|
prefetch,
|
||||||
shouldPrefetch(sessionID: string, messageCount: number) {
|
shouldPrefetch(sessionID: string, limit: 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 (data.message[sessionID]?.length ?? 0) <= messageCount
|
return (meta.limit[sessionID] ?? 0) <= limit
|
||||||
},
|
},
|
||||||
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
|
||||||
@@ -1436,14 +1440,14 @@ export function createServerSession(
|
|||||||
history: {
|
history: {
|
||||||
more: (sessionID: string) =>
|
more: (sessionID: string) =>
|
||||||
data.message[sessionID] !== undefined &&
|
data.message[sessionID] !== undefined &&
|
||||||
meta.complete[sessionID] !== undefined &&
|
meta.limit[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) {
|
async loadMore(sessionID: string, count = historyMessagePageSize) {
|
||||||
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, meta.cursor[sessionID], "prepend")
|
await loadMessages(sessionID, count, 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(() => !!input.session.identity.sessionID())
|
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
|
||||||
const projection = createTimelineProjection({
|
const projection = createTimelineProjection({
|
||||||
messages: input.session.history.messages,
|
messages: input.session.history.messages,
|
||||||
userMessages: input.userMessages,
|
userMessages: input.userMessages,
|
||||||
|
|||||||
@@ -1203,8 +1203,6 @@ 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 (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { mkdir, rm } from "fs/promises"
|
import { 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,7 +27,6 @@ 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
|
||||||
@@ -100,7 +99,6 @@ 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"],
|
||||||
@@ -117,7 +115,6 @@ 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: {},
|
||||||
@@ -157,72 +154,6 @@ 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,
|
||||||
|
|||||||
@@ -14,13 +14,9 @@ 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)
|
||||||
const exists = await published(name, version)
|
if (await published(name, version)) return console.log(`already published ${name}@${version}`)
|
||||||
if (exists) console.log(`already published ${name}@${version}`)
|
await $`bun pm pack`.cwd(dir)
|
||||||
if (!exists) {
|
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||||
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 }) {
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
|||||||
database: {
|
database: {
|
||||||
path:
|
path:
|
||||||
process.env.OPENCODE_DB ??
|
process.env.OPENCODE_DB ??
|
||||||
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
|
(["latest", "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"
|
||||||
|
|||||||
@@ -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 === "dev" || channel === "beta" || channel === "next") return "service.json"
|
if (channel === "latest" || 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 === "dev" || channel === "beta" || channel === "next") return 0xc0de
|
if (channel === "latest" || 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,7 @@ 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-dev-17403", "0.0.0-dev-17403.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-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")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ 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"))
|
||||||
@@ -39,8 +37,6 @@ 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")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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"
|
||||||
@@ -315,7 +316,7 @@ export type Endpoint5_31Output =
|
|||||||
| (
|
| (
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -335,7 +336,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -348,7 +349,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -361,7 +362,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -375,7 +376,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -384,16 +385,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -402,7 +394,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -418,7 +410,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -427,7 +419,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -440,7 +432,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -449,7 +441,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -462,7 +454,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -471,7 +463,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -480,7 +472,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -492,7 +484,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -501,7 +493,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -514,7 +506,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -528,7 +520,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -542,7 +534,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -551,7 +543,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -569,7 +561,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -584,7 +576,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -606,7 +598,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -630,7 +622,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -643,7 +635,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -658,7 +650,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -672,7 +664,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -687,7 +679,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -701,7 +693,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -715,7 +707,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -731,7 +723,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -767,7 +759,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -806,7 +798,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -821,7 +813,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -835,7 +827,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -849,7 +841,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -863,7 +855,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -872,7 +864,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -881,7 +873,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -890,7 +882,7 @@ export type Endpoint5_31Output =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
readonly id: Event.ID
|
readonly id: Event.ID
|
||||||
readonly created: number
|
readonly created: DateTime.Utc
|
||||||
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 }
|
||||||
@@ -923,10 +915,6 @@ 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>
|
||||||
@@ -971,7 +959,6 @@ 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,8 +86,6 @@ 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,
|
||||||
@@ -612,11 +610,6 @@ 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),
|
||||||
@@ -646,7 +639,6 @@ 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,8 +80,6 @@ import type {
|
|||||||
SessionBackgroundOutput,
|
SessionBackgroundOutput,
|
||||||
SessionMessageInput,
|
SessionMessageInput,
|
||||||
SessionMessageOutput,
|
SessionMessageOutput,
|
||||||
SessionViewInput,
|
|
||||||
SessionViewOutput,
|
|
||||||
MessageListInput,
|
MessageListInput,
|
||||||
MessageListOutput,
|
MessageListOutput,
|
||||||
ModelListInput,
|
ModelListInput,
|
||||||
@@ -898,17 +896,6 @@ 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) =>
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export type ProviderInfo = {
|
|||||||
id: string
|
id: string
|
||||||
integrationID?: string
|
integrationID?: string
|
||||||
name: string
|
name: string
|
||||||
activation: "auto" | "enabled" | "disabled"
|
disabled?: boolean
|
||||||
package: string
|
package: string
|
||||||
settings?: { [x: string]: any }
|
settings?: { [x: string]: any }
|
||||||
headers?: { [x: string]: string }
|
headers?: { [x: string]: string }
|
||||||
@@ -479,16 +479,6 @@ 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
|
||||||
@@ -1520,7 +1510,7 @@ export type SessionInfo = {
|
|||||||
model?: ModelRef
|
model?: ModelRef
|
||||||
cost: MoneyUSD
|
cost: MoneyUSD
|
||||||
tokens: TokenUsageInfo
|
tokens: TokenUsageInfo
|
||||||
time: { created: number; updated: number; idle?: number; viewed?: number; archived?: number }
|
time: { created: number; updated: number; archived?: number }
|
||||||
title?: string
|
title?: string
|
||||||
location: LocationRef
|
location: LocationRef
|
||||||
subpath?: string
|
subpath?: string
|
||||||
@@ -1933,7 +1923,6 @@ export type SessionEventDurable =
|
|||||||
| SessionModelSelected
|
| SessionModelSelected
|
||||||
| SessionMoved
|
| SessionMoved
|
||||||
| SessionRenamed
|
| SessionRenamed
|
||||||
| SessionViewed
|
|
||||||
| SessionDeleted
|
| SessionDeleted
|
||||||
| SessionForked
|
| SessionForked
|
||||||
| SessionInboxDelivered
|
| SessionInboxDelivered
|
||||||
@@ -2024,7 +2013,6 @@ export type V2Event =
|
|||||||
| SessionModelSelected
|
| SessionModelSelected
|
||||||
| SessionMoved
|
| SessionMoved
|
||||||
| SessionRenamed
|
| SessionRenamed
|
||||||
| SessionViewed
|
|
||||||
| SessionUsageUpdated
|
| SessionUsageUpdated
|
||||||
| SessionDeleted
|
| SessionDeleted
|
||||||
| SessionForked
|
| SessionForked
|
||||||
@@ -2488,13 +2476,7 @@ 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 time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||||
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
|
||||||
@@ -2761,13 +2743,7 @@ 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 time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||||
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
|
||||||
@@ -3034,13 +3010,7 @@ 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 time: { readonly created: number; readonly updated: number; readonly archived?: number }
|
||||||
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
|
||||||
@@ -3966,10 +3936,6 @@ 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?: {
|
||||||
|
|||||||
@@ -136,10 +136,8 @@ 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(
|
||||||
@@ -185,7 +183,6 @@ 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"),
|
||||||
@@ -210,11 +207,7 @@ 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)
|
||||||
|
|
||||||
const listed = result.page.data[0]
|
expect(DateTime.toEpochMillis(result.page.data[0].time.created)).toBe(1_717_171_717_000)
|
||||||
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)
|
||||||
@@ -224,10 +217,11 @@ 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" && logged[0].created).toBe(1_717_171_717_000)
|
expect(logged[0]?.type === "session.model.selected" && DateTime.toEpochMillis(logged[0].created)).toBe(
|
||||||
|
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" }))
|
||||||
})
|
})
|
||||||
@@ -266,8 +260,6 @@ 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" },
|
||||||
|
|||||||
@@ -539,7 +539,6 @@ 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",
|
||||||
@@ -566,7 +565,6 @@ 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")
|
||||||
@@ -579,7 +577,6 @@ 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"],
|
||||||
@@ -654,8 +651,6 @@ 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: Aug 15, 2026</p>
|
<p class="effective-date">Effective date: Mar 6, 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,11 +154,6 @@ 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>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "94b6c496-ad84-426f-9d5d-3e1ac3ebfb56",
|
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
|
||||||
"prevIds": ["dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936"],
|
"prevIds": ["5c1aa56b-c3ee-4283-9a84-c0bf626dc604"],
|
||||||
"ddl": [
|
"ddl": [
|
||||||
{
|
{
|
||||||
"name": "account_state",
|
"name": "account_state",
|
||||||
@@ -1350,26 +1350,6 @@
|
|||||||
"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
@@ -1,6 +1,6 @@
|
|||||||
export * as Bus from "./bus.js"
|
export * as Bus from "./bus.js"
|
||||||
|
|
||||||
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
import { Cause, Context, DateTime, 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?: number
|
readonly created?: DateTime.Utc
|
||||||
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 ?? 0,
|
created: event.created ?? DateTime.makeUnsafe(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 === (event.created ?? 0) &&
|
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(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: event.created ?? 0,
|
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(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* Clock.currentTimeMillis,
|
created: yield* DateTime.now,
|
||||||
...(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* Clock.currentTimeMillis,
|
created: yield* DateTime.now,
|
||||||
...(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: event.created,
|
created: DateTime.toEpochMillis(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 ?? 0,
|
created: event.created ?? DateTime.makeUnsafe(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: event.created,
|
created: DateTime.makeUnsafe(event.created),
|
||||||
aggregateID: event.aggregate_id,
|
aggregateID: event.aggregate_id,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|||||||
@@ -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.activation === "disabled") return false
|
if (provider.disabled) return false
|
||||||
if (provider.activation === "enabled") return true
|
if (typeof provider.settings?.apiKey === "string") return true
|
||||||
if (integration?.connections.length) return true
|
if (integration?.connections.length) return true
|
||||||
return provider.integrationID === undefined && !integration
|
return provider.integrationID === undefined && !integration
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import type { LocationPath } from "../../location-path.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 =
|
||||||
| LocationMutation.ExternalDirectoryAuthorization["action"]
|
| LocationPath.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,7 +40,6 @@ 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
@@ -43,7 +43,6 @@ 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,
|
||||||
@@ -90,5 +89,4 @@ export const migrations = [
|
|||||||
m41,
|
m41,
|
||||||
m42,
|
m42,
|
||||||
m43,
|
m43,
|
||||||
m44,
|
|
||||||
] satisfies DatabaseMigration.Migration[]
|
] satisfies DatabaseMigration.Migration[]
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -209,8 +209,6 @@ 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 LocationMutation from "./location-mutation.js"
|
export * as LocationPath from "./location-path.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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutation paths do not accept project references. Relative paths resolve
|
* Tool paths do not accept project references. Relative paths resolve from
|
||||||
* from the active Location. Paths outside it require separate
|
* the active Location. Paths outside its project 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 it require separate `external_directory`
|
* from the Location. Paths outside its project require separate
|
||||||
* approval. This does not approve the mutation.
|
* `external_directory` approval. This does not approve access.
|
||||||
*/
|
*/
|
||||||
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/LocationMutation") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LocationPath") {}
|
||||||
|
|
||||||
const slash = (value: string) => value.replaceAll("\\", "/")
|
const slash = (value: string) => value.replaceAll("\\", "/")
|
||||||
|
|
||||||
@@ -65,9 +65,13 @@ 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("LocationMutation.resolve")(function* (input: ResolveInput) {
|
const resolve = Effect.fn("LocationPath.resolve")(function* (input: ResolveInput) {
|
||||||
const absolute = path.resolve(location.directory, input.path)
|
const absolute = path.resolve(location.directory, input.path)
|
||||||
if (FSUtil.contains(location.directory, absolute)) {
|
const projectRoot = path.parse(location.project.directory).root
|
||||||
|
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) || "."),
|
||||||
@@ -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 { LocationMutation } from "./location-mutation.js"
|
import { LocationPath } from "./location-path.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,
|
||||||
LocationMutation.node,
|
LocationPath.node,
|
||||||
FileMutation.node,
|
FileMutation.node,
|
||||||
Formatter.node,
|
Formatter.node,
|
||||||
MCP.node,
|
MCP.node,
|
||||||
|
|||||||
@@ -358,21 +358,17 @@ 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 credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
const model = yield* resolveModel(
|
||||||
const runtimeInfo = yield* withVariant(selected, variant)
|
selected,
|
||||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
variant,
|
||||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||||
loadAISDK: (model) => aisdk.model(model),
|
{
|
||||||
})
|
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||||
const runtime =
|
loadAISDK: (model) => aisdk.model(model),
|
||||||
provider?.activation === "enabled" &&
|
},
|
||||||
credential === undefined &&
|
)
|
||||||
!hasConfiguredAuth(runtimeInfo) &&
|
|
||||||
usesAPIKeyAuth(runtimeInfo.package)
|
|
||||||
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
|
|
||||||
: model
|
|
||||||
return {
|
return {
|
||||||
model: runtime,
|
model,
|
||||||
ref: Ref.make({
|
ref: Ref.make({
|
||||||
id: selected.id,
|
id: selected.id,
|
||||||
providerID: selected.providerID,
|
providerID: selected.providerID,
|
||||||
@@ -403,35 +399,6 @@ 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,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ 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
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../location-mutation.js"
|
import { LocationPath } from "../location-path.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* LocationMutation.Service
|
const locationMutation = yield* LocationPath.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(LocationMutation.Service, locationMutation),
|
Context.make(LocationPath.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,
|
||||||
LocationMutation.node,
|
LocationPath.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.activation === "disabled") continue
|
if (item.provider.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,10 +178,7 @@ 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) {
|
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
|
||||||
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()) {
|
||||||
|
|||||||
@@ -63,39 +63,12 @@ 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/migrate-v1.mdx`.
|
the repository, its source is `packages/www/content/docs/(Get started)/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
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ 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
|
||||||
@@ -448,17 +447,6 @@ 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)
|
||||||
@@ -801,10 +789,7 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recovered) {
|
if (recovered) return
|
||||||
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) {
|
||||||
@@ -888,7 +873,12 @@ const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||||
Effect.uninterruptible(execution.interrupt(sessionID, options)),
|
Effect.uninterruptible(
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export * as SessionExecution from "./execution.js"
|
export * as SessionExecution from "./execution.js"
|
||||||
|
|
||||||
import { Cause, Context, Effect, Exit, Layer } from "effect"
|
import { Cause, Context, Effect, Exit, Layer, Stream } 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"
|
||||||
@@ -12,7 +11,6 @@ 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. */
|
||||||
@@ -21,10 +19,8 @@ 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, options?: { readonly continue?: boolean }) => Effect.Effect<void>
|
readonly interrupt: (sessionID: SessionSchema.ID) => 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>
|
||||||
}
|
}
|
||||||
@@ -49,7 +45,6 @@ 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) =>
|
||||||
@@ -76,13 +71,12 @@ 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, promotable }),
|
runner.drain({ sessionID, force, continuation }),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(locations.get(session.location)),
|
Effect.provide(locations.get(session.location)),
|
||||||
Effect.tapCause((cause) =>
|
Effect.tapCause((cause) =>
|
||||||
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (result.type === "complete") return
|
if (result.type === "complete") return
|
||||||
return yield* drain(sessionID, false, result.continuation, promotable)
|
return yield* drain(sessionID, false, result.continuation)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||||
@@ -101,7 +95,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, promotable) => drain(sessionID, force, undefined, promotable),
|
drain: (sessionID, force) => drain(sessionID, force),
|
||||||
// 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(
|
||||||
@@ -133,20 +127,16 @@ 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, options) =>
|
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
|
||||||
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,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -155,7 +145,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, Database.node],
|
deps: [SessionStore.node, LocationServiceMap.node, Bus.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. */
|
||||||
@@ -165,7 +155,6 @@ 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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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: DateTime.makeUnsafe(event.created),
|
timeCreated: 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: number
|
readonly timeCreated: DateTime.Utc
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
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: request.timeCreated,
|
time_created: DateTime.toEpochMillis(request.timeCreated),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning({ id: SessionInboxTable.id })
|
.returning({ id: SessionInboxTable.id })
|
||||||
@@ -349,14 +349,6 @@ 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.
|
||||||
|
|||||||
@@ -53,8 +53,6 @@ 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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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(
|
||||||
@@ -60,7 +59,6 @@ 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* () {
|
||||||
@@ -72,7 +70,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -87,7 +85,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -103,7 +101,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -128,7 +126,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -140,7 +138,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -153,7 +151,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -166,7 +164,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -179,7 +177,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 = created
|
draft.time.completed = event.created
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -207,7 +205,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 = created
|
draft.time.completed = event.created
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -218,7 +216,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 },
|
time: { created: event.created },
|
||||||
content: [],
|
content: [],
|
||||||
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -227,7 +225,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 = created
|
draft.time.completed = event.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
|
||||||
@@ -241,7 +239,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 = created
|
draft.time.completed = event.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
|
||||||
@@ -279,7 +277,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 },
|
time: { created: event.created },
|
||||||
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -298,7 +296,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 = created
|
match.time.ran = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateRunning.make({
|
SessionMessage.ToolStateRunning.make({
|
||||||
status: "running",
|
status: "running",
|
||||||
@@ -317,7 +315,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 = created
|
match.time.completed = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateCompleted.make({
|
SessionMessage.ToolStateCompleted.make({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
@@ -335,7 +333,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 = created
|
match.time.completed = event.created
|
||||||
match.state = castDraft(
|
match.state = castDraft(
|
||||||
SessionMessage.ToolStateError.make({
|
SessionMessage.ToolStateError.make({
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -356,7 +354,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -367,7 +365,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 ?? created, completed: created }
|
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||||
if (event.data.state !== undefined) match.state = event.data.state
|
if (event.data.state !== undefined) match.state = event.data.state
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -391,7 +389,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
"session.compaction.ended": (event) => {
|
"session.compaction.ended": (event) => {
|
||||||
@@ -416,7 +414,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 },
|
time: { created: event.created },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -431,7 +429,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 },
|
time: current?.time ?? { created: event.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)
|
||||||
|
|||||||
@@ -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: event.created,
|
time_created: DateTime.toEpochMillis(event.created),
|
||||||
time_updated: event.created,
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning({ sessionID: SessionTable.id })
|
.returning({ sessionID: SessionTable.id })
|
||||||
@@ -391,30 +391,6 @@ 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
|
||||||
@@ -435,8 +411,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: event.created,
|
time_created: DateTime.toEpochMillis(event.created),
|
||||||
time_updated: event.created,
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning({ sessionID: SessionTable.id })
|
.returning({ sessionID: SessionTable.id })
|
||||||
@@ -455,7 +431,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: event.created,
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
@@ -511,7 +487,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: event.created })
|
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
@@ -522,7 +498,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: event.created })
|
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
@@ -531,18 +507,7 @@ 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: event.created })
|
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(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),
|
||||||
@@ -570,7 +535,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: DateTime.makeUnsafe(event.created) },
|
time: { created: event.created },
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -578,7 +543,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: DateTime.makeUnsafe(event.created) },
|
time: { created: event.created },
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -596,7 +561,7 @@ const layer = Layer.effectDiscard(
|
|||||||
})
|
})
|
||||||
yield* db
|
yield* db
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ time_updated: event.created })
|
.set({ time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
@@ -615,9 +580,9 @@ const layer = Layer.effectDiscard(
|
|||||||
delivery: event.data.delivery,
|
delivery: event.data.delivery,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
|
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
|
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
|
||||||
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
|
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(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)
|
||||||
@@ -675,7 +640,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: event.created,
|
time_updated: DateTime.toEpochMillis(event.created),
|
||||||
})
|
})
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
@@ -685,7 +650,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: event.created })
|
.set({ revert: null, time_updated: DateTime.toEpochMillis(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),
|
||||||
@@ -720,7 +685,7 @@ const layer = Layer.effectDiscard(
|
|||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
yield* db
|
yield* db
|
||||||
.update(SessionTable)
|
.update(SessionTable)
|
||||||
.set({ revert: null, time_updated: event.created })
|
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
|
||||||
.where(eq(SessionTable.id, event.data.sessionID))
|
.where(eq(SessionTable.id, event.data.sessionID))
|
||||||
.run()
|
.run()
|
||||||
.pipe(Effect.orDie)
|
.pipe(Effect.orDie)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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> {
|
||||||
@@ -10,41 +9,26 @@ 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, request?: Request) => Effect.Effect<void>
|
readonly wake: (key: Key) => 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: (
|
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
|
||||||
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 with its eligibility request, and the execution loop drains again
|
* execution rings it, and the execution loop drains again instead of ending. The doorbell
|
||||||
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
* closes the gap between a drain's last eligibility check and the idle transition, since
|
||||||
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
||||||
* 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>
|
||||||
request: Request
|
pendingWake: boolean
|
||||||
pendingWake?: Request
|
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
interruptionReason?: Reason
|
interruptionReason?: Reason
|
||||||
continuation?: {
|
|
||||||
readonly request: Request
|
|
||||||
readonly when: Effect.Effect<boolean>
|
|
||||||
signaled: boolean
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +43,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, request: Request) => Effect.Effect<void, E>
|
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
|
||||||
/** Runs once when a process-local busy period begins, before its first drain. */
|
/** 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>
|
||||||
/**
|
/**
|
||||||
@@ -73,22 +57,21 @@ 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, execution.request)).pipe(
|
Effect.suspend(() => options.drain(key, force)).pipe(
|
||||||
Effect.flatMap(() =>
|
Effect.flatMap(() =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
if (execution.stopping || !execution.pendingWake) return Effect.void
|
||||||
execution.request = execution.pendingWake
|
execution.pendingWake = false
|
||||||
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, request: Request) => {
|
const start = (key: Key, force: boolean) => {
|
||||||
const execution: Execution<E, Reason> = {
|
const execution: Execution<E, Reason> = {
|
||||||
done: Deferred.makeUnsafe<void, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
request,
|
pendingWake: false,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
}
|
}
|
||||||
executions.set(key, execution)
|
executions.set(key, execution)
|
||||||
@@ -104,7 +87,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) => finish(key, execution, exit)),
|
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
|
||||||
Effect.exit,
|
Effect.exit,
|
||||||
Effect.asVoid,
|
Effect.asVoid,
|
||||||
),
|
),
|
||||||
@@ -114,22 +97,12 @@ 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>, resume: boolean) => {
|
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
|
||||||
if (resume && execution.continuation) start(key, false, execution.continuation.request)
|
if (execution.pendingWake) start(key, false)
|
||||||
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)
|
||||||
@@ -138,58 +111,26 @@ 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, "input").done)
|
return Deferred.await(start(key, true).done)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wake = (key: Key, request: Request = "input") =>
|
const wake = (key: Key) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution !== undefined) {
|
if (execution !== undefined) {
|
||||||
if (execution.stopping) {
|
execution.pendingWake = true
|
||||||
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, request)
|
start(key, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
const wakeActive = (key: Key) =>
|
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
return execution ? wake(key, execution.request) : Effect.void
|
if (execution?.owner === undefined || execution.stopping) return 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 = undefined
|
execution.pendingWake = false
|
||||||
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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -202,5 +143,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, wakeActive, interrupt, awaitIdle }
|
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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"
|
||||||
@@ -30,8 +29,6 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,25 +128,22 @@ 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
|
||||||
const promotable = input.promotable ?? "input"
|
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
|
||||||
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, promotable)) {
|
if (yield* runPendingCompaction(input.sessionID)) {
|
||||||
force = false
|
force = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
||||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
||||||
return { type: "complete" as const }
|
return { type: "complete" as const }
|
||||||
const result = yield* runSteps(input.sessionID, continuation, promotable)
|
const result = yield* runSteps(input.sessionID, continuation)
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -158,15 +155,14 @@ 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 | undefined,
|
continuation?: Continuation,
|
||||||
drainPromotable: SessionInbox.Promotable,
|
|
||||||
) {
|
) {
|
||||||
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
|
// Fresh work may promote queued input; later steps absorb steers only.
|
||||||
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
|
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
|
||||||
let step = continuation?.step ?? 1
|
let step = continuation?.step ?? 1
|
||||||
let next = continuation
|
let next = continuation
|
||||||
while (true) {
|
while (true) {
|
||||||
if (yield* runPendingCompaction(sessionID, "steer")) continue
|
if (yield* runPendingCompaction(sessionID)) 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
|
||||||
@@ -519,14 +515,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 = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
const selected =
|
||||||
|
(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 }],
|
||||||
@@ -568,7 +564,9 @@ const layer = Layer.effect(
|
|||||||
return yield* SessionInbox.serialized(
|
return yield* SessionInbox.serialized(
|
||||||
sessionID,
|
sessionID,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
const pending =
|
||||||
|
(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 { Clock, Effect } from "effect"
|
import { 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,7 +81,6 @@ 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,
|
||||||
{
|
{
|
||||||
@@ -124,56 +123,32 @@ 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,
|
||||||
) => {
|
) => {
|
||||||
type Fragment = {
|
const chunks = new Map<
|
||||||
readonly ordinal: number
|
string,
|
||||||
readonly values: string[]
|
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
|
||||||
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: [], pending: "", state })
|
chunks.set(id, { ordinal, values: [], state })
|
||||||
return Effect.succeed(ordinal)
|
return Effect.succeed(ordinal)
|
||||||
})
|
})
|
||||||
const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
|
const append = (id: string, value: string, state?: Record<string, unknown>) =>
|
||||||
if (!delta) return undefined
|
Effect.suspend(() => {
|
||||||
const current = chunks.get(id)
|
const current = chunks.get(id)
|
||||||
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
|
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
|
||||||
if (!current.pending) return undefined
|
current.values.push(value)
|
||||||
const now = yield* Clock.currentTimeMillis
|
if (state !== undefined) current.state = { ...current.state, ...state }
|
||||||
if (!force && current.publishedAt === undefined) {
|
return Effect.succeed(current.ordinal)
|
||||||
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(""),
|
||||||
@@ -181,10 +156,9 @@ 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 Array.from(chunks.keys())) yield* end(id)
|
for (const id of 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) }
|
||||||
}
|
}
|
||||||
@@ -201,15 +175,6 @@ 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(
|
||||||
@@ -224,15 +189,6 @@ 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) =>
|
||||||
@@ -395,7 +351,13 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "text-delta":
|
case "text-delta":
|
||||||
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
|
const deltaTextOrdinal = 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))
|
||||||
@@ -411,7 +373,17 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
case "reasoning-delta":
|
case "reasoning-delta":
|
||||||
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
|
const deltaReasoningOrdinal = yield* reasoning.append(
|
||||||
|
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))
|
||||||
@@ -427,6 +399,12 @@ 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":
|
||||||
|
|||||||
@@ -56,8 +56,6 @@ 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). */
|
||||||
|
|||||||
@@ -117,10 +117,6 @@ 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,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
...LocationPath.externalDirectoryPermission(external),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source: permissionSource,
|
source: permissionSource,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
...LocationPath.externalDirectoryPermission(external),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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: LocationMutation.Target
|
readonly target: LocationPath.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: LocationMutation.Target
|
readonly target: LocationPath.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: LocationMutation.Target
|
readonly target: LocationPath.Target
|
||||||
readonly content: string
|
readonly content: string
|
||||||
readonly before: string
|
readonly before: string
|
||||||
readonly after: string
|
readonly after: string
|
||||||
readonly moveTarget?: LocationMutation.Target
|
readonly moveTarget?: LocationPath.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(target.externalDirectory),
|
...LocationPath.externalDirectoryPermission(target.externalDirectory),
|
||||||
metadata: {
|
metadata: {
|
||||||
filepath: target.absolute,
|
filepath: target.absolute,
|
||||||
parentDir: target.externalDirectory.directory,
|
parentDir: target.externalDirectory.directory,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
...LocationPath.externalDirectoryPermission(external),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ export const Plugin = {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const background = input.background === true
|
const background = input.background === true
|
||||||
yield* context.progress({ sessionID: child.id, status: "running" })
|
yield* context.progress({
|
||||||
|
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.
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "../../location-mutation.js"
|
import { LocationPath } from "../../location-path.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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({
|
||||||
...LocationMutation.externalDirectoryPermission(external),
|
...LocationPath.externalDirectoryPermission(external),
|
||||||
sessionID: context.sessionID,
|
sessionID: context.sessionID,
|
||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
|
import { Cause, DateTime, 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()
|
||||||
|
|
||||||
const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
|
yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
|
||||||
const rows = yield* db
|
const rows = yield* db
|
||||||
.select()
|
.select()
|
||||||
.from(EventTable)
|
.from(EventTable)
|
||||||
@@ -433,7 +433,6 @@ 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)
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -707,7 +706,7 @@ describe("Bus", () => {
|
|||||||
|
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -727,7 +726,7 @@ describe("Bus", () => {
|
|||||||
|
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -764,7 +763,7 @@ describe("Bus", () => {
|
|||||||
const exit = yield* bus
|
const exit = yield* bus
|
||||||
.replay({
|
.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID: envelopeAggregateID,
|
aggregateID: envelopeAggregateID,
|
||||||
@@ -798,7 +797,7 @@ describe("Bus", () => {
|
|||||||
|
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -807,7 +806,7 @@ describe("Bus", () => {
|
|||||||
const exit = yield* bus
|
const exit = yield* bus
|
||||||
.replay({
|
.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 5,
|
seq: 5,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -832,14 +831,14 @@ describe("Bus", () => {
|
|||||||
|
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(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).toBe(0)
|
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -868,7 +867,7 @@ describe("Bus", () => {
|
|||||||
const exit = yield* bus
|
const exit = yield* bus
|
||||||
.replay({
|
.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: "unknown.event.1",
|
type: "unknown.event.1",
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID: Event.ID.create(),
|
aggregateID: Event.ID.create(),
|
||||||
@@ -896,7 +895,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -916,7 +915,7 @@ describe("Bus", () => {
|
|||||||
const id = Event.ID.create()
|
const id = Event.ID.create()
|
||||||
const replayed = {
|
const replayed = {
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -973,7 +972,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1002,7 +1001,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1013,7 +1012,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 2,
|
seq: 2,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1046,7 +1045,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1059,7 +1058,7 @@ describe("Bus", () => {
|
|||||||
.replay(
|
.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1081,7 +1080,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: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1102,7 +1101,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: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1127,7 +1126,7 @@ describe("Bus", () => {
|
|||||||
const id = Event.ID.create()
|
const id = Event.ID.create()
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1137,7 +1136,7 @@ describe("Bus", () => {
|
|||||||
const exit = yield* bus
|
const exit = yield* bus
|
||||||
.replay({
|
.replay({
|
||||||
id,
|
id,
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1160,7 +1159,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1171,7 +1170,7 @@ describe("Bus", () => {
|
|||||||
yield* bus.replay(
|
yield* bus.replay(
|
||||||
{
|
{
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 1,
|
seq: 1,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
@@ -1233,7 +1232,7 @@ describe("Bus", () => {
|
|||||||
|
|
||||||
yield* bus.replay({
|
yield* bus.replay({
|
||||||
id: Event.ID.create(),
|
id: Event.ID.create(),
|
||||||
created: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Bus.versionedType(DurableMessage.type, 1),
|
type: Bus.versionedType(DurableMessage.type, 1),
|
||||||
seq: 0,
|
seq: 0,
|
||||||
aggregateID,
|
aggregateID,
|
||||||
|
|||||||
@@ -102,35 +102,6 @@ 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(() => {
|
||||||
@@ -307,7 +278,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.activation = "disabled"
|
provider.disabled = true
|
||||||
})
|
})
|
||||||
catalog.model.update(disabledProvider, disabledModel, () => {})
|
catalog.model.update(disabledProvider, disabledModel, () => {})
|
||||||
catalog.provider.update(enabledProvider, () => {})
|
catalog.provider.update(enabledProvider, () => {})
|
||||||
|
|||||||
@@ -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.activation).toBe("enabled")
|
expect(provider.disabled).toBeUndefined()
|
||||||
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,7 +13,6 @@ 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>(
|
||||||
@@ -74,27 +73,6 @@ 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(
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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([LocationMutation.node, FileMutation.node]), [
|
AppNodeBuilder.build(LayerNode.group([LocationPath.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* LocationMutation.Service).resolve({ path: "hello.txt" })
|
const target = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({
|
const target = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({ path: "preserved.txt" })
|
const preserved = yield* (yield* LocationPath.Service).resolve({ path: "preserved.txt" })
|
||||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
const created = yield* (yield* LocationPath.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* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationPath.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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* LocationMutation.Service
|
const mutation = yield* LocationPath.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" })
|
||||||
|
|||||||
+59
-18
@@ -4,18 +4,26 @@ 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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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) {
|
function provide(directory: string, projectDirectory = directory) {
|
||||||
return Effect.provide(
|
return Effect.provide(
|
||||||
LayerNode.compile(LocationMutation.node, [
|
LayerNode.compile(LocationPath.node, [
|
||||||
[
|
[
|
||||||
Location.node,
|
Location.node,
|
||||||
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
|
Layer.succeed(
|
||||||
|
Location.Service,
|
||||||
|
Location.Service.of(
|
||||||
|
location(
|
||||||
|
{ directory: AbsolutePath.make(directory) },
|
||||||
|
{ projectDirectory: AbsolutePath.make(projectDirectory) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
@@ -28,13 +36,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("LocationMutation", () => {
|
describe("LocationPath", () => {
|
||||||
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* LocationMutation.Service).resolve({ path: "hello.txt" })
|
const target = yield* (yield* LocationPath.Service).resolve({ path: "hello.txt" })
|
||||||
|
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: targetPath,
|
absolute: targetPath,
|
||||||
@@ -49,7 +57,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: path.join("src", "new.txt") })
|
const target = yield* (yield* LocationPath.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",
|
||||||
@@ -58,10 +66,43 @@ describe("LocationMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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* LocationMutation.Service).resolve({ path: "../outside.txt" })
|
const target = yield* (yield* LocationPath.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"),
|
||||||
@@ -84,7 +125,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: path.join("escape", "new.txt") })
|
const target = yield* (yield* LocationPath.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",
|
||||||
@@ -104,7 +145,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: "linked/new.txt" })).toMatchObject({
|
expect(yield* (yield* LocationPath.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",
|
||||||
})
|
})
|
||||||
@@ -116,7 +157,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationPath.Service).resolve({ path: targetPath })
|
||||||
expect(target).toMatchObject({
|
expect(target).toMatchObject({
|
||||||
absolute: targetPath,
|
absolute: targetPath,
|
||||||
resource: "new.txt",
|
resource: "new.txt",
|
||||||
@@ -131,7 +172,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationPath.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"),
|
||||||
@@ -152,7 +193,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationPath.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)),
|
||||||
@@ -164,7 +205,7 @@ describe("LocationMutation", () => {
|
|||||||
withTmp((directory) =>
|
withTmp((directory) =>
|
||||||
withTmp((outside) =>
|
withTmp((outside) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const target = yield* (yield* LocationMutation.Service).resolve({ path: outside, kind: "file" })
|
const target = yield* (yield* LocationPath.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("\\", "/"),
|
||||||
@@ -179,7 +220,7 @@ describe("LocationMutation", () => {
|
|||||||
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* LocationMutation.Service).resolve({ path: targetPath })
|
const target = yield* (yield* LocationPath.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,
|
||||||
@@ -190,9 +231,9 @@ describe("LocationMutation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
test("ignores unknown mutation input fields", () => {
|
test("ignores unknown path input fields", () => {
|
||||||
expect(Object.keys(LocationMutation.ResolveInput.fields)).toEqual(["path", "kind"])
|
expect(Object.keys(LocationPath.ResolveInput.fields)).toEqual(["path", "kind"])
|
||||||
expect(Schema.decodeUnknownSync(LocationMutation.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
expect(Schema.decodeUnknownSync(LocationPath.ResolveInput)({ path: "README.md", reference: "docs" })).toEqual({
|
||||||
path: "README.md",
|
path: "README.md",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -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: 0,
|
created: DateTime.makeUnsafe(0),
|
||||||
type: Event.Updated.type,
|
type: Event.Updated.type,
|
||||||
data: {},
|
data: {},
|
||||||
} satisfies Payload<typeof Event.Updated>)
|
} satisfies Payload<typeof Event.Updated>)
|
||||||
|
|||||||
@@ -2,16 +2,13 @@ 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, Layer } from "effect"
|
import { Effect } 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 {
|
||||||
@@ -272,109 +269,6 @@ 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(
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ 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: [
|
||||||
@@ -110,7 +109,6 @@ 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,7 +64,6 @@ 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" },
|
||||||
},
|
},
|
||||||
@@ -240,7 +239,6 @@ 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: [],
|
||||||
@@ -332,7 +330,6 @@ 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" },
|
||||||
},
|
},
|
||||||
@@ -388,7 +385,6 @@ 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.activation = "disabled"
|
provider.disabled = true
|
||||||
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")))?.activation).toBe("disabled")
|
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
|
||||||
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -347,8 +347,6 @@ 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)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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: event.created,
|
created: DateTime.makeUnsafe(event.created),
|
||||||
aggregateID: event.aggregate_id,
|
aggregateID: event.aggregate_id,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
@@ -840,15 +840,7 @@ describe("SessionTransfer", () => {
|
|||||||
|
|
||||||
const imported = yield* transfer.import({
|
const imported = yield* transfer.import({
|
||||||
data: {
|
data: {
|
||||||
info: {
|
info: { ...template, id: sessionID },
|
||||||
...template,
|
|
||||||
id: sessionID,
|
|
||||||
time: {
|
|
||||||
...template.time,
|
|
||||||
idle: DateTime.makeUnsafe(200),
|
|
||||||
viewed: DateTime.makeUnsafe(150),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
id: sourceMessageID,
|
id: sourceMessageID,
|
||||||
@@ -871,18 +863,13 @@ 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)
|
||||||
const exported = yield* transfer.export({ sessionID })
|
expect((yield* transfer.export({ sessionID })).messages).toEqual(messages)
|
||||||
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
|
expect((yield* transfer.export({ sessionID, sanitize: true })).messages).toMatchObject([
|
||||||
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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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,
|
||||||
LocationMutation.node,
|
LocationPath.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,
|
||||||
LocationMutation.node,
|
LocationPath.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
readToolNode,
|
readToolNode,
|
||||||
Tool.node,
|
Tool.node,
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ 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(
|
||||||
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
|
|||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
executionCalls.push(sessionID)
|
executionCalls.push(sessionID)
|
||||||
}),
|
}),
|
||||||
interrupt: (sessionID, options) =>
|
interrupt: (sessionID) =>
|
||||||
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,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("forwards interrupt continuation policy", () =>
|
it.effect("continues after interruption when pending work remains", () =>
|
||||||
|
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([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -760,7 +770,7 @@ describe("Session.prompt", () => {
|
|||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
recorded.map((event) => ({
|
recorded.map((event) => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
created: event.created,
|
created: DateTime.makeUnsafe(event.created),
|
||||||
aggregateID: event.aggregate_id,
|
aggregateID: event.aggregate_id,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
|
|||||||
@@ -269,35 +269,6 @@ 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* () {
|
||||||
@@ -371,193 +342,6 @@ 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,8 +126,7 @@ 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,
|
||||||
wakeActive: coordinator.wakeActive,
|
interrupt: coordinator.interrupt,
|
||||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ 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"
|
||||||
@@ -203,82 +201,6 @@ 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(
|
||||||
|
|||||||
@@ -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, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
|
import { Cause, DateTime, 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,8 +413,7 @@ const execution = Layer.effect(
|
|||||||
active: coordinator.active,
|
active: coordinator.active,
|
||||||
resume: coordinator.run,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
wakeActive: coordinator.wakeActive,
|
interrupt: coordinator.interrupt,
|
||||||
interrupt: (sessionID) => coordinator.interrupt(sessionID),
|
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -672,7 +671,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: event.created,
|
created: DateTime.makeUnsafe(event.created),
|
||||||
aggregateID: event.aggregate_id,
|
aggregateID: event.aggregate_id,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
@@ -686,7 +685,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
|
||||||
@@ -748,6 +747,7 @@ 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,37 +766,20 @@ 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 = fixture.delta
|
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
|
||||||
? 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 = fixture.delta
|
const deltas = yield* db
|
||||||
? yield* db
|
.select({ type: EventTable.type })
|
||||||
.select({ type: EventTable.type })
|
.from(EventTable)
|
||||||
.from(EventTable)
|
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
|
||||||
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
|
.all()
|
||||||
.all()
|
.pipe(Effect.orDie)
|
||||||
.pipe(Effect.orDie)
|
expect(Array.from(yield* Fiber.join(live))).toHaveLength(32)
|
||||||
: []
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -1400,44 +1383,6 @@ 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
|
||||||
@@ -1479,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
|
|||||||
yield* Effect.forEach(
|
yield* Effect.forEach(
|
||||||
recorded.map((event) => ({
|
recorded.map((event) => ({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
created: event.created,
|
created: DateTime.makeUnsafe(event.created),
|
||||||
aggregateID: event.aggregate_id,
|
aggregateID: event.aggregate_id,
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
type: event.type,
|
type: event.type,
|
||||||
@@ -3143,24 +3088,6 @@ 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
|
||||||
@@ -5235,11 +5162,8 @@ describe("SessionRunnerLLM", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const kind of fragmentKinds) {
|
for (const kind of fragmentKinds) {
|
||||||
it.effect(
|
it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () =>
|
||||||
kind === "tool input"
|
verifyEphemeralDeltas(kind),
|
||||||
? "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))
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
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)))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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,
|
||||||
LocationMutation.node,
|
LocationPath.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, LocationMutation.node, FileMutation.node, editToolNode]),
|
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, editToolNode]),
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
Environment.node,
|
Environment.node,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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,
|
||||||
LocationMutation.node,
|
LocationPath.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, LocationMutation.node, FileMutation.node, patchToolNode]), [
|
AppNodeBuilder.build(LayerNode.group([Tool.node, LocationPath.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 external to the Location", () =>
|
it.live("treats a sibling path inside the project worktree as internal", () =>
|
||||||
Effect.acquireUseRelease(
|
Effect.acquireUseRelease(
|
||||||
Effect.promise(() => tmpdir()),
|
Effect.promise(() => tmpdir()),
|
||||||
(tmp) => {
|
(tmp) => {
|
||||||
@@ -939,9 +939,8 @@ 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(["external_directory", "edit"])
|
expect(assertions.map((input) => input.action)).toEqual(["edit"])
|
||||||
expect(assertions[0]?.resources).toEqual([path.join(tmp.path, "*").replaceAll("\\", "/")])
|
expect(assertions[0]?.resources).toEqual(["../sibling.txt"])
|
||||||
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,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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,
|
||||||
LocationMutation.node,
|
LocationPath.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(
|
||||||
LocationMutation.Service,
|
LocationPath.Service,
|
||||||
LocationMutation.Service.of({
|
LocationPath.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],
|
||||||
[LocationMutation.node, mutation],
|
[LocationPath.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 })],
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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, LocationMutation.node, Permission.node],
|
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.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, LocationMutation.node, Permission.node],
|
deps: [Tool.node, Environment.node, Ripgrep.node, Location.node, LocationPath.node, Permission.node],
|
||||||
})
|
})
|
||||||
const sessionID = Session.ID.make("ses_search_tool_test")
|
const sessionID = Session.ID.make("ses_search_tool_test")
|
||||||
|
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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,7 +115,6 @@ 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),
|
||||||
})
|
})
|
||||||
@@ -133,7 +132,7 @@ const shellPluginSupervisor = makeLocationNode({
|
|||||||
deps: [
|
deps: [
|
||||||
Config.node,
|
Config.node,
|
||||||
Environment.node,
|
Environment.node,
|
||||||
LocationMutation.node,
|
LocationPath.node,
|
||||||
Permission.node,
|
Permission.node,
|
||||||
PluginRuntime.node,
|
PluginRuntime.node,
|
||||||
Shell.node,
|
Shell.node,
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ 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),
|
||||||
})
|
})
|
||||||
@@ -298,7 +297,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]).toEqual({ sessionID: child.id, status: "running" })
|
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
|
||||||
expect(child).toMatchObject({
|
expect(child).toMatchObject({
|
||||||
parentID: parent.id,
|
parentID: parent.id,
|
||||||
location: parent.location,
|
location: parent.location,
|
||||||
|
|||||||
@@ -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 { LocationMutation } from "@opencode-ai/core/location-mutation"
|
import { LocationPath } from "@opencode-ai/core/location-path"
|
||||||
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, LocationMutation.node, FileMutation.node, Environment.node, Formatter.node, Permission.node],
|
deps: [Tool.node, LocationPath.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, LocationMutation.node, FileMutation.node, writeToolNode]),
|
LayerNode.group([Tool.node, Tool.node, LocationPath.node, FileMutation.node, writeToolNode]),
|
||||||
[
|
[
|
||||||
[
|
[
|
||||||
Environment.node,
|
Environment.node,
|
||||||
|
|||||||
@@ -60,8 +60,6 @@ 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,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
|
|
||||||
import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
|
import { 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" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
|
if (channel === "beta") await downloadCliToResources("next")
|
||||||
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
|
|
||||||
|
|||||||
@@ -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 = "dev"
|
const CLI_VERSION = "0.0.0-next-16365"
|
||||||
|
|
||||||
export type Channel = "dev" | "beta" | "prod"
|
export type Channel = "dev" | "beta" | "prod"
|
||||||
|
|
||||||
@@ -74,28 +74,18 @@ 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 copyCliToResources(
|
await copyFile(
|
||||||
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-dev-16365"]])
|
expect(installs).toEqual([["Debian", "0.0.0-next-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-dev-older",
|
readCliVersion: async () => "0.0.0-next-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-dev-older; expected 0.0.0-dev-16365",
|
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-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-dev-16365" },
|
cli: { version: "0.0.0-next-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-dev-16365",
|
readCliVersion: async () => "0.0.0-next-16365",
|
||||||
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3964,7 +3964,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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.",
|
"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.",
|
||||||
"summary": "Interrupt session execution"
|
"summary": "Interrupt session execution"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4127,65 +4127,6 @@
|
|||||||
"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"],
|
||||||
@@ -12194,12 +12135,6 @@
|
|||||||
"updated": {
|
"updated": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
"idle": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"viewed": {
|
|
||||||
"type": "number"
|
|
||||||
},
|
|
||||||
"archived": {
|
"archived": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -14341,71 +14276,6 @@
|
|||||||
"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": {
|
||||||
@@ -17426,9 +17296,6 @@
|
|||||||
{
|
{
|
||||||
"$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"
|
||||||
},
|
},
|
||||||
@@ -17861,9 +17728,8 @@
|
|||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"activation": {
|
"disabled": {
|
||||||
"type": "string",
|
"type": "boolean"
|
||||||
"enum": ["auto", "enabled", "disabled"]
|
|
||||||
},
|
},
|
||||||
"package": {
|
"package": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -17881,7 +17747,7 @@
|
|||||||
"type": "object"
|
"type": "object"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["id", "name", "activation", "package"],
|
"required": ["id", "name", "package"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"ProviderNotFoundError": {
|
"ProviderNotFoundError": {
|
||||||
@@ -22797,9 +22663,6 @@
|
|||||||
{
|
{
|
||||||
"$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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
"@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
Reference in New Issue
Block a user