Compare commits

..

38 Commits

Author SHA1 Message Date
Aiden Cline 654f4ede55 fix(tui): drop unavailable saved variants 2026-08-16 04:14:49 +00:00
Dax Raad 8251934007 fix(core): batch initial streamed delta 2026-08-15 19:26:16 -04:00
Dax Raad 42e345e1bc fix(cli): resolve Bun canary compile assets 2026-08-15 18:56:03 -04:00
Dax 6a7d6c5adc refactor(core): use numeric event timestamps (#42828) 2026-08-15 18:48:58 -04:00
Dax Raad 9e22c40fff feat: align V2 beta release channels 2026-08-15 18:40:23 -04:00
opencode-agent[bot] 467722c2f9 fix(app): use tree directory picker everywhere (#42820)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-16 08:19:44 +10:00
Luke Parker d9dfceddf8 fix(app): release virtualized timeline elements (#42825) 2026-08-16 08:19:06 +10:00
Dax 4fee4d7d86 fix(core): batch streamed session deltas 2026-08-15 18:16:01 -04:00
Luke Parker 61d7f942b5 fix(app): show new session header immediately (#42822) 2026-08-16 08:04:28 +10:00
Dax Raad d8e1753330 chore(cli): build binaries with Bun 1.4 2026-08-15 17:51:09 -04:00
opencode-agent[bot] d94d520f45 fix(tui): make running subagents clickable (#42797)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 19:54:40 +00:00
Dax Raad fcc1e9c42f docs(core): add MCP setup guidance 2026-08-15 15:13:28 -04:00
opencode-agent[bot] 64b9ba339e docs(console): prohibit abusive multi-account use (#42813)
Co-authored-by: Dax Raad <mail@thdxr.com>
2026-08-15 15:08:52 -04:00
opencode-agent[bot] 73700065bf chore: generate 2026-08-15 18:35:53 +00:00
Kit Langton 07a82a442e fix(core): keep queued work parked after interrupt (#42682) 2026-08-15 18:34:43 +00:00
Kit Langton 55be895e14 fix(tui): scope session picker by directory (#42804) 2026-08-15 18:30:35 +00:00
Kit Langton 58a36a1560 fix(tui): preserve path suffix in prompt footer (#42800) 2026-08-15 18:21:07 +00:00
Dax 3d782efee4 docs(core): fix opencode skill source link (#42792) 2026-08-15 16:37:34 +00:00
opencode-agent[bot] c054bb183e chore: generate 2026-08-15 16:30:19 +00:00
Dax 0d7904c91f refactor(core): model provider activation explicitly (#42791)
Co-authored-by: Michael Hart <mhart@cloudflare.com>
2026-08-15 16:29:09 +00:00
opencode-agent[bot] 75ec0b454c chore: generate 2026-08-15 12:13:32 +00:00
Luke Parker f4baba2824 fix(app): use fixed message page size (#42743) 2026-08-15 22:12:20 +10:00
Luke Parker 79fc74afbf fix(app): show skill names (#42749) 2026-08-15 20:20:53 +10:00
Luke Parker 30dfe5352c fix(app): show read tool filenames (#42740) 2026-08-15 20:05:13 +10:00
Luke Parker f725443e30 fix(core): normalize Windows location keys (#42745) 2026-08-15 19:56:43 +10:00
opencode-agent[bot] cae205a3d9 fix(core): bound fuzzy search memory (#42741)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 19:40:24 +10:00
Luke Parker d35c6f04ed fix(ui): add keybind label padding (#42726) 2026-08-15 17:54:31 +10:00
opencode-agent[bot] 4d021b4660 fix(app): fade tab titles beneath close button (#42730)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-15 17:10:56 +10:00
Luke Parker f63f912178 test(app): typecheck complete e2e suite (#42728) 2026-08-15 17:06:58 +10:00
opencode-agent[bot] 6564d1442a chore: generate 2026-08-15 06:56:31 +00:00
Luke Parker a5f3e9e735 fix(app): preserve assistant content order (#42721) 2026-08-15 16:55:22 +10:00
Luke Parker d24a24a2ca fix(app): route global events without directory sentinel (#42719) 2026-08-15 16:47:37 +10:00
Luke Parker 8610d90838 fix(app): project sent messages through inbox events (#42714) 2026-08-15 15:57:46 +10:00
Luke Parker 57b050e9fc fix(desktop): render v2 patch metadata (#42716) 2026-08-15 15:57:34 +10:00
Luke Parker 51091be7e4 fix(desktop): verify updates before install (#42715) 2026-08-15 14:39:01 +10:00
Luke Parker c8584ec0c8 fix(app): derive popular providers from integrations (#42713) 2026-08-15 14:29:19 +10:00
Luke Parker 7301c5e798 fix(desktop): keep staged updates fresh with silent re-checks (#42707) 2026-08-15 14:22:24 +10:00
Luke Parker 41f70bfbb1 fix(app): generate blob ids without crypto.subtle in non-secure contexts (#42706)
Co-authored-by: OpenCode Vite Investigation <opencode-vite-investigation@local>
2026-08-15 14:12:57 +10:00
152 changed files with 3379 additions and 3771 deletions
+10 -3
View File
@@ -1,6 +1,10 @@
name: "Setup Bun"
description: "Setup Bun with caching and install dependencies"
inputs:
bun-version:
description: "Bun version to install instead of the root packageManager version"
required: false
default: ""
install-flags:
description: "Additional flags to pass to 'bun install'"
required: false
@@ -20,19 +24,22 @@ runs:
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
V="${{ inputs.bun-version }}"
if [ -z "$V" ]; then V=$(node -p "require('./package.json').packageManager.split('@')[1]"); fi
TAG=$([ "$V" = "canary" ] && echo "canary" || echo "bun-v${V}")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
echo "url=https://github.com/oven-sh/bun/releases/download/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-version: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
-37
View File
@@ -1,37 +0,0 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
+22 -5
View File
@@ -7,6 +7,7 @@ on:
- ci
- dev
- beta
- v2
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
@@ -32,7 +33,7 @@ permissions:
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
jobs:
version:
@@ -45,6 +46,13 @@ jobs:
- 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
id: committer
uses: ./.github/actions/setup-git-committer
@@ -74,13 +82,15 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
with:
bun-version: canary # Bun 1.4 until its stable release is published
- name: Setup git committer
id: committer
@@ -102,6 +112,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: canary
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
@@ -185,7 +196,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
@@ -335,6 +346,7 @@ jobs:
build-electron:
needs:
- version
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
@@ -373,6 +385,12 @@ jobs:
steps:
- 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
if: runner.os == 'macOS'
with:
@@ -431,6 +449,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
- name: Build
run: bun run build
@@ -569,13 +588,11 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
pattern: opencode-node-cli-*
path: packages/cli/dist/node
+1 -1
View File
@@ -9,7 +9,7 @@
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## V2 TUI Stories
+3 -3
View File
@@ -15,13 +15,13 @@ Usage: install.sh [options]
Options:
-h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 0.0.0-next-17236)
-v, --version <version> Install a specific version (e.g., 0.0.0-beta-17236)
-b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples:
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-next-17236
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-beta-17236
./install --binary /path/to/opencode2
EOF
}
@@ -166,7 +166,7 @@ else
fi
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/next || true)
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
if [ -z "$specific_version" ]; then
+1 -1
View File
@@ -8,7 +8,7 @@
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
+1 -1
View File
@@ -86,7 +86,7 @@ async function writeProtocolStream(session: CDPSession, handle: string, file: st
try {
while (true) {
const chunk = await session.send("IO.read", { handle })
await output.write(chunk.base64Encoded ? Buffer.from(chunk.data, "base64") : chunk.data)
await (chunk.base64Encoded ? output.write(Buffer.from(chunk.data, "base64")) : output.write(chunk.data))
if (chunk.eof) break
}
} finally {
@@ -125,17 +125,20 @@ export async function installTimelineStreamProbe(
const scrollTo = Element.prototype.scrollTo
const scrollTop = Object.getOwnPropertyDescriptor(Element.prototype, "scrollTop")!
if (profileVisual) {
Element.prototype.scrollTo = function (...args) {
function measuredScrollTo(this: Element, options?: ScrollToOptions): void
function measuredScrollTo(this: Element, x: number, y: number): void
function measuredScrollTo(this: Element, first?: number | ScrollToOptions, second?: number) {
state.scroll.calls += 1
const top = typeof args[0] === "object" ? args[0]?.top : args[1]
const top = typeof first === "object" ? first?.top : second
if (typeof top === "number") {
const target = Math.min(top, this.scrollHeight - this.clientHeight)
if (Math.abs(this.scrollTop - target) < 1) state.scroll.callNoops += 1
}
if (state.scroll.lastCallFrame === state.scroll.frame) state.scroll.sameFrameCalls += 1
state.scroll.lastCallFrame = state.scroll.frame
return scrollTo.apply(this, args)
Reflect.apply(scrollTo, this, typeof first === "number" ? [first, second] : [first])
}
Element.prototype.scrollTo = measuredScrollTo
Object.defineProperty(Element.prototype, "scrollTop", {
configurable: true,
get: scrollTop.get,
@@ -267,18 +267,19 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
userMessage(childID, index + 2000, 120),
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = {
[sourceID]: sourceMessages,
[targetID]: targetMessages,
[childID]: childMessages,
}
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function orderedParts(message: Message) {
return message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
export const fixture = {
directory,
project: {
@@ -333,7 +334,7 @@ export const fixture = {
sourceID,
targetID,
childID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages, [childID]: childMessages },
messages,
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -345,16 +346,12 @@ export const fixture = {
.filter((message) => message.info.role === "user")
.map((message) => message.info.id),
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
targetPartIDs: targetMessages.flatMap((message) =>
orderedParts(message)
.filter(renderable)
.map((part) => part.id),
),
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
},
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const messages = fixture.messages[sessionID] ?? []
const end = before
? Math.max(
0,
@@ -364,6 +361,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
cursor: start > 0 ? messages[start].info.id : undefined,
}
}
@@ -1,60 +0,0 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -220,7 +220,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -16,9 +16,8 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 200
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const messagePageSize = 200
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [
userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
@@ -26,7 +25,7 @@ const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
parentID: id,
created: 1700000001000 + index * 2_000,
completed: index < initialPageSize,
completed: index < messagePageSize / 2,
}),
]
}).flat()
@@ -160,21 +159,18 @@ for (const scenario of scenarios) {
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover()
const deadline = Date.now() + 10_000
const deadline = Date.now() + 30_000
while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.mouse.wheel(0, -1_200)
await page.waitForTimeout(20)
}
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest",
"messages:end:latest",
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
])
await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => {
;(
window as Window & {
@@ -186,15 +182,12 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page)
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(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory)
expect(pages).toEqual([
{ before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
{ before: undefined, limit: messagePageSize },
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
])
expect(roots).toEqual([])
@@ -82,6 +82,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
file: "src/retry.ts",
additions: 1,
deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const retry = false\n+export const retry = true",
},
],
@@ -1,28 +1,27 @@
import { expect, test } from "@playwright/test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture"
const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string) =>
({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
}) satisfies SessionMessageInfo
const assistant = (completed: boolean, tool = false, childID?: string): SessionMessageAssistant => ({
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: tool
? [
{
type: "tool",
id: "call_subagent",
name: "subagent",
state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} },
time: { created: 2 },
},
]
: [{ type: "text", text: "Working" }],
time: { created: 2, ...(completed ? { completed: 3 } : {}) },
})
test("renders current protocol notices in CLI order", async ({ page }) => {
const ownerWarnings: string[] = []
@@ -280,6 +280,7 @@ function summaryDiff(index: number) {
file: `src/diff-${index}.ts`,
additions: 1,
deletions: 1,
status: "modified" as const,
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
}
}
@@ -23,11 +23,10 @@ test("groups singleton and separated context operations at correct boundaries",
]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(
page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5)
})
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {
@@ -131,6 +131,7 @@ test("allows paint rounding for every framed row but not fixed turn gaps", async
file: "src/summary.ts",
additions: 1,
deletions: 1,
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
},
],
@@ -83,6 +83,40 @@ test("labels all web search provider variants", async ({ page }) => {
await expect(page.getByRole("button", { name: /^Web Search/ })).toBeVisible()
})
test("labels V2 read tools from their path input", async ({ page }) => {
const id = "prt_read_path"
await setupTimeline(page, {
messages: [userMessage(), assistantMessage([toolPart(id, "read", "completed", { path: "src/a.ts" })])],
})
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
})
test("labels V2 skill tools from IDs and result metadata", async ({ page }) => {
const pending = "prt_skill_id"
const completed = "prt_skill_name"
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
await expect(page.locator(`[data-timeline-part-id="${pending}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"sample-skill",
)
await expect(page.locator(`[data-timeline-part-id="${completed}"] [data-component="text-shimmer"]`)).toHaveAttribute(
"aria-label",
"OpenCode",
)
})
function questionInput() {
return { questions: [{ header: "Stability", question: "Keep it stable?", options: [] }] }
}
@@ -21,7 +21,7 @@ type EventPayload = {
payload: Record<string, unknown>
}
test.use({ viewport: { width: 1440, height: 900 }, reducedMotion: "no-preference" })
test.use({ viewport: { width: 1440, height: 900 } })
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
test.setTimeout(90_000)
@@ -57,7 +57,6 @@ test("animates todo opening without replaying it across session tabs", async ({
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
},
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
sessionStatus: { [sourceID]: { type: "busy" } },
pageMessages: () => ({ items: [] }),
events: () => events.splice(0, 1),
eventRetry: 16,
@@ -90,7 +90,8 @@ async function mockServer(page: Page) {
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
return new Promise(() => {})
if (url.pathname === "/api/event") return sse(route)
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} })
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
@@ -222,30 +222,29 @@ function turn(index: number): Message[] {
return [user, assistantMessage(targetID, index, user.info.id, parts)]
}
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
const sourceMessages = Array.from({ length: 12 }, (_, index) => [
userMessage(sourceID, index + 1000, 120),
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
]).flat()
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
function renderable(part: MessagePart) {
if (part.type === "tool" && part.tool === "todowrite") return false
if (part.type === "text") return !!part.text.trim()
if (part.type === "reasoning") return !!part.text.trim()
if (part.type === "text") return !!part.text?.trim()
if (part.type === "reasoning") return !!part.text?.trim()
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
}
function currentPartIDs(message: Message) {
const ordinals = { text: 0, reasoning: 0 }
return message.parts
.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
.sort()
return message.parts.flatMap((part) => {
if (!renderable(part)) return []
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
return []
})
}
export const fixture = {
@@ -292,7 +291,7 @@ export const fixture = {
],
sourceID,
targetID,
messages: { [sourceID]: sourceMessages, [targetID]: targetMessages },
messages,
expected: {
sourceTitle: "Uncommitted changes inquiry",
targetTitle: "Example Game: sample jump movement & sample physics analysis",
@@ -306,7 +305,7 @@ export const fixture = {
}
export function pageMessages(sessionID: string, limit: number, before?: string) {
const messages = fixture.messages[sessionID as keyof typeof fixture.messages] ?? []
const messages = fixture.messages[sessionID] ?? []
const end = before
? Math.max(
0,
@@ -316,6 +315,6 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
const start = Math.max(0, end - limit)
return {
items: messages.slice(start, end),
cursor: start > 0 ? messages[start]!.info.id : undefined,
cursor: start > 0 ? messages[start].info.id : undefined,
}
}
@@ -124,7 +124,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
@@ -188,7 +188,11 @@ test.describe("smoke: session timeline", () => {
const bottom = root
.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')
?.getBoundingClientRect()
samples.push({ ids: visible, last: visible.includes(last), bottomError: bottom?.bottom - view.bottom })
samples.push({
ids: visible,
last: visible.includes(last),
bottomError: bottom ? bottom.bottom - view.bottom : undefined,
})
if (
!firstPaint &&
visible.includes(last) &&
@@ -263,7 +267,7 @@ test.describe("smoke: session timeline", () => {
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID as keyof typeof fixture.messages] ?? [] }),
pageMessages: (sessionID) => ({ items: fixture.messages[sessionID] ?? [] }),
})
await configureSmokePage(page, fixture.directory)
await page.addInitScript(
@@ -723,7 +727,7 @@ function expectCompleteScroll(
).toEqual([])
expect(new Set(expectedPartIDs).size).toBe(expectedPartIDs.length)
expect(new Set(expectedMessageIDs).size).toBe(expectedMessageIDs.length)
expect(expectedPartIDs.length).toBe(331)
expect(expectedPartIDs.length).toBe(465)
}
async function selectHomeProject(page: Page, projectName: string) {
+3 -13
View File
@@ -1,21 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "..",
"types": ["node", "bun"]
},
"include": [
"./performance/timeline-stability/**/*.spec.ts",
"./performance/timeline-stability/fixture.test.ts",
"./performance/timeline-stability/fixture.ts",
"./performance/unit/visual-stability.test.ts",
"./reproduction/timeline-suspense/**/*.ts",
"./reproduction/timeline-suspense/**/*.tsx",
"../src/types.ts",
"../src/pages/session/timeline/observe-element-offset.ts",
"./regression/new-session-panel-corner.spec.ts",
"./regression/session-timeline-context-resize.spec.ts",
"./utils/**/*.ts"
]
"include": ["./**/*.ts", "./**/*.tsx", "../src/types.ts"]
}
@@ -72,7 +72,12 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.click()
await page.locator("[data-directory-path]").click()
const directoryItem = page.getByRole("treeitem", { name: "NewProject" })
await expect(directoryItem).toBeVisible()
await directoryItem.click()
const selectFolder = page.getByRole("button", { name: "Select folder" })
await expect(selectFolder).toBeEnabled()
await selectFolder.click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
@@ -1,205 +0,0 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
group: "recent" | "folders"
}
function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = displayPickerPath(absolute, "", "")
const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search, group }
}
function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
return true
})
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = createDirectorySearch({
sdk,
home,
base: start,
})
const recentProjects = createMemo(() => {
const projects = serverCtx.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {
if (session.time.archived) continue
const updated = session.time.updated ?? session.time.created
if (updated > at) at = updated
}
}
byProject.set(project.worktree, at)
}
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
return {
...row,
search: `${row.search}\n${name}`,
}
})
})
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
groupBy={(item) => item.group}
sortGroupsBy={(a, b) => {
if (a.category === b.category) return 0
return a.category === "recent" ? -1 : 1
}}
groupHeader={(group) =>
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
}}
>
{(item) => {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -1,9 +1,7 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/servers"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
import { DialogSelectDirectory } from "./dialog-select-directory"
import { directoryPickerKind } from "./directory-picker-policy"
const DialogSelectDirectoryV2 = lazy(() =>
@@ -19,7 +17,6 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() {
const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
@@ -36,10 +33,6 @@ export function useDirectoryPicker() {
const cancel = () => {
if (!selected) input.onSelect(null)
}
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
}
}
@@ -0,0 +1,321 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildPromptRequest } from "./build-prompt-request"
describe("buildPromptRequest", () => {
test("builds text, files, and agents from the prompt", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
expect(result.text).toContain("hello @src/foo.ts @planner")
expect(result.text).toContain("check this")
expect(result.displayText).toBe("hello @src/foo.ts @planner")
expect(result.comments).toMatchObject([{ path: "src/bar.ts", comment: "check this" }])
expect(result.agents).toEqual([{ name: "planner", mention: { start: 16, end: 24, text: "@planner" } }])
expect(result.files.some((file) => file.uri.startsWith("file:///repo/src/foo.ts"))).toBe(true)
expect(result.files.find((file) => file.uri.startsWith("file:///repo/src/foo.ts"))?.mention).toEqual({
start: 5,
end: 16,
text: "@src/foo.ts",
})
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
sessionDirectory: "/repo",
})
const uploads = result.files.filter((file) => file.uri.startsWith("data:"))
expect(uploads).toHaveLength(2)
expect(uploads.map((file) => file.name)).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.files[0]?.name).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory files", () => {
const result = buildPromptRequest({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
expect(result.files[0]).toEqual({
uri: "file:///repo/../docs",
mime: "application/x-directory",
name: "docs",
mention: { start: 0, end: 5, text: "@docs" },
})
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
const fooFiles = result.files.filter((file) => file.uri.startsWith("file:///repo/src/foo.ts"))
expect(fooFiles).toHaveLength(2)
expect(result.text).toContain("focus here")
})
test("adds files for @mentions inside comment text", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
sessionDirectory: "/repo",
})
expect(result.files).toHaveLength(2)
expect(result.files.some((file) => file.uri === "file:///repo/src/review.ts")).toBe(true)
expect(result.files.some((file) => file.uri === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(file!.uri).not.toContain("%5C")
// Should have normalized to forward slashes
expect(file!.uri).toContain("/src/foo.ts")
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const file = result.files[0]
expect(file).toBeDefined()
// URL should be parseable
expect(() => new URL(file!.uri)).not.toThrow()
// Special chars should be encoded
expect(file!.uri).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(file!.uri).toMatch(/file:\/\/\/[A-Z]:/)
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
expect(result.files[0]?.uri).toBe("file:///home/user/project/src/app.ts")
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
expect(result.files[0]?.uri).toBe("file:///Users/kelvin/Projects/opencode/README.md")
})
test("handles context files with Windows paths", () => {
const result = buildPromptRequest({
prompt: [],
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
expect(result.files).toHaveLength(2)
// All file URLs should be valid
result.files.forEach((file) => {
expect(() => new URL(file.uri)).not.toThrow()
expect(file.uri).not.toContain("%5C") // No encoded backslashes
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(file!.uri)).not.toThrow()
expect(file!.uri).toContain("/D:/other/project/file.ts")
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should have query parameters
expect(file!.uri).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Query params should parse correctly
const url = new URL(file!.uri)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildPromptRequest({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const file = result.files[0]
expect(file).toBeDefined()
// Should be valid URL
expect(() => new URL(file!.uri)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(file!.uri).toContain("/..")
})
})
@@ -0,0 +1,115 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { formatCommentNote, type PromptComment } from "@/utils/comment-note"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
text: string
displayText: string
files: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
return {
uri: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
mime: attachment.mime ?? "text/plain",
name: attachment.filename ?? getFilename(attachment.path),
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => ({
name: attachment.name,
mention: { start: attachment.start, end: attachment.end, text: attachment.content },
}))
const used = new Set(files.map((file) => file.uri))
const comments: PromptComment[] = []
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const uri = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(uri)) return []
used.add(uri)
const file = { uri, mime: "text/plain", name: getFilename(item.path) }
if (!comment) return [file]
comments.push({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
})
const mentions = parseCommentMentions(comment).flatMap((path) => {
const uri = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(uri)) return []
used.add(uri)
return [{ uri, mime: "text/plain", name: getFilename(path) }]
})
return [file, ...mentions]
})
const images = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
return {
text: [...(input.text.trim() ? [input.text] : []), ...comments.map(formatCommentNote)].join("\n"),
displayText: input.text,
files: [...files, ...context, ...images],
agents,
comments,
}
}
@@ -1,396 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { buildRequestParts } from "./build-request-parts"
describe("buildRequestParts", () => {
test("builds typed request and optimistic parts without cast path", () => {
const prompt: Prompt = [
{ type: "text", content: "hello", start: 0, end: 5 },
{
type: "file",
path: "src/foo.ts",
content: "@src/foo.ts",
start: 5,
end: 16,
selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
},
{ type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
]
const result = buildRequestParts({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
],
text: "hello @src/foo.ts @planner",
messageID: "msg_1",
sessionID: "ses_1",
sessionDirectory: "/repo",
})
expect(result.requestParts[0]?.type).toBe("text")
expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
expect(
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
).toBe(true)
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
expect(
result.requestParts.some(
(part) =>
part.type === "text" &&
part.synthetic &&
part.metadata?.opencodeComment &&
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
),
).toBe(true)
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
})
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("preserves an external attachment source path for the model", () => {
const result = buildRequestParts({
prompt: [],
context: [],
images: [
{
type: "image",
id: "img_external",
filename: "opencode.global.dat",
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
mime: "text/plain",
dataUrl: "data:text/plain;base64,AAA",
},
],
text: "inspect this",
messageID: "msg_external",
sessionID: "ses_external",
sessionDirectory: "C:\\Repos\\sst\\opencode",
})
expect(result.requestParts.find((part) => part.type === "file")?.filename).toBe(
"C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
)
})
test("preserves reference aliases as directory file parts", () => {
const result = buildRequestParts({
prompt: [
{
type: "file",
path: "/repo/../docs",
content: "@docs",
start: 0,
end: 5,
mime: "application/x-directory",
filename: "docs",
},
],
context: [],
images: [],
text: "@docs",
messageID: "msg_reference",
sessionID: "ses_reference",
sessionDirectory: "/repo/app",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
expect(filePart.mime).toBe("application/x-directory")
expect(filePart.filename).toBe("docs")
expect(filePart.url).toBe("file:///repo/../docs")
expect(filePart.source?.type).toBe("file")
if (filePart.source?.type === "file") {
expect(filePart.source.path).toBe("/repo/../docs")
expect(filePart.source.text.value).toBe("@docs")
}
}
})
test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
images: [],
text: "@src/foo.ts",
messageID: "msg_2",
sessionID: "ses_2",
sessionDirectory: "/repo",
})
const fooFiles = result.requestParts.filter(
(part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
)
const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
expect(fooFiles).toHaveLength(2)
expect(synthetic).toHaveLength(1)
})
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\foo.ts",
messageID: "msg_win_1",
sessionID: "ses_win_1",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
// Should create valid file URLs
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should not have encoded backslashes in wrong place
expect(filePart.url).not.toContain("%5C")
// Should have normalized to forward slashes
expect(filePart.url).toContain("/src/foo.ts")
}
})
test("handles Windows absolute path with special characters", () => {
const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@file#name.txt",
messageID: "msg_win_2",
sessionID: "ses_win_2",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
test("handles Linux absolute paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src/app.ts",
messageID: "msg_linux_1",
sessionID: "ses_linux_1",
sessionDirectory: "/home/user/project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///home/user/project/src/app.ts")
}
})
test("handles macOS paths correctly", () => {
const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@README.md",
messageID: "msg_mac_1",
sessionID: "ses_mac_1",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// URL should be parseable
expect(() => new URL(filePart.url)).not.toThrow()
// Should be a normal Unix path
expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md")
}
})
test("handles context files with Windows paths", () => {
const prompt: Prompt = []
const result = buildRequestParts({
prompt,
context: [
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
images: [],
text: "test",
messageID: "msg_win_ctx",
sessionID: "ses_win_ctx",
sessionDirectory: "D:\\workspace\\app",
})
const fileParts = result.requestParts.filter((part) => part.type === "file")
expect(fileParts).toHaveLength(2)
// All file URLs should be valid
fileParts.forEach((part) => {
if (part.type === "file") {
expect(() => new URL(part.url)).not.toThrow()
expect(part.url).not.toContain("%5C") // No encoded backslashes
}
})
})
test("handles absolute Windows paths (user manually specifies full path)", () => {
const prompt: Prompt = [
{ type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@D:\\other\\project\\file.ts",
messageID: "msg_abs",
sessionID: "ses_abs",
sessionDirectory: "C:\\current\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
test("handles selection with query parameters on Windows", () => {
const prompt: Prompt = [
{
type: "file",
path: "src\\App.tsx",
content: "@src\\App.tsx",
start: 0,
end: 11,
selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 },
},
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@src\\App.tsx",
messageID: "msg_sel",
sessionID: "ses_sel",
sessionDirectory: "C:\\project",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should have query parameters
expect(filePart.url).toContain("?start=10&end=20")
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Query params should parse correctly
const url = new URL(filePart.url)
expect(url.searchParams.get("start")).toBe("10")
expect(url.searchParams.get("end")).toBe("20")
}
})
test("handles file paths with dots and special segments on Windows", () => {
const prompt: Prompt = [
{ type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 },
]
const result = buildRequestParts({
prompt,
context: [],
images: [],
text: "@..\\..\\shared\\util.ts",
messageID: "msg_dots",
sessionID: "ses_dots",
sessionDirectory: "C:\\projects\\myapp\\src",
})
const filePart = result.requestParts.find((part) => part.type === "file")
expect(filePart).toBeDefined()
if (filePart?.type === "file") {
// Should be valid URL
expect(() => new URL(filePart.url)).not.toThrow()
// Should preserve .. segments (backend normalizes)
expect(filePart.url).toContain("/..")
}
})
})
@@ -1,216 +0,0 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@/types"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
type PromptRequestPart =
| (Omit<TextPart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<FilePart, "id" | "sessionID" | "messageID"> & { id: string })
| (Omit<MessageAgentPart, "id" | "sessionID" | "messageID"> & { id: string })
type ContextFile = {
key: string
type: "file"
path: string
selection?: FileSelection
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
preview?: string
}
type BuildRequestPartsInput = {
prompt: Prompt
context: ContextFile[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
messageID: string
sessionID: string
sessionDirectory: string
}
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
if (part.type === "text") {
return {
id: part.id,
type: "text",
text: part.text,
synthetic: part.synthetic,
ignored: part.ignored,
time: part.time,
metadata: part.metadata,
sessionID,
messageID,
}
}
if (part.type === "file") {
return {
id: part.id,
type: "file",
mime: part.mime,
filename: part.filename,
url: part.url,
source: part.source,
sessionID,
messageID,
}
}
return {
id: part.id,
type: "agent",
name: part.name,
source: part.source,
sessionID,
messageID,
}
}
export function buildRequestParts(input: BuildRequestPartsInput) {
const requestParts: PromptRequestPart[] = input.text.trim()
? [
{
id: Identifier.ascending("part"),
type: "text",
text: input.text,
},
]
: []
const files = input.prompt.filter(isFileAttachment).map((attachment) => {
const path = absolute(input.sessionDirectory, attachment.path)
const source = attachment.source
? {
...attachment.source,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}
: {
type: "file" as const,
text: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
path,
}
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime ?? "text/plain",
url: attachment.url ?? `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
filename: attachment.filename ?? getFilename(attachment.path),
source,
} satisfies PromptRequestPart
})
const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "agent",
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
} satisfies PromptRequestPart
})
const used = new Set(files.map((part) => part.url))
const context = input.context.flatMap((item) => {
const path = absolute(input.sessionDirectory, item.path)
const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
const comment = item.comment?.trim()
if (!comment && used.has(url)) return []
used.add(url)
const filePart = {
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(item.path),
} satisfies PromptRequestPart
if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [
{
id: Identifier.ascending("part"),
type: "text",
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
synthetic: true,
metadata: createCommentMetadata({
path: item.path,
selection: item.selection,
comment,
preview: item.preview,
origin: item.commentOrigin,
}),
} satisfies PromptRequestPart,
filePart,
...mentions,
]
})
const images = input.images.map((attachment) => {
return {
id: Identifier.ascending("part"),
type: "file",
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.sourcePath ?? attachment.filename,
} satisfies PromptRequestPart
})
requestParts.push(...files, ...context, ...agents, ...images)
return {
requestParts,
optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
}
}
@@ -11,15 +11,17 @@ type SessionCreateInput = {
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}
const optimistic: Array<{
const admitted: Array<{
directory?: string
sessionID?: string
message: {
agent: string
model: { providerID: string; modelID: string }
variant?: string
}
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}> = []
const confirmed: unknown[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
@@ -35,9 +37,11 @@ const switchedModels: Array<{
const sessionRequestOrder: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const admittedServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0
let restoredPrompts = 0
let clearEchoCalls = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
@@ -47,6 +51,8 @@ let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let locationFailure: Error | undefined
let promptFailure: Error | undefined
let clearEchoResult = true
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
@@ -74,7 +80,7 @@ const prompt = {
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
set: () => restoredPrompts++,
context: {
add: () => undefined,
remove: () => undefined,
@@ -116,7 +122,16 @@ const clientFor = (directory: string) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
return { data: undefined }
if (promptFailure) throw promptFailure
const prompt = input as { sessionID: string; id: string; text: string }
return {
id: prompt.id,
sessionID: prompt.sessionID,
timeCreated: 1,
type: "user" as const,
delivery: "steer" as const,
payload: { text: prompt.text },
}
},
switchAgent: async (input: { sessionID: string; agent: string }) => {
sessionRequestOrder.push("agent")
@@ -235,16 +250,27 @@ beforeAll(async () => {
return {
data: { command: commands, project: "project" },
session: {
optimistic: {
add: (value: {
inbox: {
echo: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
comments: unknown[]
}) => {
optimisticServers.push(server)
optimistic.push(value)
admittedServers.push(server)
admitted.push(value)
},
confirm: (value: unknown) => {
confirmed.push(value)
},
clearEcho: () => {
clearEchoCalls++
return clearEchoResult
},
remove: () => undefined,
},
},
set: () => undefined,
@@ -304,7 +330,8 @@ beforeAll(async () => {
beforeEach(() => {
createdSessions.length = 0
optimistic.length = 0
admitted.length = 0
confirmed.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
@@ -314,8 +341,10 @@ beforeEach(() => {
switchedModels.length = 0
sessionRequestOrder.length = 0
syncedServers.length = 0
optimisticServers.length = 0
admittedServers.length = 0
promptCaptures.length = 0
restoredPrompts = 0
clearEchoCalls = 0
params = {}
search = {}
sentShell.length = 0
@@ -333,6 +362,8 @@ beforeEach(() => {
createWorktreeGate = undefined
worktreeFailure = undefined
locationFailure = undefined
promptFailure = undefined
clearEchoResult = true
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
@@ -421,7 +452,7 @@ describe("prompt submit worktree selection", () => {
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"])
expect(admittedServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
@@ -441,13 +472,15 @@ describe("prompt submit worktree selection", () => {
await submit.handleSubmit(event)
await Bun.sleep(0)
expect(optimistic).toHaveLength(1)
expect(optimistic[0]).toMatchObject({
message: {
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
},
expect(admitted).toHaveLength(1)
expect(admitted[0]).toMatchObject({
sessionID: "session-1",
text: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model", variant: "high" },
})
expect(admitted[0]?.messageID).toStartWith("msg_")
expect(confirmed).toMatchObject([{ id: admitted[0]?.messageID, sessionID: "session-1" }])
expect(sentPrompts).toEqual(["/repo/main"])
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
expect(switchedModels).toEqual([
@@ -466,6 +499,22 @@ describe("prompt submit worktree selection", () => {
expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_")
})
test("keeps a confirmed echo when the prompt response is lost", async () => {
params = { id: "session-1" }
promptFailure = new Error("connection lost")
clearEchoResult = false
const submit = makeSubmit({
info: () => ({ id: "session-1", agent: "agent", model: { id: "model", providerID: "provider" } }),
})
await submit.handleSubmit(event)
await settle()
expect(admitted).toHaveLength(1)
expect(clearEchoCalls).toBe(1)
expect(restoredPrompts).toBe(0)
})
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
@@ -1,10 +1,9 @@
import type { Message } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { showToast } from "@/utils/toast"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Binary } from "@opencode-ai/core/util/binary"
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js"
import { startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
@@ -15,7 +14,7 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts"
import { buildPromptRequest } from "./build-prompt-request"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
@@ -100,43 +99,22 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
)
const { requestParts, optimisticParts } = buildRequestParts({
const request = buildPromptRequest({
prompt: input.draft.prompt,
context: input.draft.context,
images: encodedImages,
text,
sessionID: input.draft.sessionID,
messageID,
sessionDirectory: input.draft.sessionDirectory,
})
const message: Message = {
id: messageID,
setBusy()
input.sync.session.inbox.echo({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
role: "user",
time: { created: Date.now() },
messageID,
agent: input.draft.agent,
model: { ...input.draft.model, variant: input.draft.variant },
}
const add = () =>
input.sync.session.optimistic.add({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
message,
parts: optimisticParts,
})
const remove = () =>
input.sync.session.optimistic.remove({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
batch(() => {
setBusy()
add()
...request,
})
try {
@@ -159,40 +137,23 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
}
await input.api.prompt({
const admitted = await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
files: requestParts.flatMap((part) => {
if (part.type !== "file") return []
const text = part.source?.text
return [
{
uri: part.url,
name: part.filename,
mention: text ? { start: text.start, end: text.end, text: text.value } : undefined,
},
]
}),
agents: requestParts.flatMap((part) =>
part.type === "agent"
? [
{
name: part.name,
mention: part.source
? { start: part.source.start, end: part.source.end, text: part.source.value }
: undefined,
},
]
: [],
),
text: request.text,
files: request.files.map((file) => ({ uri: file.uri, name: file.name, mention: file.mention })),
agents: request.agents,
})
input.sync.session.inbox.confirm(admitted)
return true
} catch (err) {
batch(() => {
setIdle()
remove()
const failed = input.sync.session.inbox.clearEcho({
directory: input.draft.sessionDirectory,
sessionID: input.draft.sessionID,
messageID,
})
if (!failed) return true
setIdle()
throw err
}
}
@@ -538,14 +499,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
@@ -565,7 +518,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
@@ -119,9 +119,11 @@ export function createProviderConnectionController(options: {
const finish = async () => {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
const key = directory ? pathKey(directory) : null
await Promise.all([
queryClient.refetchQueries(serverSync.queryOptions.providers(key)).catch(() => undefined),
queryClient.refetchQueries(serverSync.queryOptions.integrations(key)).catch(() => undefined),
])
if (polling.disposed) return
options.onComplete()
}
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { useIntegrations } from "@/hooks/use-integrations"
import { createMemo, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
@@ -40,7 +41,9 @@ export const SettingsProvidersV2: Component<{
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
providerConnect.select(provider)
@@ -73,7 +76,14 @@ export const SettingsProvidersV2: Component<{
return items
})
// Connection state comes from the integration list like the TUI: credential
// connections mean an API key or OAuth grant, env connections mean detected
// environment variables, and a connectionless integration is config-provided.
const source = (item: ProviderItem): ProviderSource | undefined => {
const current = integration(item.id)
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
if (current?.connections.some((connection) => connection.type === "env")) return "env"
if (current) return "config"
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
@@ -92,7 +102,11 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
const canDisconnect = (item: ProviderItem) => {
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
return source(item) !== "env" && !isConfigCustom(item.id)
}
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -68,7 +68,8 @@
display: none;
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 4px;
-webkit-mask-image: linear-gradient(
to right,
@@ -86,7 +87,8 @@
);
}
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"],
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] {
-webkit-mask-image: linear-gradient(
to left,
black 0,
@@ -103,8 +105,7 @@
);
}
[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] {
[data-titlebar-tab]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] {
--tab-title-fade-offset: 24px;
}
+10 -28
View File
@@ -1,10 +1,10 @@
import { Binary } from "@opencode-ai/core/util/binary"
import type { Message, Part } from "@/types"
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInboxInfo, SessionInfo } from "@opencode-ai/client/promise"
import { createMemo } from "solid-js"
import { produce, reconcile, type SetStoreFunction } from "solid-js/store"
import type { createServerSdkContext } from "./server-sdk"
import type { createServerSyncContextInner } from "./server-sync"
import type { PromptEcho } from "./server-session"
import type { State } from "./global-sync/types"
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -82,34 +82,16 @@ export const createDirSyncContext = (
const session = serverSync.session.get(sessionID)
if (session?.location.directory === directory) return session
},
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
serverSync.session.optimistic.add(input)
inbox: {
echo(input: PromptEcho & { directory?: string }) {
serverSync.session.inbox.echo(input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
serverSync.session.optimistic.remove(input)
confirm(input: SessionInboxInfo) {
return serverSync.session.inbox.confirm(input)
},
clearEcho(input: { directory?: string; sessionID: string; messageID: string }) {
return serverSync.session.inbox.clearEcho(input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
serverSync.session.optimistic.add({
sessionID: input.sessionID,
message: {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
},
parts: input.parts,
})
},
async sync(sessionID: string, options?: { force?: boolean }) {
await serverSync.session.sync(sessionID, options)
+1 -1
View File
@@ -143,7 +143,7 @@ describe("encodeFilePath", () => {
})
test("should handle mixed separator path (Windows + Unix)", () => {
// This is what happens in build-request-parts.ts when concatenating paths
// This is what happens in build-prompt-request.ts when concatenating paths
const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
const result = encodeFilePath(mixedPath)
const fileUrl = `file://${result}`
+2 -1
View File
@@ -287,7 +287,8 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
)
return
const directory = e.name
const directory = event.current?.location?.directory
if (!directory) return
const time = Date.now()
if (event.type === "session.execution.failed") {
handleSessionError(directory, event, time)
+1 -1
View File
@@ -194,7 +194,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
const handlePermission = (e: PermissionEvent) => {
const event = e.details
if (event?.type !== "permission.asked") return
void respondPending(event.properties, e.name)
void respondPending(event.properties, event.current?.location?.directory)
}
const unsubscribe = input.sdk.event.listen((event) => {
+12 -21
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
@@ -45,23 +45,21 @@ describe("adaptServerEvent", () => {
})
describe("current event buffering", () => {
const delta = (id: string, value: string, ordinal = 0) => ({
directory: "/repo",
payload: adaptServerEvent({
const delta = (id: string, value: string, ordinal = 0) =>
adaptServerEvent({
id,
created: 1,
type: "session.text.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
} as OpenCodeEvent),
})
} as OpenCodeEvent)
test("merges adjacent text deltas for the same message and ordinal", () => {
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
expect(result).toHaveLength(1)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
expect(result[0]?.properties).toMatchObject({ delta: "hello world" })
})
test("coalesces current tool input deltas by tool ID", () => {
@@ -74,26 +72,19 @@ describe("current event buffering", () => {
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
current("evt_1", "call_1", "{"),
current("evt_2", "call_1", "}"),
current("evt_3", "call_2", "[]"),
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
expect(result[0]?.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves boundaries between distinct delta streams", () => {
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
test("preserves current event order when enqueuing", () => {
const events: Parameters<typeof enqueueServerEvent>[0] = []
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
expect(coalesceServerEvents(events).map((event) => event.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
})
})
+31 -29
View File
@@ -12,7 +12,7 @@ import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
type QueuedServerEvent = { directory: string; payload: ServerEvent }
type ServerEventMap = { [Type in ServerEvent["type"]]: Extract<ServerEvent, { type: Type }> }
type CurrentDelta = Extract<
OpenCodeEvent,
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
@@ -22,22 +22,17 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
queue.push(event)
return true
}
export function coalesceServerEvents(events: QueuedServerEvent[]) {
const output: QueuedServerEvent[] = []
export function coalesceServerEvents(events: ServerEvent[]) {
const output: ServerEvent[] = []
events.forEach((event) => {
const current = currentDelta(event.payload.current)
const current = currentDelta(event.current)
if (current) {
const previous = output[output.length - 1]
const prior = currentDelta(previous?.payload.current)
const prior = currentDelta(previous?.current)
if (
previous &&
prior &&
previous.directory === event.directory &&
prior.location?.directory === current.location?.directory &&
currentDeltaKey(prior) === currentDeltaKey(current)
) {
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
@@ -46,13 +41,10 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
? { ...current.data, text: fragment }
: { ...current.data, delta: fragment }
output[output.length - 1] = {
directory: event.directory,
payload: {
...event.payload,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent,
}
...event,
properties: data,
current: { ...current, data } as CurrentDelta,
} as ServerEvent
return
}
output.push(event)
@@ -89,7 +81,8 @@ export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: ()
start()
}
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<ServerEventMap>>
type ServerLocationEventEmitter = ReturnType<typeof createGlobalEmitter<{ [directory: string]: ServerEvent }>>
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
type ServerSDKBase = {
server: ServerConnection.Any
@@ -104,6 +97,9 @@ type ServerSDKBase = {
event: {
on: ServerEventEmitter["on"]
listen: ServerEventEmitter["listen"]
location: {
on: ServerLocationEventEmitter["on"]
}
}
}
@@ -123,18 +119,16 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
})()
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
const emitter = createGlobalEmitter<{
[key: string]: ServerEvent
}>()
const emitter = createGlobalEmitter<ServerEventMap>()
const locations = createGlobalEmitter<{ [directory: string]: ServerEvent }>()
type Queued = QueuedServerEvent
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const CONNECT_TIMEOUT_MS = 2_000
const RECONNECT_DELAY_MS = 1_000
let queue: Queued[] = []
let buffer: Queued[] = []
let queue: ServerEvent[] = []
let buffer: ServerEvent[] = []
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -152,7 +146,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
output.forEach((event) => {
emitter.emit(event.type, event)
const directory = event.current?.location?.directory
if (directory) locations.emit(directory, event)
})
})
buffer.length = 0
@@ -165,8 +163,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
function publish(event: OpenCodeEvent) {
const directory = event.location?.directory ?? "global"
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
queue.push(adaptServerEvent(event))
schedule()
}
function wait(delay: number, signal: AbortSignal) {
@@ -313,6 +311,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
queue = []
buffer = []
emitter.clear()
locations.clear()
})
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
@@ -330,6 +329,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
location: {
on: locations.on.bind(locations),
},
},
}
}
@@ -365,7 +367,7 @@ export type DirectorySDK = {
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
const emitter = createGlobalEmitter<SDKEventMap>()
const unsub = serverSDK.event.on(directory, (event) => {
const unsub = serverSDK.event.location.on(directory, (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
@@ -6,6 +6,32 @@ const event = (input: object) => input as OpenCodeEvent
const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } }
describe("v2 session reducer", () => {
test("moves a repeated inbox payload to the current event position", () => {
const reducer = createV2SessionReducer()
const result = reducer.reduce(
[
{ id: "msg_user", type: "user", text: "local", time: { created: 0 } },
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
],
event({
...base,
id: "evt_admitted",
type: "session.inbox.enqueued",
data: {
sessionID: "ses_1",
inboxID: "msg_user",
item: { type: "user", delivery: "steer", payload: { text: "durable" } },
},
}),
)
expect(result?.messages).toEqual([
{ id: "msg_agent", type: "agent-switched", agent: "review", time: { created: 1 } },
{ id: "msg_user", type: "user", text: "durable", time: { created: 1 } },
])
expect(result?.touched).toEqual(["msg_user"])
})
test("projects promoted input and streaming assistant content", () => {
const reducer = createV2SessionReducer()
let messages: SessionMessageInfo[] = []
@@ -1,4 +1,10 @@
import type { OpenCodeEvent, SessionInboxItem, SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
import type {
OpenCodeEvent,
SessionInboxInfo,
SessionInboxItem,
SessionInfo,
SessionMessageInfo,
} from "@opencode-ai/client/promise"
type Assistant = Extract<SessionMessageInfo, { type: "assistant" }>
type Compaction = Extract<SessionMessageInfo, { type: "compaction" }>
@@ -29,12 +35,14 @@ export function createV2SessionReducer() {
})
const append = (message: SessionMessageInfo) =>
result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id])
const replace = (message: SessionMessageInfo) =>
result([...source.filter((item) => item.id !== message.id), message], [message.id])
switch (event.type) {
case "session.inbox.enqueued":
pending.set(key(sessionID, event.data.inboxID), event.data.item)
if (event.data.item.type === "user")
return append({
return replace({
id: event.data.inboxID,
type: "user",
metadata: event.data.item.payload.metadata,
@@ -44,7 +52,7 @@ export function createV2SessionReducer() {
time: { created: event.created },
})
if (event.data.item.type !== "synthetic") return result([...source])
return append({
return replace({
id: event.data.inboxID,
type: "synthetic",
metadata: event.data.item.payload.metadata,
@@ -480,6 +488,9 @@ export function createV2SessionReducer() {
return {
reduce,
confirm(item: SessionInboxInfo) {
pending.set(key(item.sessionID, item.id), item)
},
clear(sessionID: string) {
for (const id of pending.keys()) {
if (id.startsWith(`${sessionID}:`)) pending.delete(id)
+427 -265
View File
@@ -185,6 +185,16 @@ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart =>
id: `${messageID}:text:${input.id === "pending" ? 1 : 0}`,
})
const promptEcho = (messageID: string, text = "hello") => ({
sessionID: "child",
messageID,
text,
displayText: text,
agent: "build",
model: { providerID: "provider", modelID: "model" },
comments: [],
})
const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
data,
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
@@ -299,6 +309,26 @@ function setup(sessions: Record<string, SessionInfo>) {
}
describe("server session", () => {
test("hydrates session info after a native session.created event", async () => {
const ctx = setup({ created: session("created") })
ctx.store.apply({
type: "session.created",
properties: {
sessionID: "created",
projectID: "project",
location: { directory: "/repo" },
slug: "created",
version: "test",
},
})
expect(ctx.store.get("created")).toBeUndefined()
await ctx.store.resolve("created")
expect(ctx.store.get("created")?.location.directory).toBe("/repo")
expect(ctx.get).toEqual([{ sessionID: "created" }])
})
test("projects V2 session events into current and legacy message state", () => {
const ctx = setup({ child: session("child") })
ctx.store.remember(session("child"))
@@ -340,14 +370,38 @@ describe("server session", () => {
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
})
apply({
id: "evt_tool_z",
created: 5,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 3, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_z", name: "shell" },
})
apply({
id: "evt_tool_a",
created: 6,
type: "session.tool.input.started",
durable: { aggregateID: "child", seq: 4, version: 1 },
location: { directory: "/repo" },
data: { sessionID: "child", assistantMessageID: "msg_2_assistant", id: "call_a", name: "shell" },
})
expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
id: "msg_2_assistant",
type: "assistant",
content: [{ type: "text", text: "world" }],
content: [
{ type: "text", text: "world" },
{ type: "tool", id: "call_z" },
{ type: "tool", id: "call_a" },
],
})
expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
expect(ctx.store.data.part.msg_2_assistant?.map((part) => part.id)).toEqual([
"msg_2_assistant:text:0",
"call_z",
"call_a",
])
})
test("projects V2 pending inputs and forms", () => {
@@ -564,7 +618,7 @@ describe("server session", () => {
await ctx.store.sync("root")
expect(ctx.get).toEqual([{ sessionID: "root" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(ctx.messages).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(ctx.store.data.message.root).toEqual([])
})
@@ -575,8 +629,32 @@ describe("server session", () => {
ctx.store.invalidate()
await ctx.store.sync("root")
expect(ctx.store.data.message.root).toEqual([])
expect(ctx.get).toHaveLength(2)
expect(ctx.messages).toHaveLength(2)
expect(ctx.messages).toEqual([
{ sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 200, order: "desc" },
])
})
test("keeps a fixed page size after the local message cache exceeds the API limit", async () => {
const client = messageClient(response(), response())
const store = createServerSession(client)
await store.sync("child")
Array.from({ length: 428 }, (_, index) =>
store.apply({
type: "message.updated",
properties: { info: userMessage(`message-${index}`, { time: { created: index } }) },
}),
)
expect(store.data.message.child).toHaveLength(428)
await store.sync("child", { force: true })
expect(client.requests).toEqual([
{ sessionID: "child", limit: 200, order: "desc" },
{ sessionID: "child", limit: 200, order: "desc" },
])
})
test("loads current session content through the current message API", async () => {
@@ -601,11 +679,50 @@ describe("server session", () => {
await store.sync("root")
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(requests).toEqual([{ sessionID: "root", limit: 200, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("preserves assistant content order from message history", async () => {
const source = [
{ id: "msg_user", type: "user", text: "inspect it", time: { created: 1 } },
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "text", text: "I will inspect it." },
{
type: "tool",
id: "call_z",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 2 },
},
{
type: "tool",
id: "call_a",
name: "shell",
state: { status: "streaming", input: "" },
time: { created: 3 },
},
],
time: { created: 2 },
},
] satisfies SessionMessageInfo[]
const messageApi = {
list: async () => ({ data: source.toReversed(), cursor: { previous: null, next: null } }),
} as unknown as MessageApi
const store = createServerSession({} as SessionApi, messageApi)
store.remember(session("root"))
await store.sync("root")
expect(store.data.part.msg_assistant?.map((part) => part.id)).toEqual(["msg_assistant:text:0", "call_z", "call_a"])
})
test("extends a current page to include the user for split assistant turns", async () => {
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
const assistant = (id: string, created: number) => ({
@@ -638,8 +755,8 @@ describe("server session", () => {
await store.sync("root")
expect(requests).toEqual([
{ sessionID: "root", limit: 20, order: "desc" },
{ sessionID: "root", limit: 20, cursor: "older" },
{ sessionID: "root", limit: 200, order: "desc" },
{ sessionID: "root", limit: 200, cursor: "older" },
])
expect(store.data.message.root.map((message) => message.id)).toEqual([
user.id,
@@ -648,6 +765,26 @@ describe("server session", () => {
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.
describe.skip("V1 assistant parent projections", () => {
test("backfills an assistant-only initial page through its user root", async () => {
@@ -661,7 +798,7 @@ describe("server session", () => {
await store.sync("child")
expect(client.requests).toEqual([{ sessionID: "child", limit: 20, order: "desc" }])
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
expect(store.data.message.child).toEqual([user, ...assistants])
expect(store.history.more("child")).toBe(false)
@@ -710,19 +847,17 @@ describe("server session", () => {
expect(store.data.part[parent.id]).toBeUndefined()
})
test("does not let an optimistic user suppress initial root backfill", async () => {
test("does not let an admitted user suppress initial root backfill", async () => {
const user = userMessage("message-1")
const part = textPart(user.id)
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
const client = rootMessageClient(
[response(assistants.map((info) => ({ info, parts: [] })))],
[singleResponse(user)],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
store.inbox.echo(promptEcho(user.id, "text"))
await store.sync("child")
store.optimistic.remove({ sessionID: "child", messageID: user.id })
expect(client.requests).toHaveLength(1)
expect(client.rootRequests).toHaveLength(1)
@@ -783,28 +918,6 @@ describe("server session", () => {
expect(store.data.part[stale.id]).toEqual([freshPart])
})
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
const refreshed = { ...confirmed, text: "fresh" }
const pending = textPart(stale.id, { id: "pending", text: "pending" })
const assistant = assistantMessage("message-2", stale.id)
const client = rootMessageClient(
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }])],
[singleResponse(fresh, [refreshed])],
)
const store = createServerSession(client)
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
await store.sync("child")
await store.sync("child", { force: true })
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
expect(store.data.message.child).toEqual([fresh, assistant])
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
})
test("uses a parent received by SSE during the replacement load", async () => {
const pending = deferredResponse()
const user = userMessage("message-1")
@@ -1040,30 +1153,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined()
})
test("preserves optimistic parts re-added after removal during a refresh", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
)
await store.sync("child")
const refreshing = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [part] })
pending.resolve(response([{ info: message, parts: [stale] }]))
await refreshing
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
})
test("drops stale event content omitted by a complete initial page", async () => {
const stale = userMessage("stale")
const store = createServerSession(messageClient(response()))
@@ -1085,170 +1174,309 @@ describe("server session", () => {
expect(store.data.message.child).toEqual([live, fetched])
})
test("does not restore removed optimistic content on refresh", async () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "removed" })
const kept = { ...message, id: "kept" }
const keptPart = { ...part, id: "kept-part", messageID: kept.id }
const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
})
await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([kept])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part[kept.id]).toBeUndefined()
})
test("replaces confirmed optimistic content with the initial page", async () => {
const optimistic = userMessage("message")
const fetched = { ...optimistic, time: { created: 2 } }
const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
await store.sync("child")
expect(store.data.message.child).toEqual([fetched])
})
test("replaces a confirmed optimistic part with fetched content", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const optimistic = textPart(message.id, { text: "optimistic" })
const fetched = { ...optimistic, text: "fetched" }
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
pending.resolve(response([{ info: message, parts: [fetched] }]))
await loading
expect(store.data.part[message.id]).toEqual([fetched])
})
test("rolls back only unconfirmed optimistic parts", async () => {
const pending = deferredResponse()
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(messageClient(pending.promise))
const loading = store.sync("child")
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
pending.resolve(response([{ info: message, parts: [confirmed] }]))
await loading
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([confirmed])
})
test("updates confirmed optimistic parts from later pages", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
const updated = { ...confirmed, text: "updated" }
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
await store.sync("child", { force: true })
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.part[message.id]).toEqual([updated])
})
test("does not restore a confirmed optimistic part after its removal event", async () => {
const message = userMessage("message")
const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
const store = createServerSession(
messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
)
store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
await store.sync("child")
store.apply({
type: "message.part.removed",
properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
})
await store.sync("child", { force: true })
expect(store.data.part[message.id]).toEqual([pendingPart])
})
test("clears delta buffers when removing optimistic content", () => {
const message = userMessage("message")
const part = textPart(message.id, { text: "optimistic" })
test("echoes a prompt without changing durable message order", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
store.inbox.echo({
...promptEcho("msg_prompt"),
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
files: [{ uri: "file:///repo/src/foo.ts", mime: "text/plain", name: "foo.ts" }],
agents: [{ name: "explore" }],
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
],
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.data.pending.child).toMatchObject([{ id: "msg_prompt", type: "user", delivery: "steer" }])
expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:file:0", type: "file", filename: "foo.ts" },
{ id: "msg_prompt:agent:0", type: "agent", name: "explore" },
{
id: "msg_prompt:comment:0",
type: "text",
synthetic: true,
metadata: {
opencodeComment: {
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
preview: "const value = 1",
origin: "review",
},
},
},
])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: {
type: "user",
delivery: "steer",
payload: {
text: "hello\nThe user made the following comment regarding line 4 of src/foo.ts: check this",
},
},
},
} as OpenCodeEvent)
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
})
test("removes projected messages when rolling back optimistic content", () => {
const message = userMessage("message")
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [] })
test("preserves a local echo while message history omits pending input", async () => {
const store = createServerSession(messageClient(response()))
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
await store.sync("child")
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("preserves local comment presentation through message refresh", async () => {
const note = "The user made the following comment regarding line 4 of src/foo.ts: check this"
const message = userMessage("msg_prompt")
const store = createServerSession(
messageClient(response([{ info: message, parts: [textPart(message.id, { text: note })] }])),
)
store.inbox.echo({
...promptEcho(message.id),
text: `hello\n${note}`,
comments: [
{
path: "src/foo.ts",
selection: { startLine: 4, startChar: 1, endLine: 4, endChar: 5 },
comment: "check this",
origin: "review",
},
],
})
await store.sync("child")
expect(store.data.part.msg_prompt).toMatchObject([
{ id: "msg_prompt:text:0", type: "text", text: "hello" },
{ id: "msg_prompt:comment:0", type: "text", synthetic: true },
])
})
test("retires an admitted echo absent from authoritative reconnect state", async () => {
const store = createServerSession(messageClient(response()))
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
await Promise.all([store.sync("child"), store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))])
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("retires a stale enqueued message when inbox hydration finishes after history", async () => {
const store = createServerSession(messageClient(response()))
store.applyV2({
id: "evt_prompt",
created: 1,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
await store.sync("child")
await store.hydrateTransient("child", async () => ({ pending: [], forms: [] }))
store.inbox.reconcile("child")
expect(store.data.pending.child).toEqual([])
expect(store.data.session_message.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("does not remove content confirmed by a message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("deduplicates the durable admission event against its local echo", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.inbox.echo(promptEcho("msg_prompt"))
store.optimistic.remove({ sessionID: "child", messageID: message.id })
store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toBeUndefined()
expect(store.data.pending.child).toHaveLength(1)
expect(store.data.input.child).toEqual(["msg_prompt"])
expect(store.data.session_message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("does not remove parts confirmed by part events", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("uses the prompt response when the admission event was missed", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 2,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.optimistic.remove({ sessionID: "child", messageID: message.id })
store.applyV2({
id: "evt_delivered",
created: Date.now() + 1,
type: "session.inbox.delivered",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
expect(store.data.pending.child).toEqual([])
expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toMatchObject([{ id: "msg_prompt", type: "user", text: "hello" }])
expect(store.data.message.child?.filter((message) => message.id === "msg_prompt")).toHaveLength(1)
expect(store.data.part.msg_prompt).toMatchObject([{ type: "text", text: "hello" }])
})
test("treats a part event as confirmation when it precedes the message event", () => {
const message = userMessage("message")
const part = textPart(message.id)
test("keeps a durable admission when the HTTP request later fails", () => {
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
store.inbox.echo(promptEcho("msg_prompt"))
store.applyV2({
id: "evt_prompt",
created: 2,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(false)
expect(store.data.pending.child).toHaveLength(1)
expect(store.data.message.child?.map((message) => message.id)).toEqual(["msg_prompt"])
})
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([part])
test("places durable admission after delayed selection events", () => {
const store = setup({ child: session("child") }).store
store.remember(session("child"))
store.inbox.echo(promptEcho("msg_prompt"))
store.applyV2({
id: "evt_agent",
created: 1,
type: "session.agent.selected",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child", agent: "review" },
} as OpenCodeEvent)
store.applyV2({
id: "evt_model",
created: 2,
type: "session.model.selected",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", model: { id: "new-model", providerID: "new-provider" } },
} as OpenCodeEvent)
store.applyV2({
id: "evt_prompt",
created: 3,
type: "session.inbox.enqueued",
durable: { aggregateID: "child", seq: 3, version: 1 },
data: {
sessionID: "child",
inboxID: "msg_prompt",
item: { type: "user", delivery: "steer", payload: { text: "hello" } },
},
} as OpenCodeEvent)
expect(store.data.session_message.child?.map((message) => message.type)).toEqual([
"agent-switched",
"model-switched",
"user",
])
expect(store.data.message.child?.find((message) => message.id === "msg_prompt")).toMatchObject({
agent: "review",
model: { providerID: "new-provider", modelID: "new-model" },
})
})
test("removes an echoed prompt when submission fails", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
expect(store.inbox.clearEcho({ sessionID: "child", messageID: "msg_prompt" })).toBe(true)
expect(store.data.pending.child).toEqual([])
expect(store.data.input.child).toEqual([])
expect(store.data.session_message.child).toBeUndefined()
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("removes a response-confirmed echo when the server cancels it", () => {
const store = setup({ child: session("child") }).store
store.inbox.echo(promptEcho("msg_prompt"))
store.inbox.confirm({
id: "msg_prompt",
sessionID: "child",
timeCreated: 1,
type: "user",
delivery: "steer",
payload: { text: "hello" },
})
store.applyV2({
id: "evt_cancelled",
created: 2,
type: "session.inbox.cancelled",
durable: { aggregateID: "child", seq: 2, version: 1 },
data: { sessionID: "child", inboxID: "msg_prompt" },
} as OpenCodeEvent)
expect(store.data.pending.child).toEqual([])
expect(store.data.message.child).toEqual([])
expect(store.data.part.msg_prompt).toBeUndefined()
})
test("clears stale parts when the initial page has none", async () => {
@@ -1469,28 +1697,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined()
})
test("preserves optimistic re-adds across message retries", async () => {
const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>()
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
const store = createServerSession(client, { retry: retryImmediately })
await store.sync("child")
const loading = store.sync("child", { force: true })
store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
failed.reject(new Error("failed to fetch"))
await client.requested(3)
retried.resolve(response([{ info: message, parts: [stale] }]))
await loading
expect(store.data.message.child).toEqual([message])
expect(store.data.part[message.id]).toEqual([optimistic])
})
test("accepts part omission from a successful retry after an earlier delta", async () => {
const failed = Promise.withResolvers<MessageResponse>()
const retried = Promise.withResolvers<MessageResponse>()
@@ -1654,33 +1860,6 @@ describe("server session", () => {
expect(store.data.part[message.id]).toBeUndefined()
})
test("does not cache skipped optimistic parts", () => {
const message = userMessage("message")
const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [part] })
expect(store.data.part[message.id]).toEqual([])
})
test("clears stale delta buffers when replacing optimistic parts", () => {
const message = userMessage("message")
const stale = textPart(message.id, { id: "stale", text: "stale" })
const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
const store = setup({ child: session("child") }).store
store.optimistic.add({ sessionID: "child", message, parts: [stale] })
store.apply({
type: "message.part.delta",
properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
})
store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
})
test("preserves removals during history prepend", async () => {
const pending = deferredResponse()
const latest = userMessage("message-2", { time: { created: 2 } })
@@ -1906,24 +2085,7 @@ describe("server session", () => {
test("preserves pinned session content under server-wide cache pressure", () => {
const ctx = setup({})
ctx.store.pin("active")
ctx.store.optimistic.add({
sessionID: "active",
message: {
id: "message",
sessionID: "active",
role: "assistant",
time: { created: 1 },
parentID: "parent",
modelID: "model",
providerID: "provider",
mode: "build",
agent: "agent",
path: { cwd: "/repo", root: "/repo" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
})
ctx.store.inbox.echo({ ...promptEcho("message", "keep"), sessionID: "active" })
for (let index = 0; index < 50; index++) {
ctx.store.remember(session(`session-${index}`))
+274 -301
View File
@@ -18,41 +18,22 @@ import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/s
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server"
import {
createCommentMetadata,
formatCommentNote,
parseCommentNote,
readCommentMetadata,
type PromptComment,
} from "@/utils/comment-note"
type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const historyMessagePageSize = 200
const messagePageSize = 200
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
function projectMessageSource(message: Message): SessionMessageInfo[] {
if (message.role === "user") {
return [
{ id: `${message.id}:agent`, type: "agent-switched", agent: message.agent, time: message.time },
{
id: `${message.id}:model`,
type: "model-switched",
model: { id: message.model.modelID, providerID: message.model.providerID, variant: message.model.variant },
time: message.time,
},
{ id: message.id, type: "user", text: "", time: message.time },
]
}
return [
{
id: message.id,
type: "assistant",
agent: message.agent ?? message.mode,
model: { id: message.modelID, providerID: message.providerID, variant: message.variant },
content: [],
time: message.time,
},
]
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find(
(message) =>
@@ -64,13 +45,6 @@ function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
return boundary?.type === "assistant"
}
type OptimisticItem = {
message: Message
parts: Part[]
confirmedParts?: Part[]
confirmedMessage?: boolean
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
@@ -81,6 +55,18 @@ type MessagePage = {
complete: boolean
}
export type PromptEcho = {
sessionID: string
messageID: string
text: string
displayText: string
agent: string
model: { providerID: string; modelID: string; variant?: string }
files?: { uri: string; mime: string; name?: string; mention?: { start: number; end: number; text: string } }[]
agents?: { name: string; mention?: { start: number; end: number; text: string } }[]
comments: PromptComment[]
}
// Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries.
type MessageLoadState = {
touchedMessages: Set<string>
@@ -90,7 +76,6 @@ type MessageLoadState = {
deltaParts: Map<string, Set<string>>
carriedDeltaParts: Map<string, Set<string>>
removedParts: Map<string, Set<string>>
optimisticParts: Map<string, Set<string>>
orphanParents: Set<string>
clearedMessageParts: Set<string>
touchedSource: Set<string>
@@ -101,34 +86,6 @@ type MessageLoadBaseline = Pick<
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
>
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : []
if (found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set(
item.message.id,
merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)),
),
)
}
return {
...page,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, parts]) => ({ id, part: parts })),
observed,
}
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
@@ -203,6 +160,7 @@ export function createServerSession(
input: {} as Record<string, string[]>,
message: {} as Record<string, Message[]>,
session_message: {} as Record<string, SessionMessageInfo[]>,
// Part order is semantic and follows SessionMessageAssistant.content; IDs identify parts only.
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
session_working(id: string) {
@@ -212,7 +170,6 @@ export function createServerSession(
const requests = new Map<string, Promise<SessionInfo>>()
const inflight = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const v2 = createV2SessionReducer()
const pendingRevision = new Map<string, number>()
const formRevision = new Map<string, number>()
@@ -223,7 +180,45 @@ export function createServerSession(
const pendingParts = new Map<string, Map<string, Set<string>>>()
const orphanParts = new Map<string, Set<string>>()
const removedMessages = new Map<string, Set<string>>()
const echoes = new Map<string, Map<string, "sending" | "admitted">>()
const messageSnapshots = new Map<string, Set<string>>()
const settledInputs = new Map<string, Set<string>>()
const deltaBases = new Map<string, { base: string; sessionID: string }>()
const markEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID) ?? new Map<string, "sending" | "admitted">()
messages.set(messageID, "sending")
echoes.set(sessionID, messages)
}
const confirmEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
if (!messages?.has(messageID)) return false
messages.set(messageID, "admitted")
return true
}
const releaseEcho = (sessionID: string, messageID: string) => {
const messages = echoes.get(sessionID)
const state = messages?.get(messageID)
if (!messages || !state) return
messages.delete(messageID)
if (messages.size === 0) echoes.delete(sessionID)
return state
}
const present = (messageID: string, parts: Part[]) => {
const local = data.part[messageID] ?? []
const comments = local.filter(
(part) =>
part.type === "text" &&
part.synthetic &&
(readCommentMetadata(part.metadata) !== undefined || parseCommentNote(part.text) !== undefined),
)
if (!comments.length) return parts
const text = local.find((part) => part.type === "text" && !part.synthetic)
const projected = parts.flatMap((part) => {
if (part.id !== `${messageID}:text:0` || part.type !== "text") return [part]
return text?.type === "text" && text.text ? [{ ...part, text: text.text }] : []
})
return [...projected, ...comments]
}
const deleteMessageParts = (
cache: { part: Record<string, Part[] | undefined>; part_text_accum_delta: Record<string, string | undefined> },
messageID: string,
@@ -246,25 +241,12 @@ export function createServerSession(
return created
}
const [meta, setMeta] = createStore({
limit: {} as Record<string, number | undefined>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean | undefined>,
loading: {} as Record<string, boolean | undefined>,
at: {} as Record<string, number | undefined>,
})
const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
const projected = projectMessageSource(message)
const projectedIDs = new Set(projected.map((item) => item.id))
setData(
"session_message",
message.sessionID,
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
)
}
const remember = (session: SessionInfo) => {
setData("info", session.id, reconcile(session))
infoSeen.delete(session.id)
@@ -276,7 +258,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...echoes.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -352,65 +334,6 @@ export function createServerSession(
return { session, root }
}
const clearOptimistic = (sessionID: string, messageID?: string) => {
if (!messageID) {
optimistic.delete(sessionID)
return
}
const items = optimistic.get(sessionID)
if (!items) return
items.delete(messageID)
if (items.size === 0) optimistic.delete(sessionID)
}
const clearOptimisticPart = (sessionID: string, messageID: string, partID: string) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((part) => part.id !== partID)
const confirmedParts = item.confirmedParts?.filter((part) => part.id !== partID)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, { ...item, parts, confirmedParts, confirmedMessage: true })
}
const confirmOptimisticPart = (sessionID: string, messageID: string, part: Part) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const parts = item.parts.filter((value) => value.id !== part.id)
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], [part]),
confirmedMessage: true,
})
}
const confirmOptimistic = (sessionID: string, messageID: string, confirmedParts: Part[]) => {
const items = optimistic.get(sessionID)
const item = items?.get(messageID)
if (!items || !item) return
const confirmed = new Set(confirmedParts.map((part) => part.id))
const parts = item.parts.filter((part) => !confirmed.has(part.id))
if (parts.length === 0) {
clearOptimistic(sessionID, messageID)
return
}
items.set(messageID, {
...item,
parts,
confirmedParts: merge(item.confirmedParts ?? [], confirmedParts),
confirmedMessage: true,
})
}
const trackPartChange = (sessionID: string, messageID: string, partID: string) => {
const load = messageLoads.get(sessionID)
if (!load) return
@@ -448,14 +371,6 @@ export function createServerSession(
const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID)
}
for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID)
load.clearedMessageParts.add(messageID)
load.touchedMessages.add(messageID)
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched)
}
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
@@ -486,7 +401,9 @@ export function createServerSession(
sessionIDs.forEach((sessionID) => {
messageHydrationRevision.set(sessionID, (messageHydrationRevision.get(sessionID) ?? 0) + 1)
generations.delete(sessionID)
clearOptimistic(sessionID)
echoes.delete(sessionID)
messageSnapshots.delete(sessionID)
settledInputs.delete(sessionID)
requests.delete(sessionID)
inflight.delete(sessionID)
inflightTodo.delete(sessionID)
@@ -504,7 +421,6 @@ export function createServerSession(
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
delete draft.limit[sessionID]
delete draft.cursor[sessionID]
delete draft.complete[sessionID]
delete draft.loading[sessionID]
@@ -521,7 +437,7 @@ export function createServerSession(
...inflight.keys(),
...inflightTodo.keys(),
...messageLoads.keys(),
...optimistic.keys(),
...echoes.keys(),
...Object.entries(data.permission)
.filter(([, items]) => items.length > 0)
.map(([sessionID]) => sessionID),
@@ -538,11 +454,13 @@ export function createServerSession(
pickSessionCacheEvictions({ seen, keep: sessionID, limit: SESSION_CACHE_LIMIT, preserve: protectedSessions() }),
)
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
const fetchMessages = async (sessionID: string, before?: string, onAttempt?: () => void) => {
const request = (cursor?: string) =>
(options?.retry ?? retry)(() => {
onAttempt?.()
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
return messageApi.list(
cursor ? { sessionID, limit: messagePageSize, cursor } : { sessionID, limit: messagePageSize, order: "desc" },
)
})
const first = await request(before)
const pages = [first]
@@ -556,9 +474,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source)
return {
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
source,
sourceMode: before ? ("older" as const) : ("latest" as const),
projectSource: true,
@@ -598,9 +514,10 @@ export function createServerSession(
) => {
for (const item of items) {
if (!messageIDs.has(item.id)) continue
const fetched = load?.clearedMessageParts.has(item.id)
? []
: item.part.filter((part) => !SKIP_PARTS.has(part.type))
const fetched = present(
item.id,
load?.clearedMessageParts.has(item.id) ? [] : item.part.filter((part) => !SKIP_PARTS.has(part.type)),
)
const fetchedIDs = new Set(fetched.map((part) => part.id))
const pending = pendingParts.get(sessionID)?.get(item.id)
const touched = new Set([...(load?.touchedParts.get(item.id) ?? []), ...(pending ?? [])])
@@ -651,47 +568,56 @@ export function createServerSession(
preserveUnfetched: boolean | ((message: Message) => boolean),
cleanupOrphans: boolean,
) => {
if (page.sourceMode === "latest")
messageSnapshots.set(sessionID, new Set((page.source ?? []).map((message) => message.id)))
page.source?.forEach((message) => releaseEcho(sessionID, message.id))
const source = page.source
? (() => {
const incoming = new Map(page.source.map((message) => [message.id, message]))
const existing = data.session_message[sessionID] ?? []
const boundary = Math.min(...page.source.map((message) => message.time.created))
const inbox = new Set(data.input[sessionID] ?? [])
const current = existing.filter(
(message) =>
!incoming.has(message.id) &&
!inbox.has(message.id) &&
(page.sourceMode === "older" ||
load?.touchedSource.has(message.id) ||
(!page.complete && message.time.created < boundary)),
)
// message.list never returns admitted-but-undelivered inbox entries; keep them after the
// fetched history until a delivered or cancelled event resolves them.
const admitted = existing.filter((message) => !incoming.has(message.id) && inbox.has(message.id))
const combined =
page.sourceMode === "older"
? [...page.source, ...current, ...admitted]
: [...current, ...page.source, ...admitted]
const live = new Map(existing.map((message) => [message.id, message]))
return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
(message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),
return combined.map((message) =>
load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message,
)
})()
: undefined
const projected =
const merged =
page.projectSource && source
? (() => {
const normalized = normalizeSessionMessages(sessionID, source)
return {
...page,
session: normalized.messages.sort(compareMessages),
part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()].map(([id, part]) => ({ id, part })).sort((a, b) => cmp(a.id, b.id)),
}
})()
: page
const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])])
merged.observed.forEach((item) => {
if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts)
})
const touchedMessages = new Set([...(load?.touchedMessages ?? []), ...(removedMessages.get(sessionID) ?? [])])
const messages = reconcileFetched(merged.session, data.message[sessionID] ?? [], {
touched: touchedMessages,
retained: load?.retainedMessages,
removed: load?.removedMessages,
preserveUnfetched,
preserveUnfetched: (message) =>
echoes.get(sessionID)?.has(message.id) === true ||
preserveUnfetched === true ||
(typeof preserveUnfetched === "function" && preserveUnfetched(message)),
compare: compareMessages,
})
batch(() => {
@@ -705,14 +631,13 @@ export function createServerSession(
}
orphanParts.delete(sessionID)
}
setMeta("limit", sessionID, messages.length)
setMeta("cursor", sessionID, merged.cursor)
setMeta("complete", sessionID, merged.complete)
setMeta("at", sessionID, Date.now())
})
}
const loadMessages = async (sessionID: string, limit: number, before?: string, mode?: "replace" | "prepend") => {
const loadMessages = async (sessionID: string, before?: string, mode?: "replace" | "prepend") => {
if (meta.loading[sessionID]) return
const active = generation(sessionID)
const load: MessageLoadState = {
@@ -723,7 +648,6 @@ export function createServerSession(
deltaParts: new Map(),
carriedDeltaParts: new Map(),
removedParts: new Map(),
optimisticParts: new Map(),
orphanParents: new Set(),
clearedMessageParts: new Set(),
touchedSource: new Set(),
@@ -732,7 +656,7 @@ export function createServerSession(
setMeta("loading", sessionID, true)
let applied = false
try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const page = await fetchMessages(sessionID, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest),
undefined,
@@ -744,11 +668,7 @@ export function createServerSession(
const users = new Set([
...page.session.filter((message) => message.role === "user").map((message) => message.id),
...(data.message[sessionID] ?? [])
.filter((message) => {
if (message.role !== "user") return false
const item = optimistic.get(sessionID)?.get(message.id)
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
})
.filter((message) => message.role === "user" && load.touchedMessages.has(message.id))
.map((message) => message.id),
])
const parentIDs = [
@@ -815,32 +735,30 @@ export function createServerSession(
}
}
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
const sync = (sessionID: string, options?: { force?: boolean }) => {
touch(sessionID)
return runInflight(inflight, sessionID, async () => {
const cached = data.message[sessionID] !== undefined && meta.limit[sessionID] !== undefined
const cached = data.message[sessionID] !== undefined && meta.complete[sessionID] !== undefined
const invalid = invalidated.has(sessionID)
const revision = invalidationRevision
if (cached && data.info[sessionID] && !invalid && !options?.force) return
await Promise.all([
resolve(sessionID, invalid ? { ...options, force: true } : options),
cached && !invalid && !options?.force
? Promise.resolve()
: loadMessages(sessionID, options?.messageLimit ?? meta.limit[sessionID] ?? initialMessagePageSize),
cached && !invalid && !options?.force ? Promise.resolve() : loadMessages(sessionID),
])
if (invalid && invalidationRevision === revision) invalidated.delete(sessionID)
})
}
const prefetch = async (sessionID: string, limit: number) => {
const prefetch = async (sessionID: string, messageCount: number) => {
touch(sessionID)
await inflight.get(sessionID)
if (
Date.now() - (meta.at[sessionID] ?? 0) <= 15_000 &&
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= limit)
(meta.complete[sessionID] || (data.message[sessionID]?.length ?? 0) >= messageCount)
)
return
await runInflight(inflight, sessionID, () => loadMessages(sessionID, limit))
await runInflight(inflight, sessionID, () => loadMessages(sessionID))
}
const eventSessionID = (event: { type: string; properties?: unknown }) => {
@@ -893,12 +811,12 @@ export function createServerSession(
apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } })
}
for (const messageID of touched) {
const next = normalized.parts.get(messageID) ?? []
const next = present(messageID, normalized.parts.get(messageID) ?? [])
const nextIDs = new Set(next.map((part) => part.id))
for (const part of next) {
apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } })
}
for (const part of data.part[messageID] ?? []) {
for (const part of [...(data.part[messageID] ?? [])]) {
if (nextIDs.has(part.id)) continue
apply({
type: "message.part.removed",
@@ -926,6 +844,67 @@ export function createServerSession(
.catch(() => {})
}
const removeEcho = (sessionID: string, messageID: string) => {
if (!releaseEcho(sessionID, messageID)) return false
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
const load = messageLoads.get(sessionID)
load?.touchedMessages.add(messageID)
load?.removedMessages.add(messageID)
load?.clearedMessageParts.add(messageID)
batch(() => {
setData("pending", sessionID, (items) => items?.filter((item) => item.id !== messageID))
setData("input", sessionID, (items) => items?.filter((id) => id !== messageID))
setData("message", sessionID, (messages) => messages?.filter((message) => message.id !== messageID))
setData(produce((draft) => deleteMessageParts(draft, messageID)))
})
return true
}
const confirmInbox = (item: SessionInboxInfo) => {
if (!confirmEcho(item.sessionID, item.id)) return false
v2.confirm(item)
pendingRevision.set(item.sessionID, (pendingRevision.get(item.sessionID) ?? 0) + 1)
const current = data.pending[item.sessionID] ?? []
const index = current.findIndex((entry) => entry.id === item.id)
if (index < 0) setData("pending", item.sessionID, [...current, item])
if (index >= 0) setData("pending", item.sessionID, index, reconcile(item))
return true
}
const reconcileInbox = (sessionID: string) => {
const pending = new Set((data.pending[sessionID] ?? []).map((item) => item.id))
const fetched = messageSnapshots.get(sessionID) ?? new Set<string>()
const removed = [...(settledInputs.get(sessionID) ?? [])].filter(
(messageID) => !pending.has(messageID) && !fetched.has(messageID),
)
settledInputs.delete(sessionID)
if (removed.length) {
const ids = new Set(removed)
const source = data.session_message[sessionID] ?? []
projectV2({
sessionID,
messages: source.filter((message) => !ids.has(message.id)),
touched: [],
removed: source.filter((message) => ids.has(message.id)).map((message) => message.id),
})
}
const messages = echoes.get(sessionID)
if (!messages) return
const projected = new Set((data.session_message[sessionID] ?? []).map((message) => message.id))
for (const [messageID, state] of messages) {
if (projected.has(messageID)) {
releaseEcho(sessionID, messageID)
continue
}
if (pending.has(messageID)) {
confirmEcho(sessionID, messageID)
continue
}
if (state === "admitted") removeEcho(sessionID, messageID)
}
}
const applyV2 = (event: OpenCodeEvent) => {
if (event.type === "form.created") {
formRevision.set(event.data.form.sessionID, (formRevision.get(event.data.form.sessionID) ?? 0) + 1)
@@ -949,6 +928,9 @@ export function createServerSession(
}
if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return
const sessionID = event.data.sessionID
if (event.type === "session.inbox.enqueued" || event.type === "session.inbox.delivered")
releaseEcho(sessionID, event.data.inboxID)
if (event.type === "session.inbox.cancelled") removeEcho(sessionID, event.data.inboxID)
if (
event.type === "session.inbox.enqueued" ||
event.type === "session.inbox.delivery.changed" ||
@@ -960,11 +942,10 @@ export function createServerSession(
pendingRevision.set(sessionID, (pendingRevision.get(sessionID) ?? 0) + 1)
if (event.type === "session.inbox.enqueued") {
const current = data.pending[sessionID] ?? []
if (!current.some((item) => item.id === event.data.inboxID))
setData("pending", sessionID, [
...current,
{ id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item },
])
const item = { id: event.data.inboxID, sessionID, timeCreated: event.created, ...event.data.item }
const index = current.findIndex((entry) => entry.id === event.data.inboxID)
if (index < 0) setData("pending", sessionID, [...current, item])
if (index >= 0) setData("pending", sessionID, index, reconcile(item))
if (event.data.item.type !== "compaction" && !data.input[sessionID]?.includes(event.data.inboxID))
setData("input", sessionID, [...(data.input[sessionID] ?? []), event.data.inboxID])
}
@@ -1101,16 +1082,9 @@ export function createServerSession(
}
case "message.updated": {
const info = (event.properties as { info: Message }).info
indexProjectedMessage(info)
const load = messageLoads.get(info.sessionID)
load?.touchedMessages.add(info.id)
load?.removedMessages.delete(info.id)
const items = optimistic.get(info.sessionID)
const item = items?.get(info.id)
if (items && item) {
if (item.parts.length === 0) clearOptimistic(info.sessionID, info.id)
if (item.parts.length > 0) items.set(info.id, { ...item, confirmedMessage: true })
}
const orphans = orphanParts.get(info.sessionID)
orphans?.delete(info.id)
if (orphans?.size === 0) orphanParts.delete(info.sessionID)
@@ -1123,13 +1097,18 @@ export function createServerSession(
return
}
const result = Binary.search(messages, messageKey(info), messageKey)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found)
setData("message", info.sessionID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, info)
return next
})
if (result.found) {
setData("message", info.sessionID, result.index, reconcile(info))
return
}
// Delivery rewrites time.created, changing the sort key; reposition instead of duplicating.
setData("message", info.sessionID, (value = []) => {
const next = value.slice()
const moved = next.findIndex((message) => message.id === info.id)
if (moved >= 0) next.splice(moved, 1)
next.splice(moved >= 0 && moved < result.index ? result.index - 1 : result.index, 0, info)
return next
})
return
}
case "message.removed": {
@@ -1144,13 +1123,11 @@ export function createServerSession(
load?.deltaParts.delete(props.messageID)
load?.carriedDeltaParts.delete(props.messageID)
load?.removedParts.delete(props.messageID)
load?.optimisticParts.delete(props.messageID)
pendingParts.get(props.sessionID)?.delete(props.messageID)
if (pendingParts.get(props.sessionID)?.size === 0) pendingParts.delete(props.sessionID)
const removedMessagesForSession = removedMessages.get(props.sessionID) ?? new Set<string>()
removedMessagesForSession.add(props.messageID)
removedMessages.set(props.sessionID, removedMessagesForSession)
clearOptimistic(props.sessionID, props.messageID)
setData(
produce((draft) => {
const messages = draft.message[props.sessionID]
@@ -1196,12 +1173,8 @@ export function createServerSession(
pending?.delete(part.id)
if (pending?.size === 0) pendingParts.get(part.sessionID)?.delete(part.messageID)
if (pendingParts.get(part.sessionID)?.size === 0) pendingParts.delete(part.sessionID)
const optimistic = load?.optimisticParts.get(part.messageID)
optimistic?.delete(part.id)
if (optimistic?.size === 0) load?.optimisticParts.delete(part.messageID)
deltaBases.delete(part.id)
trackPartChange(part.sessionID, part.messageID, part.id)
confirmOptimisticPart(part.sessionID, part.messageID, part)
setData(
"part_text_accum_delta",
produce((draft) => void delete draft[part.id]),
@@ -1211,14 +1184,9 @@ export function createServerSession(
setData("part", part.messageID, [part])
return
}
const result = Binary.search(parts, part.id, (item) => item.id)
if (result.found) setData("part", part.messageID, result.index, reconcile(part))
if (!result.found)
setData("part", part.messageID, (value = []) => {
const next = value.slice()
next.splice(result.index, 0, part)
return next
})
const index = parts.findIndex((item) => item.id === part.id)
if (index >= 0) setData("part", part.messageID, index, reconcile(part))
if (index < 0) setData("part", part.messageID, (value = []) => [...value, part])
return
}
case "message.part.removed": {
@@ -1240,20 +1208,16 @@ export function createServerSession(
const parts = load.removedParts.get(props.messageID) ?? new Set<string>()
parts.add(props.partID)
load.removedParts.set(props.messageID, parts)
const optimistic = load.optimisticParts.get(props.messageID)
optimistic?.delete(props.partID)
if (optimistic?.size === 0) load.optimisticParts.delete(props.messageID)
}
trackPartChange(props.sessionID, props.messageID, props.partID)
clearOptimisticPart(props.sessionID, props.messageID, props.partID)
setData(
produce((draft) => {
delete draft.part_text_accum_delta[props.partID]
deltaBases.delete(props.partID)
const parts = draft.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (result.found) parts.splice(result.index, 1)
const index = parts.findIndex((part) => part.id === props.partID)
if (index >= 0) parts.splice(index, 1)
if (parts.length === 0) delete draft.part[props.messageID]
}),
)
@@ -1269,8 +1233,8 @@ export function createServerSession(
}
const parts = data.part[props.messageID]
if (!parts) return
const result = Binary.search(parts, props.partID, (part) => part.id)
if (!result.found) return
const index = parts.findIndex((part) => part.id === props.partID)
if (index < 0) return
trackPartChange(props.sessionID, props.messageID, props.partID)
const load = messageLoads.get(props.sessionID)
if (load) {
@@ -1282,7 +1246,7 @@ export function createServerSession(
if (carried?.size === 0) load.carriedDeltaParts.delete(props.messageID)
}
const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field]
const current = parts[index]?.[field]
if (!deltaBases.has(props.partID) && typeof current === "string")
deltaBases.set(props.partID, { base: current, sessionID: props.sessionID })
setData(
@@ -1295,7 +1259,7 @@ export function createServerSession(
props.messageID,
produce((draft) => {
if (!draft) return
const part = draft[result.index]
const part = draft[index]
const field = props.field as keyof typeof part
;(part[field] as string) = ((part[field] as string | undefined) ?? "") + props.delta
}),
@@ -1354,25 +1318,30 @@ export function createServerSession(
while (true) {
const pendingAt = pendingRevision.get(sessionID) ?? 0
const formAt = formRevision.get(sessionID) ?? 0
const previous = new Set(data.input[sessionID] ?? [])
const result = await load()
const pendingStable = (pendingRevision.get(sessionID) ?? 0) === pendingAt
const formStable = (formRevision.get(sessionID) ?? 0) === formAt
if (pendingStable) {
const current = new Set(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id))
const settled = settledInputs.get(sessionID) ?? new Set<string>()
previous.forEach((messageID) => {
if (!current.has(messageID)) settled.add(messageID)
})
if (settled.size) settledInputs.set(sessionID, settled)
result.pending.forEach(v2.confirm)
setData("pending", sessionID, reconcile(result.pending))
setData(
"input",
sessionID,
reconcile(result.pending.filter((item) => item.type !== "compaction").map((item) => item.id)),
)
setData("input", sessionID, reconcile([...current]))
}
if (formStable) setData("form", sessionID, reconcile(result.forms))
if (pendingStable && formStable) return
}
},
refreshPinned(hydrateTransient: (sessionID: string) => Promise<void>) {
const sessions = [...pinned.keys()]
return Promise.all(
[...pinned.keys()].flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => undefined)
sessions.flatMap((sessionID) => [sync(sessionID, { force: true }), hydrateTransient(sessionID)]),
).then(() => sessions.forEach(reconcileInbox))
},
invalidate() {
invalidationRevision += 1
@@ -1381,77 +1350,81 @@ export function createServerSession(
setMeta("at", {})
},
prefetch,
shouldPrefetch(sessionID: string, limit: number) {
shouldPrefetch(sessionID: string, messageCount: number) {
if (data.message[sessionID] === undefined) return true
if (Date.now() - (meta.at[sessionID] ?? 0) > 15_000) return true
if (meta.complete[sessionID]) return false
return (meta.limit[sessionID] ?? 0) <= limit
return (data.message[sessionID]?.length ?? 0) <= messageCount
},
fresh(sessionID: string, ttl: number) {
return Date.now() - (meta.at[sessionID] ?? 0) <= ttl
},
optimistic: {
add(input: { sessionID: string; message: Message; parts: Part[] }) {
const parts = input.parts
.filter((part) => !!part?.id && !SKIP_PARTS.has(part.type))
.sort((a, b) => cmp(a.id, b.id))
const load = messageLoads.get(input.sessionID)
if (load?.clearedMessageParts.has(input.message.id)) {
const touched = load.touchedParts.get(input.message.id) ?? new Set<string>()
parts.forEach((part) => touched.add(part.id))
load.touchedParts.set(input.message.id, touched)
inbox: {
echo(input: PromptEcho) {
const created = Date.now()
const files = input.files?.map((file) => ({
data: "",
mime: file.mime,
source: { type: "uri" as const, uri: file.uri },
name: file.name,
mention: file.mention,
}))
const item: SessionInboxInfo = {
id: input.messageID,
sessionID: input.sessionID,
timeCreated: created,
type: "user",
delivery: "steer",
payload: { text: input.text, files, agents: input.agents },
}
if (load) {
load.removedMessages.delete(input.message.id)
load.optimisticParts.set(input.message.id, new Set(parts.map((part) => part.id)))
}
const items = optimistic.get(input.sessionID)
const removedMessagesForSession = removedMessages.get(input.sessionID)
removedMessagesForSession?.delete(input.message.id)
if (removedMessagesForSession?.size === 0) removedMessages.delete(input.sessionID)
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
indexProjectedMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
produce((draft) => {
for (const part of [...(data.part[input.message.id] ?? []), ...parts]) {
delete draft[part.id]
deltaBases.delete(part.id)
}
}),
)
setData("part", input.message.id, parts)
const projected = normalizeSessionMessages(input.sessionID, [
{ id: `${input.messageID}:agent`, type: "agent-switched", agent: input.agent, time: { created } },
{
id: `${input.messageID}:model`,
type: "model-switched",
model: {
id: input.model.modelID,
providerID: input.model.providerID,
variant: input.model.variant,
},
time: { created },
},
{
id: input.messageID,
type: "user",
text: input.displayText,
files,
agents: input.agents,
time: { created },
},
])
const message = projected.messages[0]!
const comments: Part[] = input.comments.map((comment, index) => ({
id: `${input.messageID}:comment:${index}`,
sessionID: input.sessionID,
messageID: input.messageID,
type: "text",
text: formatCommentNote(comment),
synthetic: true,
metadata: createCommentMetadata(comment),
}))
const parts = [...(projected.parts.get(input.messageID) ?? []), ...comments]
removedMessages.get(input.sessionID)?.delete(input.messageID)
markEcho(input.sessionID, input.messageID)
pendingRevision.set(input.sessionID, (pendingRevision.get(input.sessionID) ?? 0) + 1)
batch(() => {
setData("pending", input.sessionID, (items = []) => [...items.filter((entry) => entry.id !== item.id), item])
if (!data.input[input.sessionID]?.includes(input.messageID))
setData("input", input.sessionID, [...(data.input[input.sessionID] ?? []), input.messageID])
setData("message", input.sessionID, (messages = []) => merge(messages, [message]).sort(compareMessages))
setData("part", input.messageID, parts)
})
},
remove(input: { sessionID: string; messageID: string }) {
const item = optimistic.get(input.sessionID)?.get(input.messageID)
if (!item) return
messageLoads.get(input.sessionID)?.optimisticParts.delete(input.messageID)
clearOptimistic(input.sessionID, input.messageID)
if (item.confirmedMessage) {
const partIDs = new Set(item.parts.map((part) => part.id))
setData(
produce((draft) => {
for (const part of item.parts) {
delete draft.part_text_accum_delta[part.id]
deltaBases.delete(part.id)
}
const parts = draft.part[input.messageID]
if (!parts) return
draft.part[input.messageID] = parts.filter((part) => !partIDs.has(part.id))
if (draft.part[input.messageID]?.length === 0) delete draft.part[input.messageID]
}),
)
return
}
const projectedIDs = new Set(projectMessageSource(item.message).map((message) => message.id))
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => !projectedIDs.has(message.id)),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
confirm: confirmInbox,
reconcile: reconcileInbox,
clearEcho(input: { sessionID: string; messageID: string }) {
if (echoes.get(input.sessionID)?.get(input.messageID) !== "sending") return false
return removeEcho(input.sessionID, input.messageID)
},
},
async todo(sessionID: string, request?: { force?: boolean }) {
@@ -1463,14 +1436,14 @@ export function createServerSession(
history: {
more: (sessionID: string) =>
data.message[sessionID] !== undefined &&
meta.limit[sessionID] !== undefined &&
meta.complete[sessionID] !== undefined &&
!meta.complete[sessionID] &&
!!meta.cursor[sessionID],
loading: (sessionID: string) => meta.loading[sessionID] ?? false,
async loadMore(sessionID: string, count = historyMessagePageSize) {
async loadMore(sessionID: string) {
touch(sessionID)
if (meta.loading[sessionID] || meta.complete[sessionID] || !meta.cursor[sessionID]) return
await loadMessages(sessionID, count, meta.cursor[sessionID], "prepend")
await loadMessages(sessionID, meta.cursor[sessionID], "prepend")
},
},
evict(sessionID: string) {
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type {
McpListInput,
McpResourceCatalogInput,
OpenCodeEvent,
SessionApi,
SessionInfo,
SessionListInput,
@@ -15,11 +16,13 @@ import {
loadMcpResourcesQuery,
reconcileActiveSessionStatuses,
seedActiveSessionStatuses,
sessionListEventDirectories,
shouldRefreshWorkspaceSessions,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import type { ServerApi } from "@/utils/server"
import { adaptServerEvent } from "./server-sdk"
type McpApi = ServerApi["mcp"]
@@ -214,6 +217,23 @@ describe("workspace session inventory", () => {
expect(shouldRefreshWorkspaceSessions(event("session.updated", "session.moved"))).toBe(true)
expect(shouldRefreshWorkspaceSessions(event("message.updated"))).toBe(false)
})
test("invalidates both locations when a session moves", () => {
const event = adaptServerEvent({
id: "evt_moved",
created: 1,
type: "session.moved",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
location: { directory: "/source" },
data: {
sessionID: "ses_1",
location: { directory: "/destination" },
projectID: "project_2",
},
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
expect(sessionListEventDirectories(event)).toEqual(["/source", "/destination"])
})
})
describe("canDisposeDirectory", () => {
+18 -16
View File
@@ -88,6 +88,12 @@ const SESSION_LIST_EVENTS = new Set([
"session.usage.updated",
])
export function sessionListEventDirectories(event: ServerEvent) {
if (!SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) return []
const destination = event.current?.type === "session.moved" ? event.current.data.location.directory : undefined
return [...new Set([event.current?.location?.directory, destination].filter((item): item is string => !!item))]
}
type McpListApi = {
readonly list: (input?: McpListInput) => Promise<McpListOutput>
}
@@ -231,7 +237,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return { pending, forms }
})
}
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
const hydrateSession = async (sessionID: string) => {
await Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
session.inbox.reconcile(sessionID)
}
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [
@@ -551,14 +560,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
type: "session.updated",
properties: { sessionID: info.id, info },
})
const markSessionListChanged = (event: ServerEvent, directory: string, previousDirectory?: string) => {
if (SESSION_LIST_EVENTS.has(event.current?.type ?? event.type)) {
const markSessionListsChanged = (event: ServerEvent) => {
sessionListEventDirectories(event).forEach((directory) => {
const key = directoryKey(directory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
}
if (!previousDirectory || previousDirectory === directory) return
const key = directoryKey(previousDirectory)
sessionRevision.set(key, (sessionRevision.get(key) ?? 0) + 1)
})
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
@@ -569,15 +575,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const directory = event.current?.location?.directory
const eventType: string = event.type
const previousDirectory =
event.current?.type === "session.moved"
? session.get(event.current.data.sessionID)?.location.directory
: undefined
markSessionListChanged(event, directory, previousDirectory)
markSessionListsChanged(event)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.moved") {
@@ -629,9 +630,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
homeSessions.refresh(event.type)
catalog.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType, directory })
connection.handleEvent({ type: eventType })
if (directory === "global") {
if (!directory) {
applyGlobalEvent({
event,
project: globalStore.project,
@@ -644,6 +645,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return
}
const key = directoryKey(directory)
if (event.current?.type === "session.forked")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -42,7 +42,7 @@ test("invalidates global and active catalogs after connection", async () => {
load: async () => {},
})
catalog.handleEvent({ type: "server.connected", directory: "global" })
catalog.handleEvent({ type: "server.connected" })
await Bun.sleep(0)
expect(queryClient.getQueryState(global)?.isInvalidated).toBe(true)
@@ -4,7 +4,7 @@ import { pathKey, type PathKey } from "@/utils/path-key"
type CatalogEvent = {
type: string
directory: string
directory?: string
}
export function createCatalogSync(input: {
@@ -24,7 +24,7 @@ export function createCatalogSync(input: {
event.type === "integration.updated" ||
event.type === "integration.connection.updated"
) {
void refresh(event.directory === "global" ? null : pathKey(event.directory)).catch(() => undefined)
void refresh(event.directory ? pathKey(event.directory) : null).catch(() => undefined)
}
}
@@ -12,10 +12,8 @@ test("invalidates disconnected data and synchronizes after the handshake", () =>
connected: () => calls.push("connected"),
})
connection.handleEvent({ type: "server.connected", directory: "global" })
connection.handleEvent({ type: "server.connected" })
expect(calls).toContain("connected")
connection.handleEvent({ type: "server.connected", directory: "/repo" })
expect(calls.filter((call) => call === "connected")).toHaveLength(1)
setStatus("connected")
return dispose
})
@@ -12,8 +12,8 @@ export function createConnectionSync(input: {
})
let connectedOnce = false
function handleEvent(event: { type: string; directory: string }) {
if (event.directory !== "global" || event.type !== "server.connected") return
function handleEvent(event: { type: string }) {
if (event.type !== "server.connected") return
input.connected({ reconnect: connectedOnce })
connectedOnce = true
}
@@ -1,137 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@/types"
import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "./sync"
type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({
id,
sessionID,
role: "user",
time: { created },
agent: "assistant",
model: { providerID: "openai", modelID: "gpt" },
})
const textPart = (id: string, sessionID: string, messageID: string): Text => ({
id,
sessionID,
messageID,
type: "text",
text: id,
})
describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] },
part: {} as Record<string, Part[] | undefined>,
}
applyOptimisticAdd(draft, {
sessionID,
message: userMessage("msg_a", sessionID, 2),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")],
})
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
})
test("applyOptimisticRemove removes message and part entries", () => {
const sessionID = "ses_1"
const draft = {
message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
part: {
msg_1: [textPart("prt_1", sessionID, "msg_1")],
msg_2: [textPart("prt_2", sessionID, "msg_2")],
} as Record<string, Part[] | undefined>,
}
applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
expect(draft.part.msg_1).toBeUndefined()
expect(draft.part.msg_2).toHaveLength(1)
})
test("mergeOptimisticPage keeps pending messages in fetched timelines", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }],
)
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true)
})
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [{ id: "msg_2", part: [textPart("prt_2", sessionID, "msg_2")] }],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
expect(page.confirmed).toEqual([])
})
test("mergeOptimisticPage confirms echoed messages once all parts arrive", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_2", sessionID)],
part: [
{
id: "msg_2",
part: [{ ...textPart("prt_1", sessionID, "msg_2"), text: "server" }, textPart("prt_2", sessionID, "msg_2")],
},
],
complete: true,
},
[
{
message: userMessage("msg_2", sessionID),
parts: [textPart("prt_1", sessionID, "msg_2"), textPart("prt_2", sessionID, "msg_2")],
},
],
)
expect(page.confirmed).toEqual(["msg_2"])
expect(page.part.find((x) => x.id === "msg_2")?.part).toMatchObject([
{ id: "prt_1", type: "text", text: "server" },
{ id: "prt_2", type: "text", text: "prt_2" },
])
})
})
-108
View File
@@ -1,114 +1,6 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@/types"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID)
if (index >= 0) messages.splice(index, 1)
}
delete draft.part[input.messageID]
}
export const useSync = () => {
const serverSync = useServerSync()
+16 -5
View File
@@ -2,6 +2,7 @@ import { useQueryOptions } from "@/context/server-sync"
import { Iterable, pipe } from "effect"
import { type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog"
import { useIntegrations } from "./use-integrations"
import { useQuery } from "@tanstack/solid-query"
import { pathKey } from "@/utils/path-key"
@@ -23,6 +24,7 @@ export function useProviders(directory: Accessor<string | undefined>) {
const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null)
})
const integrations = useIntegrations(directory)
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
@@ -30,13 +32,22 @@ export function useProviders(directory: Accessor<string | undefined>) {
ready: () => providersQuery.isSuccess,
all: () => providers().all,
default: () => providers().default,
popular: () =>
pipe(
// V2 servers list only available providers, so the connectable catalog
// comes from the integration list, with the provider catalog as fallback.
popular: () => {
const catalog = integrations
.list()
.filter((integration) => popularProviderSet.has(integration.id))
.map((integration) => ({ id: integration.id, name: integration.name }))
const seen = new Set(catalog.map((integration) => integration.id))
return pipe(
providers().all,
Iterable.map(([, p]) => p),
Iterable.filter((p) => popularProviderSet.has(p.id)),
(v) => Array.from(v),
),
Iterable.filter((p) => popularProviderSet.has(p.id) && !seen.has(p.id)),
Iterable.map((p) => ({ id: p.id, name: p.name })),
(v) => [...catalog, ...v],
)
},
connected: () => {
const connected = new Set(providers().connected)
return pipe(
@@ -94,7 +94,7 @@ export function createTimelineController(input: {
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const projection = createTimelineProjection({
messages: input.session.history.messages,
userMessages: input.userMessages,
@@ -1203,6 +1203,8 @@ function MessageTimelineView(
onCleanup(() => {
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 (
+1 -1
View File
@@ -4,7 +4,7 @@ export type UpdaterState =
| { status: "disabled" }
| { status: "idle" }
| { status: "checking" }
| { status: "downloading"; version: string; percent?: number }
| { status: "downloading"; version: string }
| { status: "ready"; version: string }
| { status: "up-to-date" }
| { status: "installing"; version: string }
+4 -1
View File
@@ -22,7 +22,10 @@ function blobUrl(id: string, blob: Blob) {
}
async function blobID(blob: Blob) {
const id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer())))
const bytes = crypto.subtle
? new Uint8Array(await crypto.subtle.digest("SHA-256", await blob.arrayBuffer()))
: crypto.getRandomValues(new Uint8Array(16))
const id = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
return id
+70 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { rm } from "fs/promises"
import { mkdir, rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
@@ -27,6 +27,7 @@ const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?
const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
const releaseAssets = new Map<string, Promise<Map<string, string>>>()
const allTargets: {
os: string
@@ -99,6 +100,7 @@ for (const item of targets) {
}
const target = targetName(item)
const name = target.replace(binary, "cli")
const executablePath = await compileExecutable(item)
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
@@ -115,6 +117,7 @@ for (const item of targets) {
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
executablePath,
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
@@ -154,6 +157,72 @@ for (const item of targets) {
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]) {
return [
binary,
+7 -3
View File
@@ -14,9 +14,13 @@ async function published(name: string, version: string) {
async function publish(dir: string, name: string, version: string) {
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) return console.log(`already published ${name}@${version}`)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
const exists = await published(name, version)
if (exists) console.log(`already published ${name}@${version}`)
if (!exists) {
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
if (Script.channel === "beta") await $`npm dist-tag add ${`${name}@${version}`} next`
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
+1 -1
View File
@@ -84,7 +84,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: {
path:
process.env.OPENCODE_DB ??
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
+2 -2
View File
@@ -25,12 +25,12 @@ const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
const decodeRegistration = Schema.decodeUnknownEffect(Schema.fromJsonString(Service.Info))
export function filename(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return "service.json"
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
+2 -1
View File
@@ -35,7 +35,8 @@ describe("updater", () => {
test("accepts strict release version variants", () => {
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("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
})
+4
View File
@@ -11,6 +11,8 @@ import { ServiceConfig } from "../src/services/service-config"
test("managed service ports are stable per installation channel", () => {
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("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
@@ -37,6 +39,8 @@ test("local channel stores service config with the local service filename", asyn
test("service filenames share release channels and identify preview channels", () => {
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("local")).toBe("service-local.json")
expect(ServiceConfig.filename("preview-a")).toBe("service-preview-a.json")
+40 -41
View File
@@ -20,7 +20,6 @@ import type { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode-ai/schema/event-log"
import type { Shell } from "@opencode-ai/schema/shell"
import type { DateTime } from "effect"
import type { Provider } from "@opencode-ai/schema/provider"
import type { Integration } from "@opencode-ai/schema/integration"
import type { Form } from "@opencode-ai/schema/form"
@@ -316,7 +315,7 @@ export type Endpoint5_31Output =
| (
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.created"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -336,7 +335,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.agent.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -349,7 +348,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.model.selected"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -362,7 +361,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.moved"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -376,7 +375,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -385,7 +384,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -394,7 +393,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -410,7 +409,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivered"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -419,7 +418,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.enqueued"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -432,7 +431,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.cancelled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -441,7 +440,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.inbox.delivery.changed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -454,7 +453,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -463,7 +462,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.succeeded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -472,7 +471,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -484,7 +483,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.execution.interrupted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -493,7 +492,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.instructions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -506,7 +505,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -520,7 +519,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.skill.activated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -534,7 +533,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -543,7 +542,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.shell.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -561,7 +560,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -576,7 +575,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -598,7 +597,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.step.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -622,7 +621,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -635,7 +634,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.text.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -650,7 +649,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -664,7 +663,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.reasoning.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -679,7 +678,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -693,7 +692,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.input.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -707,7 +706,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.called"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -723,7 +722,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.success"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -759,7 +758,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.tool.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -798,7 +797,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.retry.scheduled"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -813,7 +812,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.started"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -827,7 +826,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.ended"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -841,7 +840,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.compaction.failed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -855,7 +854,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.staged"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -864,7 +863,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.cleared"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -873,7 +872,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.revert.committed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -882,7 +881,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: DateTime.Utc
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.usage.recorded"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -192,7 +192,7 @@ export type ProviderInfo = {
id: string
integrationID?: string
name: string
disabled?: boolean
activation: "auto" | "enabled" | "disabled"
package: string
settings?: { [x: string]: any }
headers?: { [x: string]: string }
@@ -21,7 +21,7 @@ export default function TermsOfService() {
<section data-component="brand-content">
<article data-component="terms-of-service">
<h1>Terms of Use</h1>
<p class="effective-date">Effective date: Mar 6, 2026</p>
<p class="effective-date">Effective date: Aug 15, 2026</p>
<p>
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
@@ -154,6 +154,11 @@ export default function TermsOfService() {
is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or
otherwise objectionable;
</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>Represent that the Output was human-generated when it was not;</li>
<li>
+10 -10
View File
@@ -1,6 +1,6 @@
export * as Bus from "./bus.js"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import type { EventLog } from "@opencode-ai/schema/event-log"
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 = {
readonly id: Event.ID
readonly type: string
readonly created?: DateTime.Utc
readonly created?: number
readonly seq: number
readonly aggregateID: string
readonly data: Record<string, unknown>
@@ -74,7 +74,7 @@ const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
}
return {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
created: event.created ?? 0,
type: definition.type,
durable: envelope(event.aggregateID, event.seq, definition.durable.version),
data: Schema.decodeUnknownSync(definition.data)(event.data),
@@ -283,7 +283,7 @@ export function configured(options?: Options) {
if (
stored?.id === event.id &&
stored.type === versionedType(definition.type, durable.version) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)) &&
stored.created === (event.created ?? 0) &&
isDeepStrictEqual(stored.data, encoded)
) {
if (input.ownerID && row?.ownerID == null) {
@@ -358,7 +358,7 @@ export function configured(options?: Options) {
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(0)),
created: event.created ?? 0,
type: versionedType(definition.type, durable.version),
data: encoded,
},
@@ -455,7 +455,7 @@ export function configured(options?: Options) {
definition,
{
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
@@ -491,7 +491,7 @@ export function configured(options?: Options) {
commit: options?.commit,
event: {
id: options?.id ?? Event.ID.create(),
created: yield* DateTime.now,
created: yield* Clock.currentTimeMillis,
...(options?.metadata ? { metadata: options.metadata } : {}),
type: definition.type,
...(location ? { location } : {}),
@@ -571,7 +571,7 @@ export function configured(options?: Options) {
id: event.id,
aggregate_id: aggregateID,
seq,
created: DateTime.toEpochMillis(event.created),
created: event.created,
type: versionedType(item.definition.type, item.definition.durable.version),
data: encoded,
})
@@ -619,7 +619,7 @@ export function configured(options?: Options) {
Effect.gen(function* () {
const payload = {
id: event.id,
created: event.created ?? DateTime.makeUnsafe(0),
created: event.created ?? 0,
type: definition.type,
data: Schema.decodeUnknownSync(definition.data)(event.data),
} as Event.Payload
@@ -733,7 +733,7 @@ export function configured(options?: Options) {
return [
decodeSerializedEvent({
id: event.id,
created: DateTime.makeUnsafe(event.created),
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
+2 -2
View File
@@ -65,8 +65,8 @@ const layer = Layer.effect(
const integrations = yield* Integration.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.disabled) return false
if (typeof provider.settings?.apiKey === "string") return true
if (provider.activation === "disabled") return false
if (provider.activation === "enabled") return true
if (integration?.connections.length) return true
return provider.integrationID === undefined && !integration
}
@@ -40,6 +40,7 @@ export const Plugin = define({
for (const [id, item] of configuredProviders(loaded.entries)) {
const providerID = id
catalog.provider.update(providerID, (provider) => {
provider.activation = "enabled"
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
+36 -20
View File
@@ -23,6 +23,32 @@ export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/FileSystem/Search") {}
const REFRESH_INTERVAL = Duration.toMillis("10 seconds")
type Prepared = ReturnType<typeof fuzzysort.prepare>
function emptyIndex() {
return { files: new Map<string, Prepared>(), directories: new Map<string, Prepared>() }
}
function search(index: ReturnType<typeof emptyIndex>, input: FileSystem.FindInput) {
const items =
input.type === "file"
? Array.from(index.files.values())
: input.type === "directory"
? Array.from(index.directories.values())
: [...index.files.values(), ...index.directories.values()]
const result = fuzzysort.go(input.query, items, { limit: input.limit ?? 50 })
// Targets are owned by the current location index. The only global fuzzysort
// state left is its query cache, which must not retain every query forever.
fuzzysort.cleanup()
return result.map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
path: RelativePath.make(relative),
type,
})
})
}
export const ripgrepLayer = Layer.effect(
Service,
@@ -32,12 +58,13 @@ export const ripgrepLayer = Layer.effect(
const scope = yield* Scope.Scope
const clock = yield* Clock.Clock
const home = Protected.isHome(location.directory)
let index = { files: [] as string[], directories: new Set<string>() }
let index = emptyIndex()
let initialized = false
let settledAt = Number.NEGATIVE_INFINITY
let refreshing = false
const scan = Effect.gen(function* () {
const next = { files: [] as string[], directories: new Set<string>() }
const next = emptyIndex()
const previous = index
if (!initialized) index = next
yield* ripgrep.find({
cwd: location.directory,
@@ -46,11 +73,13 @@ export const ripgrepLayer = Layer.effect(
exclude: home ? [...Protected.names()].map((name) => `${name}/**`) : undefined,
onEntry: (entry) =>
Effect.sync(() => {
next.files.push(entry.path)
next.files.set(entry.path, previous.files.get(entry.path) ?? fuzzysort.prepare(entry.path))
const parts = entry.path.split("/")
parts
.slice(0, -1)
.forEach((_, offset) => next.directories.add(parts.slice(0, offset + 1).join("/") + path.sep))
parts.slice(0, -1).forEach((_, offset) => {
const directory = parts.slice(0, offset + 1).join("/") + path.sep
if (!next.directories.has(directory))
next.directories.set(directory, previous.directories.get(directory) ?? fuzzysort.prepare(directory))
})
}),
})
index = next
@@ -74,20 +103,7 @@ export const ripgrepLayer = Layer.effect(
find: (input) =>
Effect.gen(function* () {
yield* refresh
const items =
input.type === "file"
? index.files
: input.type === "directory"
? Array.from(index.directories)
: [...index.files, ...index.directories]
return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => {
const relative = item.target
const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const)
return FileSystem.Entry.make({
path: RelativePath.make(relative),
type,
})
})
return search(index, input)
}),
})
}),
+9 -5
View File
@@ -1,4 +1,5 @@
import { Effect, Layer, LayerMap } from "effect"
import path from "path"
import { Agent } from "./agent.js"
import { AISDK } from "./aisdk.js"
import { Catalog } from "./catalog.js"
@@ -49,6 +50,7 @@ import { ReadToolFileSystem } from "./tool/read-filesystem.js"
import { Tool } from "./tool.js"
import { ToolOutput } from "./tool-output.js"
import { Vcs } from "./vcs.js"
import { AbsolutePath } from "./schema.js"
export { LocationServiceMap } from "./location-service-map.js"
@@ -110,11 +112,13 @@ export type LocationError = LayerNode.Error<typeof locationServices>
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
): Layer.Layer<LocationServiceMap.Service> {
// Structural Equal is own-key-set sensitive, so `{ directory }` (schema-decoded
// payloads omit optional keys) and `{ directory, workspaceID: undefined }` are
// different RcMap keys. The RcMap caches by the raw key before the build
// callback runs, so canonicalize at the map boundary to the key-present shape.
const canonical = (ref: Location.Ref) => Location.Ref.make({ directory: ref.directory, workspaceID: ref.workspaceID })
// Structural Equal distinguishes optional-key shape and Windows separator style.
// The RcMap caches the raw key before the build callback, so normalize both here.
const canonical = (ref: Location.Ref) =>
Location.Ref.make({
directory: AbsolutePath.make(process.platform === "win32" ? path.normalize(ref.directory) : ref.directory),
workspaceID: ref.workspaceID,
})
return Layer.effect(
LocationServiceMap.Service,
Effect.map(
+43 -10
View File
@@ -358,17 +358,21 @@ export const layer = Layer.effect(
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolveModel(
selected,
variant,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const runtimeInfo = yield* withVariant(selected, variant)
const model = yield* fromCatalogModel(runtimeInfo, credential, {
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
})
const runtime =
provider?.activation === "enabled" &&
credential === undefined &&
!hasConfiguredAuth(runtimeInfo) &&
usesAPIKeyAuth(runtimeInfo.package)
? LanguageModel.update(model, { route: model.route.with({ auth: Auth.none }) })
: model
return {
model,
model: runtime,
ref: Ref.make({
id: selected.id,
providerID: selected.providerID,
@@ -399,6 +403,35 @@ export const layer = Layer.effect(
}),
)
function hasConfiguredAuth(model: Info) {
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
(value) => typeof value === "string" && value !== "",
)
}
function usesAPIKeyAuth(packageName: string | undefined) {
const name = Provider.packageName(packageName)
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
name === "@opencode-ai/ai/providers/openai" ||
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
name === "@opencode-ai/ai/providers/azure" ||
name?.startsWith("@opencode-ai/ai/providers/azure/") === true
)
}
export const node = makeLocationNode({
service: Service,
layer,
+1
View File
@@ -86,6 +86,7 @@ function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
const info = {
id: providerID,
name: item.name,
activation: "auto",
package: Provider.aisdk(item.npm),
...(item.api ? { settings: { baseURL: item.api } } : {}),
} satisfies Provider.Info
@@ -10,7 +10,7 @@ export const LLMGatewayPlugin = define({
const configured = new Set((yield* integrations.list()).map((integration) => integration.id))
yield* ctx.catalog.transform((evt) => {
for (const item of evt.provider.list()) {
if (item.provider.disabled) continue
if (item.provider.activation === "disabled") continue
if (!Provider.isAISDK(item.provider.package)) continue
if (Provider.packageName(item.provider.package) !== "@ai-sdk/openai-compatible") continue
if (item.provider.settings?.baseURL !== "https://api.llmgateway.io/v1") continue
@@ -178,7 +178,10 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || connected || item.provider.settings?.apiKey)
catalog.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.settings = { ...provider.settings, apiKey: "public" }
if (!hasKey) {
provider.activation = "enabled"
provider.settings = { ...provider.settings, apiKey: "public" }
}
})
if (hasKey) return
for (const model of item.models.values()) {
+28 -1
View File
@@ -63,12 +63,39 @@ examples, but do not fetch it to determine the V2 configuration shape.
See the [full configuration guide](https://opencode.ai/v2/docs/config) for
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)
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/(Get started)/migrate-v1.mdx`.
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+5 -7
View File
@@ -789,7 +789,10 @@ const layer = Layer.effect(
return false
}),
)
if (recovered) return
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
@@ -873,12 +876,7 @@ const layer = Layer.effect(
),
),
interrupt: Effect.fn("Session.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)
}),
),
Effect.uninterruptible(execution.interrupt(sessionID, options)),
),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
+22 -11
View File
@@ -1,7 +1,8 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -19,8 +21,10 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
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. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
sessionID: SessionSchema.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }),
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
),
)
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation)
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force) => drain(sessionID, force),
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
interrupt: (sessionID, options) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
+11 -3
View File
@@ -161,7 +161,7 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: event.created,
timeCreated: DateTime.makeUnsafe(event.created),
}
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 sessionID: SessionSchema.ID
readonly item: Item
readonly timeCreated: DateTime.Utc
readonly timeCreated: number
},
) {
const message = yield* db
@@ -222,7 +222,7 @@ export const projectAdmitted = Effect.fn("SessionInbox.projectAdmitted")(functio
: encodeMove(request.item.payload),
delivery: request.item.delivery,
enqueued_seq: request.enqueuedSeq,
time_created: DateTime.toEpochMillis(request.timeCreated),
time_created: request.timeCreated,
})
.onConflictDoNothing()
.returning({ id: SessionInboxTable.id })
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
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
* item in either delivery mode.
+22 -21
View File
@@ -26,6 +26,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type DraftTool = WritableDraft<SessionMessage.AssistantTool>
type DraftText = WritableDraft<SessionMessage.AssistantText>
type DraftReasoning = WritableDraft<SessionMessage.AssistantReasoning>
const created = DateTime.makeUnsafe(event.created)
const latestTool = (assistant: DraftAssistant | undefined, id?: string) =>
assistant?.content.findLast(
@@ -70,7 +71,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created: event.created },
time: { created },
}),
)
})
@@ -85,7 +86,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
model: event.data.model,
previous,
time: { created: event.created },
time: { created },
}),
)
})
@@ -101,7 +102,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: yield* adapter.getLocation(),
time: { created: event.created },
time: { created },
}),
)
})
@@ -126,7 +127,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
text: event.data.text,
description: `Instructions updated: ${Object.keys(event.data.delta).join(", ")}`,
metadata: event.metadata,
time: { created: event.created },
time: { created },
}),
)
},
@@ -138,7 +139,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.data.metadata,
id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic",
time: { created: event.created },
time: { created },
}),
)
},
@@ -151,7 +152,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created: event.created },
time: { created },
}),
)
},
@@ -164,7 +165,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
shellID: event.data.shell.id,
command: event.data.shell.command,
status: event.data.shell.status,
time: { created: event.created },
time: { created },
}),
)
},
@@ -177,7 +178,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.status = event.data.shell.status
draft.exit = event.data.shell.exit
draft.output = event.data.output
draft.time.completed = event.created
draft.time.completed = created
}),
)
}
@@ -205,7 +206,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.retry = undefined
draft.time.completed = event.created
draft.time.completed = created
}),
)
}
@@ -216,7 +217,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
time: { created: event.created },
time: { created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
@@ -225,7 +226,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.time.completed = created
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
@@ -239,7 +240,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = event.created
draft.time.completed = created
draft.finish = "error"
draft.error = castDraft(event.data.error)
draft.retry = undefined
@@ -277,7 +278,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "tool",
id: event.data.id,
name: event.data.name,
time: { created: event.created },
time: { created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
}),
),
@@ -296,7 +297,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
match.time.ran = event.created
match.time.ran = created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
status: "running",
@@ -315,7 +316,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && match.state.status === "running") {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.time.completed = created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -333,7 +334,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match && (match.state.status === "streaming" || match.state.status === "running")) {
match.executed = event.data.executed || match.executed === true
match.providerResultState = event.data.resultState
match.time.completed = event.created
match.time.completed = created
match.state = castDraft(
SessionMessage.ToolStateError.make({
status: "error",
@@ -354,7 +355,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
time: { created },
}),
),
)
@@ -365,7 +366,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestReasoning(draft)
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? event.created, completed: event.created }
match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state
}
})
@@ -389,7 +390,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created: event.created },
time: { created },
}),
),
"session.compaction.ended": (event) => {
@@ -414,7 +415,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created: event.created },
time: { created },
}),
)
})
@@ -429,7 +430,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: current?.metadata ?? event.metadata,
reason: event.data.reason,
error: event.data.error,
time: current?.time ?? { created: event.created },
time: current?.time ?? { created },
})
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
yield* adapter.appendMessage(failed)
+14 -14
View File
@@ -162,8 +162,8 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
tokens_reasoning: 0,
tokens_cache_read: 0,
tokens_cache_write: 0,
time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
time_created: event.created,
time_updated: event.created,
})
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
@@ -411,8 +411,8 @@ const layer = Layer.effectDiscard(
agent: event.data.agent,
model: event.data.model,
version: event.data.version,
time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(event.created),
time_created: event.created,
time_updated: event.created,
})
.onConflictDoNothing()
.returning({ sessionID: SessionTable.id })
@@ -431,7 +431,7 @@ const layer = Layer.effectDiscard(
path: event.data.subpath,
...(event.data.projectID ? { project_id: event.data.projectID } : {}),
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
time_updated: DateTime.toEpochMillis(event.created),
time_updated: event.created,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
@@ -487,7 +487,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(event.created) })
.set({ agent: event.data.agent, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
@@ -498,7 +498,7 @@ const layer = Layer.effectDiscard(
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(event.created) })
.set({ model: event.data.model, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
@@ -507,7 +507,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.Renamed, (event) =>
db
.update(SessionTable)
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(event.created) })
.set({ title: event.data.title, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
@@ -535,7 +535,7 @@ const layer = Layer.effectDiscard(
files: input.payload.files,
agents: input.payload.agents,
skills: input.payload.skills,
time: { created: event.created },
time: { created: DateTime.makeUnsafe(event.created) },
}
: {
id: input.id,
@@ -543,7 +543,7 @@ const layer = Layer.effectDiscard(
text: input.payload.text,
description: input.payload.description,
metadata: input.payload.metadata,
time: { created: event.created },
time: { created: DateTime.makeUnsafe(event.created) },
},
)
}),
@@ -561,7 +561,7 @@ const layer = Layer.effectDiscard(
})
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.set({ time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
@@ -640,7 +640,7 @@ const layer = Layer.effectDiscard(
.update(SessionTable)
.set({
revert: { ...revert, files: revert.files ? [...revert.files] : undefined },
time_updated: DateTime.toEpochMillis(event.created),
time_updated: event.created,
})
.where(eq(SessionTable.id, event.data.sessionID))
.run()
@@ -650,7 +650,7 @@ const layer = Layer.effectDiscard(
yield* bus.project(SessionEvent.RevertEvent.Cleared, (event) =>
db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
.set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie, Effect.asVoid),
@@ -685,7 +685,7 @@ const layer = Layer.effectDiscard(
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
.set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
+82 -23
View File
@@ -1,6 +1,7 @@
export * as SessionRunCoordinator from "./run-coordinator.js"
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. */
export interface Coordinator<Key, E, Reason = never> {
@@ -9,26 +10,41 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
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
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell
* closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
* execution rings it with its eligibility request, and the execution loop drains again
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
request: Request
pendingWake?: Request
stopping: boolean
interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
}
/**
@@ -43,7 +59,7 @@ type Execution<E, Reason> = {
* ```
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -57,21 +73,22 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.request = execution.pendingWake
execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}),
),
)
const start = (key: Key, force: boolean) => {
const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
request,
stopping: false,
}
executions.set(key, execution)
@@ -87,7 +104,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit,
Effect.asVoid,
),
@@ -97,12 +114,22 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// 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>) => {
if (execution.pendingWake) start(key, false)
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
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> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -111,26 +138,58 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true).done)
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key) =>
const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => {
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
if (execution.stopping) {
if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
// Coalesced wakes keep the widest request: "input" subsumes "steer".
execution.pendingWake = execution.pendingWake === "input" ? "input" : request
return
}
start(key, false)
start(key, false, request)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
const wakeActive = (key: Key) =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
execution.stopping = true
execution.pendingWake = false
execution.pendingWake = undefined
execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner)
})
@@ -143,5 +202,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
})
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"
@@ -29,6 +30,8 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
}) => Effect.Effect<DrainResult, RunError>
}
+16 -14
View File
@@ -128,22 +128,25 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
let force = input.force
let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID)) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation)
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false
continuation = undefined
}
@@ -155,14 +158,15 @@ const layer = Layer.effect(
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation?: Continuation,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
let step = continuation?.step ?? 1
let next = continuation
while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -515,14 +519,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -564,9 +568,7 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Effect } from "effect"
import { Clock, Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -81,6 +81,7 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* and consumers fold by id/ordinal rather than global position.
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
const tools = new Map<
string,
{
@@ -123,32 +124,56 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const fragments = (
name: string,
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,
) => {
const chunks = new Map<
string,
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
>()
type Fragment = {
readonly ordinal: number
readonly values: string[]
pending: string
publishedAt?: number
state?: Record<string, unknown>
}
const chunks = new Map<string, Fragment>()
let nextOrdinal = 0
const start = (id: string, state?: Record<string, unknown>) =>
Effect.suspend(() => {
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}`))
const ordinal = nextOrdinal++
chunks.set(id, { ordinal, values: [], state })
chunks.set(id, { ordinal, values: [], pending: "", state })
return Effect.succeed(ordinal)
})
const append = (id: string, value: string, state?: Record<string, unknown>) =>
Effect.suspend(() => {
const current = chunks.get(id)
if (!current) return Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value)
if (state !== undefined) current.state = { ...current.state, ...state }
return Effect.succeed(current.ordinal)
})
const publishDelta = Effect.fnUntraced(function* (id: string, force = false) {
if (!delta) return undefined
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
if (!current.pending) return undefined
const now = yield* Clock.currentTimeMillis
if (!force && current.publishedAt === undefined) {
current.publishedAt = now
return undefined
}
if (!force && current.publishedAt !== undefined && now - current.publishedAt < deltaBatchInterval)
return undefined
yield* delta(id, current.pending, current.ordinal)
current.pending = ""
current.publishedAt = now
return undefined
})
const append = Effect.fnUntraced(function* (id: string, value: string, state?: Record<string, unknown>) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} delta before start: ${id}`))
current.values.push(value)
if (delta) current.pending += value
if (state !== undefined) current.state = { ...current.state, ...state }
yield* publishDelta(id)
return current.ordinal
})
const end = Effect.fnUntraced(function* (id: string, state?: Record<string, unknown>, value?: string) {
const current = chunks.get(id)
if (!current) return yield* Effect.die(new Error(`${name} end before start: ${id}`))
yield* publishDelta(id, true)
yield* ended(
id,
value ?? current.values.join(""),
@@ -156,9 +181,10 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state === undefined ? current.state : { ...current.state, ...state },
)
chunks.delete(id)
return undefined
})
const flush = Effect.fnUntraced(function* () {
for (const id of chunks.keys()) yield* end(id)
for (const id of Array.from(chunks.keys())) yield* end(id)
})
return { start, append, end, flush, has: (id: string) => chunks.has(id) }
}
@@ -175,6 +201,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state,
})
}),
(_textID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true,
)
const reasoning = fragments(
@@ -189,6 +224,15 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
state,
})
}),
(_reasoningID, value, ordinal) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal,
delta: value,
})
}),
true,
)
const toolInput = fragments("tool input", (id, value) =>
@@ -351,13 +395,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "text-delta":
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,
})
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata))
@@ -373,17 +411,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "reasoning-delta":
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,
})
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
return
case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata))
@@ -399,12 +427,6 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
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* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID: tool.assistantMessageID,
id: event.id,
delta: event.text,
})
return
}
case "tool-input-end":
+1 -3
View File
@@ -181,9 +181,7 @@ export const Plugin = {
)
const background = input.background === true
yield* context.progress({
metadata: { sessionID: child.id, status: "running" },
})
yield* context.progress({ sessionID: child.id, status: "running" })
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
+25 -24
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Session } from "@opencode-ai/schema/session"
@@ -422,7 +422,7 @@ describe("Bus", () => {
const { db } = yield* Database.Service
const aggregateID = Event.ID.create()
yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
const rows = yield* db
.select()
.from(EventTable)
@@ -433,6 +433,7 @@ describe("Bus", () => {
expect(rows).toHaveLength(1)
expect(rows[0]?.type).toBe(Bus.versionedType(SyncMessage.type, 1))
expect(rows[0]?.aggregate_id).toBe(aggregateID)
expect(rows[0]?.created).toBe(event.created)
}),
)
@@ -706,7 +707,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -726,7 +727,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -763,7 +764,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
@@ -797,7 +798,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -806,7 +807,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 5,
aggregateID,
@@ -831,14 +832,14 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
seq: 0,
aggregateID,
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
})
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
expect(received[0]?.created).toBe(0)
}),
)
@@ -867,7 +868,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: "unknown.event.1",
seq: 0,
aggregateID: Event.ID.create(),
@@ -895,7 +896,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -915,7 +916,7 @@ describe("Bus", () => {
const id = Event.ID.create()
const replayed = {
id,
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -972,7 +973,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1001,7 +1002,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1012,7 +1013,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@@ -1045,7 +1046,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1058,7 +1059,7 @@ describe("Bus", () => {
.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1080,7 +1081,7 @@ describe("Bus", () => {
yield* bus.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1101,7 +1102,7 @@ describe("Bus", () => {
const aggregateID = Session.ID.create()
const replayed = {
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1126,7 +1127,7 @@ describe("Bus", () => {
const id = Event.ID.create()
yield* bus.replay({
id,
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1136,7 +1137,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id,
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1159,7 +1160,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1170,7 +1171,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1232,7 +1233,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
+30 -1
View File
@@ -102,6 +102,35 @@ describe("Catalog", () => {
}).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", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
@@ -278,7 +307,7 @@ describe("Catalog", () => {
const fallbackModel = Model.ID.make("fallback")
yield* catalog.transform((catalog) => {
catalog.provider.update(disabledProvider, (provider) => {
provider.disabled = true
provider.activation = "disabled"
})
catalog.model.update(disabledProvider, disabledModel, () => {})
catalog.provider.update(enabledProvider, () => {})
+1 -1
View File
@@ -342,7 +342,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
names: ["CUSTOM_API_KEY"],
})
expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed")
expect(provider.disabled).toBeUndefined()
expect(provider.activation).toBe("enabled")
expect(provider.package).toBe("aisdk:custom-sdk")
expect(provider.settings).toEqual({ baseURL: "https://example.test" })
expect(provider.headers).toEqual({ first: "first", shared: "last", last: "last" })
+56 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, spyOn, test } from "bun:test"
import fuzzysort from "fuzzysort"
import os from "os"
import path from "path"
import { Deferred, Effect, Layer } from "effect"
@@ -123,4 +124,58 @@ describe("FileSystemSearch", () => {
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
})
test("reuses location-owned fuzzy targets across index refreshes", async () => {
let scans = 0
const first = Effect.runSync(Deferred.make<void>())
const second = Effect.runSync(Deferred.make<void>())
const prepare = spyOn(fuzzysort, "prepare")
const cleanup = spyOn(fuzzysort, "cleanup")
const layer = AppNodeBuilder.build(FileSystemSearch.node, [
[
Location.node,
Layer.succeed(
Location.Service,
Location.Service.of(
location({ directory: AbsolutePath.make(path.join(os.tmpdir(), "opencode-search-cache")) }),
),
),
],
[
Ripgrep.node,
Layer.succeed(
Ripgrep.Service,
Ripgrep.Service.of({
find: (input) =>
Effect.gen(function* () {
scans++
const entry = FileSystem.Entry.make({ path: RelativePath.make("src/index.ts"), type: "file" })
if (input.onEntry) yield* input.onEntry(entry)
yield* Deferred.succeed(scans === 1 ? first : second, undefined)
return [entry]
}),
glob: () => Effect.succeed([]),
grep: () => Effect.succeed([]),
}),
),
],
])
await Effect.runPromise(
Effect.gen(function* () {
const search = yield* FileSystemSearch.Service
yield* Deferred.await(first)
yield* search.find({ query: "index", type: "file" })
yield* TestClock.adjust("10 seconds")
yield* search.find({ query: "index", type: "file" })
yield* Deferred.await(second)
yield* search.find({ query: "index", type: "file" })
expect(prepare).toHaveBeenCalledTimes(2)
expect(cleanup).toHaveBeenCalledTimes(3)
}).pipe(Effect.provide(layer), Effect.provide(TestClock.layer()), Effect.scoped),
)
prepare.mockRestore()
cleanup.mockRestore()
})
})
+7 -3
View File
@@ -510,7 +510,7 @@ describe("LocationServiceMap", () => {
),
)
it.live("normalizes ref key shapes to one cached location graph", () =>
it.live("normalizes equivalent refs to one cached location graph", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
@@ -520,16 +520,20 @@ describe("LocationServiceMap", () => {
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const directory = AbsolutePath.make(dir.path)
const absent = Location.Ref.make({ directory })
const alternate = AbsolutePath.make(directory.replaceAll("\\", "/"))
const absent = Location.Ref.make({ directory: alternate })
const present = Location.Ref.make({ directory, workspaceID: undefined })
// The two shapes are not structurally Equal: own-key sets differ.
expect(Object.keys(absent)).toEqual(["directory"])
expect(Object.keys(present)).toEqual(["directory", "workspaceID"])
expect(Equal.equals(absent, present)).toBe(false)
if (process.platform === "win32") expect(absent.directory).not.toBe(present.directory)
const first = yield* locations.contextEffect(absent)
expect(yield* locations.contextEffect(present)).toBe(first)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toHaveLength(1)
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([
Location.Ref.make({ directory, workspaceID: undefined }),
])
// Invalidating with the shape opposite to the one that booted must evict.
yield* locations.invalidate(present)
+1 -1
View File
@@ -1002,7 +1002,7 @@ test("reconciles only changed MCP server config", async () => {
const publishUpdate = () =>
PubSub.publish(updates, {
id: ID.create(),
created: DateTime.makeUnsafe(0),
created: 0,
type: Event.Updated.type,
data: {},
} satisfies Payload<typeof Event.Updated>)
+107 -1
View File
@@ -2,13 +2,16 @@ import { describe, expect } from "bun:test"
import { LLM, LanguageModel } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect"
import { Effect, Layer } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { Compatibility, ID, Info, VariantID } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
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"
interface ModelOptions {
@@ -269,6 +272,109 @@ describe("ModelResolver", () => {
}),
)
it.effect("uses no native API-key auth for an explicitly enabled provider without credentials", () => {
const selected = model(Provider.aisdk("@ai-sdk/google"), {
providerID: Provider.ID.make("gateway"),
settings: { baseURL: "https://gateway.example.com/v1" },
headers: { "cf-access-token": "access-token" },
})
const provider = Provider.Info.make({
...Provider.Info.empty(selected.providerID),
activation: "enabled",
package: selected.package ?? "",
settings: selected.settings,
headers: selected.headers,
})
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(provider),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.die("unused"),
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
return withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const resolved = yield* resolver.resolveModel(selected)
const headers = yield* resolved.model.route.auth.apply({
request: LLM.request({ model: resolved.model, prompt: "Hello" }),
method: "POST",
url: "https://gateway.example.com/v1",
body: "{}",
headers: Headers.fromInput(resolved.model.route.defaults.headers),
})
expect(headers["cf-access-token"]).toBe("access-token")
expect(headers.authorization).toBeUndefined()
expect(headers["x-goog-api-key"]).toBeUndefined()
}).pipe(Effect.provide(layer)),
)
})
it.effect("keeps native provider environment auth strict when no API key is configured", () =>
withEnv({ GOOGLE_GENERATIVE_AI_API_KEY: undefined }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example.com/v1" },
}),
)
const exit = yield* Effect.exit(
resolved.route.auth.apply({
request: LLM.request({ model: resolved, prompt: "Hello" }),
method: "POST",
url: "https://google.example.com/v1",
body: "{}",
headers: Headers.empty,
}),
)
expect(exit._tag).toBe("Failure")
}),
),
)
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
+2
View File
@@ -47,6 +47,7 @@ const fixtureSnapshot = [
info: {
id: Provider.ID.make("acme"),
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
models: [
@@ -109,6 +110,7 @@ const fixture2Snapshot = [
info: {
id: Provider.ID.make("beta"),
name: "Beta",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
models: [
@@ -64,6 +64,7 @@ describe("ModelsDevPlugin", () => {
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://api.acme.test/v1" },
},
@@ -239,6 +240,7 @@ describe("ModelsDevPlugin", () => {
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
},
environment: [],
@@ -330,6 +332,7 @@ describe("ModelsDevPlugin", () => {
info: {
id: providerID,
name: "Acme",
activation: "auto",
package: Provider.aisdk("@ai-sdk/openai-compatible"),
settings: { baseURL: "https://${ACME_HOST}/${UNDECLARED_HOST}/v1" },
},
@@ -385,6 +388,7 @@ describe("ModelsDevPlugin", () => {
info: {
id: Provider.ID.make(id),
name,
activation: "auto",
package: Provider.aisdk(packageName),
},
environment: id === "azure" ? ["AZURE_RESOURCE_NAME", environment] : [environment],
@@ -60,14 +60,14 @@ describe("LLMGatewayPlugin", () => {
})
yield* catalog.transform((catalog) => {
catalog.provider.update(Provider.ID.make("llmgateway"), (provider) => {
provider.disabled = true
provider.activation = "disabled"
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
provider.settings = { baseURL: "https://api.llmgateway.io/v1" }
})
})
yield* addPlugin()
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.disabled).toBe(true)
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.activation).toBe("disabled")
expect((yield* catalog.provider.get(Provider.ID.make("llmgateway")))?.headers).toBeUndefined()
}),
)
@@ -347,6 +347,8 @@ describe("OpencodePlugin", () => {
})
yield* addPlugin()
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)
}),
),
+1 -1
View File
@@ -599,7 +599,7 @@ describe("Session.create", () => {
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
+8 -18
View File
@@ -31,6 +31,7 @@ import { testEffect } from "./lib/effect"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
const interruptContinuations: Array<boolean | undefined> = []
const wakeCalls: Session.ID[] = []
const activeSessions = new Set<Session.ID>()
const execution = Layer.succeed(
@@ -41,14 +42,16 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
interrupt: (sessionID) =>
interrupt: (sessionID, options) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
}),
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
}),
wakeActive: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
@@ -177,31 +180,18 @@ describe("Session.prompt", () => {
}),
)
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", () =>
it.effect("forwards interrupt continuation policy", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
interruptCalls.length = 0
interruptContinuations.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(interruptContinuations).toEqual([true])
expect(wakeCalls).toEqual([])
}),
)
@@ -770,7 +760,7 @@ describe("Session.prompt", () => {
yield* Effect.forEach(
recorded.map((event) => ({
id: event.id,
created: DateTime.makeUnsafe(event.created),
created: event.created,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,

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