Compare commits

..

1 Commits

Author SHA1 Message Date
Filip Hejmowski c71ebe46df feat(core): route subagent models by role 2026-08-15 17:03:57 +00:00
91 changed files with 1444 additions and 1628 deletions
+3 -10
View File
@@ -1,10 +1,6 @@
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
@@ -24,22 +20,19 @@ runs:
shell: bash
run: |
if [ "$RUNNER_ARCH" = "X64" ]; then
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}")
V=$(node -p "require('./package.json').packageManager.split('@')[1]")
case "$RUNNER_OS" in
macOS) OS=darwin ;;
Linux) OS=linux ;;
Windows) OS=windows ;;
esac
echo "url=https://github.com/oven-sh/bun/releases/download/${TAG}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
echo "url=https://github.com/oven-sh/bun/releases/download/bun-v${V}/bun-${OS}-x64-baseline.zip" >> "$GITHUB_OUTPUT"
fi
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: ${{ !steps.bun-url.outputs.url && inputs.bun-version || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && !inputs.bun-version && 'package.json' || '' }}
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
bun-download-url: ${{ steps.bun-url.outputs.url }}
- name: Get cache directory
+37
View File
@@ -0,0 +1,37 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
jobs:
sync:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Setup Git Committer
id: setup-git-committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Install OpenCode
run: bun i -g opencode-ai
- name: Sync beta branch
env:
GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
run: bun script/beta.ts
+5 -23
View File
@@ -7,7 +7,6 @@ on:
- ci
- dev
- beta
- v2
- fix/npm-native-binary-install
- snapshot-*
workflow_dispatch:
@@ -33,7 +32,7 @@ permissions:
packages: write
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'next') || '' }}
jobs:
version:
@@ -46,13 +45,6 @@ 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
@@ -82,15 +74,13 @@ jobs:
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
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
@@ -112,7 +102,6 @@ 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 }}
@@ -196,7 +185,7 @@ jobs:
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
strategy:
fail-fast: false
matrix:
@@ -346,7 +335,6 @@ jobs:
build-electron:
needs:
- version
- sign-cli-macos
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
continue-on-error: false
env:
@@ -385,12 +373,6 @@ 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:
@@ -449,7 +431,6 @@ 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
@@ -466,7 +447,6 @@ jobs:
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Package
if: needs.version.outputs.release
@@ -589,11 +569,13 @@ 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 `dev` TUI storage channel so tabs and other client-local state match the installed client.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## 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-beta-17236)
-v, --version <version> Install a specific version (e.g., 0.0.0-next-17236)
-b, --binary <path> Install from a local binary instead of downloading
--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-beta-17236
curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- --version 0.0.0-next-17236
./install --binary /path/to/opencode2
EOF
}
@@ -166,7 +166,7 @@ else
fi
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/beta || true)
metadata=$(curl -fsSL https://registry.npmjs.org/@opencode-ai%2fcli/next || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
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=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -30,6 +30,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
@@ -399,7 +400,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
})
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
if (media.mime === "application/pdf")
return {
type: "document" as const,
@@ -409,8 +410,6 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
data: media.base64,
},
} satisfies AnthropicDocumentBlock
if (!media.mime.startsWith("image/"))
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
return {
type: "image" as const,
source: {
+3 -2
View File
@@ -24,6 +24,7 @@ import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
const ADAPTER = "gemini"
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
@@ -247,7 +248,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
if (part.type === "text") return { text: part.text }
const media = ProviderShared.normalizeMedia(part)
const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
@@ -352,7 +353,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
const media: GeminiInlineDataPart[] = []
for (const item of content) {
if (item.type === "text") continue
const value = ProviderShared.normalizeToolFile(item)
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
}
parts.push({
+5 -4
View File
@@ -26,6 +26,7 @@ import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "open-responses"
const NAME = "Open Responses"
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
export const PATH = "/responses"
// =============================================================================
@@ -284,7 +285,7 @@ export interface Extension {
readonly name: string
readonly lowerMedia?: (input: {
readonly part: MediaPart
readonly media: ProviderShared.NormalizedMedia
readonly media: ProviderShared.ValidatedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
@@ -379,13 +380,13 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
request: LLMRequest,
extension: Extension,
) {
const media = ProviderShared.normalizeMedia(part)
const media = yield* ProviderShared.validateMedia(extension.name, part, MEDIA_MIMES)
const extended = extension.lowerMedia?.({ part, media, request })
if (extended) return extended
if (!media.mime.startsWith("image/")) {
if (media.mime === "application/pdf") {
return {
type: "input_file" as const,
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
filename: part.filename ?? "document.pdf",
file_data: media.dataUrl,
}
}
+2 -3
View File
@@ -28,6 +28,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = "/chat/completions"
@@ -283,9 +284,7 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
if (!media.mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`)
const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES)
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
})
+46 -11
View File
@@ -155,24 +155,59 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
export const parseToolInput = (route: string, name: string, raw: string) =>
parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
export interface NormalizedMedia {
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
export const PDF_MIMES = ["application/pdf"] as const
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
export interface ValidatedMedia {
readonly mime: string
readonly base64: string
readonly dataUrl: string
readonly bytes: Uint8Array
}
export const normalizeMedia = (part: MediaPart): NormalizedMedia => {
export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* (
route: string,
part: MediaPart,
supportedMimes: ReadonlySet<string>,
) {
const mime = part.mediaType.toLowerCase()
if (typeof part.data !== "string") {
const base64 = Buffer.from(part.data).toString("base64")
return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }
}
if (!part.data.startsWith("data:")) return { mime, base64: part.data, dataUrl: `data:${mime};base64,${part.data}` }
return { mime, base64: part.data.slice(part.data.indexOf(",") + 1), dataUrl: part.data }
}
if (!supportedMimes.has(mime)) return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`)
export const normalizeToolFile = (part: Tool.FileContent) =>
normalizeMedia({ type: "media", mediaType: part.mime, data: part.uri, filename: part.name })
let base64: string
if (typeof part.data !== "string") {
if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES)
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
base64 = Buffer.from(part.data).toString("base64")
} else if (part.data.startsWith("data:")) {
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data)
if (!match) return yield* invalidRequest(`${route} media data URL must contain valid base64`)
if (match[1]!.toLowerCase() !== mime)
return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`)
base64 = match[2]!
} else {
base64 = part.data
}
if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES)
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`)
if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64))
return yield* invalidRequest(`${route} media must contain valid base64`)
const bytes = Buffer.from(base64, "base64")
if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES)
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
if (bytes.toString("base64") !== base64) return yield* invalidRequest(`${route} media must contain canonical base64`)
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
})
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
@@ -66,7 +66,11 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
const media = ProviderShared.normalizeMedia(part)
const media = yield* ProviderShared.validateMedia(
"Bedrock Converse",
part,
new Set<string>(Object.keys(IMAGE_FORMATS)),
)
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
}
if (mime.startsWith("image/"))
@@ -75,7 +79,11 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
const media = ProviderShared.normalizeMedia(part)
const media = yield* ProviderShared.validateMedia(
"Bedrock Converse",
part,
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
)
return documentBlock(part.filename, documentFormat, media.base64)
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
@@ -399,7 +399,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("rejects tool-result media that cannot be lowered", () =>
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
@@ -418,7 +418,8 @@ describe("Anthropic Messages route", () => {
}),
).pipe(Effect.flip)
expect(error.message).toContain("Anthropic Messages does not support media type audio/mpeg")
expect(error.message).toContain("Anthropic Messages")
expect(error.message).toContain("audio/mpeg")
}),
)
+24 -18
View File
@@ -4,6 +4,7 @@ import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as Gemini from "../../src/protocols/gemini.js"
import { ProviderShared } from "../../src/protocols/shared.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents, sseRaw } from "../lib/sse.js"
@@ -290,30 +291,35 @@ describe("Gemini route", () => {
}),
)
it.effect("passes encoded media through without local validation", () =>
for (const [name, media] of [
["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
["malformed base64", { mediaType: "image/png", data: "%%%=" }],
["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
}),
)
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const error = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "%%%=" },
{ type: "media", mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" },
{ type: "media", mediaType: "image/svg+xml", data: "PHN2Zz4=" },
]),
Message.user({
type: "media",
mediaType: "image/png",
data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
}),
],
}),
)
expect(prepared.body.contents).toEqual([
{
role: "user",
parts: [
{ inlineData: { mimeType: "image/png", data: "%%%=" } },
{ inlineData: { mimeType: "image/png", data: "/9j/" } },
{ inlineData: { mimeType: "image/svg+xml", data: "PHN2Zz4=" } },
],
},
])
).pipe(Effect.flip)
expect(error.message).toContain("encoded limit")
}),
)
+22 -29
View File
@@ -527,42 +527,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("passes encoded image media through without local validation", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user([
{ type: "media", mediaType: "image/png", data: "not-base64" },
{ type: "media", mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" },
{ type: "media", mediaType: "image/svg+xml", data: "PHN2Zz4=" },
]),
],
}),
)
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [
{ type: "image_url", image_url: { url: "data:image/png;base64,not-base64" } },
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
{ type: "image_url", image_url: { url: "data:image/svg+xml;base64,PHN2Zz4=" } },
],
},
])
}),
)
for (const [name, media] of [
["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
["malformed base64", { mediaType: "image/png", data: "not-base64" }],
["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
] as const)
it.effect(`rejects ${name}`, () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
).pipe(Effect.flip)
expect(error.message).toMatch(/does not support|does not match|valid base64/)
}),
)
it.effect("rejects non-image media that cannot be lowered", () =>
it.effect("rejects oversized image input", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" })],
messages: [
Message.user({
type: "media",
mediaType: "image/png",
data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
}),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("OpenAI Chat does not support media type audio/mpeg")
expect(error.message).toContain("encoded limit")
}),
)
@@ -1149,32 +1149,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes large PDF tool-result content through", () =>
Effect.gen(function* () {
const base64 = "A".repeat(8_125_844)
const dataUrl = `data:application/pdf;base64,${base64}`
const prepared = yield* compileRequest(
LLM.request({
id: "req_tool_result_large_pdf",
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
Message.tool({
id: "call_1",
name: "read",
resultType: "content",
result: [{ type: "file", uri: dataUrl, mime: "application/pdf", name: "report.pdf" }],
}),
],
}),
)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
])
}),
)
it.effect("uses xAI inline file encoding for PDF tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1210,9 +1184,9 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes non-image tool-result content through as an input file", () =>
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const error = yield* compileRequest(
LLM.request({
id: "req_tool_result_unsupported_media",
model,
@@ -1226,11 +1200,10 @@ describe("OpenAI Responses route", () => {
}),
],
}),
)
).pipe(Effect.flip)
expect(expectToolOutput(prepared.body).output).toEqual([
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
])
expect(error.message).toContain("OpenAI Responses")
expect(error.message).toContain("audio/mpeg")
}),
)
@@ -2421,28 +2394,17 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes non-image user media through as an input file", () =>
it.effect("rejects unsupported user media content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
const error = yield* compileRequest(
LLM.request({
id: "req_media",
model,
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
}),
)
).pipe(Effect.flip)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [
{
type: "input_file",
filename: "file",
file_data: "data:application/x-tar;base64,AAECAw==",
},
],
},
])
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
}),
)
@@ -0,0 +1,60 @@
import { expect, test } from "@playwright/test"
import type { Page } from "@playwright/test"
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
const OUTSIDE_CAP = "foxtrot-docs"
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
const rows = (page: Page) => page.locator("[data-directory-path]")
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
async function openProjectDialog(page: Page) {
await mockOpenCodeServer(page, {
sessions: fixture.sessions,
provider: fixture.provider,
directory: fixture.directory,
project: fixture.project,
pageMessages,
fileList: () => [],
findFiles: () => [],
})
await page.addInitScript((dirs) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
lastProject: {},
}),
)
}, worktrees)
await page.goto("/")
const add = page.getByRole("button", { name: "Add project" }).first()
await expectAppVisible(add)
await add.click()
await expect(rows(page)).toHaveCount(5)
return page.getByRole("textbox").last()
}
test("searches every recent project, not just the five most recent", async ({ page }) => {
const search = await openProjectDialog(page)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
await search.fill("foxtrot")
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
})
test("still caps the idle recent list at five projects", async ({ page }) => {
await openProjectDialog(page)
await expect(row(page, NAMES[4])).toHaveCount(1)
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
})
@@ -72,12 +72,7 @@ test("creates a session in a new project and selects its model", async ({ page }
const addProject = page.locator('[data-action="home-add-project-row"]')
await expectAppVisible(addProject)
await addProject.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-directory-path]").click()
await page.locator('[data-action="home-new-session"]').click()
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
@@ -0,0 +1,205 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
server: ServerConnection.Any
}
const RECENT_PROJECT_LIMIT = 5
type Row = {
absolute: string
search: string
group: "recent" | "folders"
}
function toRow(absolute: string, home: string, group: Row["group"]): Row {
const full = displayPickerPath(absolute, "", "")
const tilde = displayPickerPath(full, "~", home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search, group }
}
function uniqueRows(rows: Row[]) {
const seen = new Set<string>()
return rows.filter((row) => {
if (seen.has(row.absolute)) return false
seen.add(row.absolute)
return true
})
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const global = useGlobal()
const { sync, sdk, ...serverCtx } = global.ensureServerCtx(props.server)
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const [fallbackPath] = createResource(
() => (!(sync.data.path.home || sync.data.path.directory) ? true : undefined),
() =>
sdk.api.location
.get()
.then(
(location): Path => ({
state: "",
config: "",
worktree: location.project.directory,
directory: location.directory,
home: "",
}),
)
.catch(() => undefined),
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = createDirectorySearch({
sdk,
home,
base: start,
})
const recentProjects = createMemo(() => {
const projects = serverCtx.projects.list()
const byProject = new Map<string, number>()
for (const project of projects) {
let at = 0
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const sessions = sync.child(directory, { bootstrap: false })[0].session
for (const session of sessions) {
if (session.time.archived) continue
const updated = session.time.updated ?? session.time.created
if (updated > at) at = updated
}
}
byProject.set(project.worktree, at)
}
return projects
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
.sort((a, b) => b.at - a.at || a.index - b.index)
.map(({ project }) => {
const row = toRow(project.worktree, home(), "recent")
const name = project.name || getFilename(project.worktree)
return {
...row,
search: `${row.search}\n${name}`,
}
})
})
const items = async (value: string) => {
const results = await directories(value)
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
const recent = recentProjects()
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
return uniqueRows([...visible, ...directoryRows])
}
function resolve(absolute: string) {
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}
return (
<Dialog title={props.title ?? language.t("command.project.open")}>
<List
class="px-3"
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
groupBy={(item) => item.group}
sortGroupsBy={(a, b) => {
if (a.category === b.category) return 0
return a.category === "recent" ? -1 : 1
}}
groupHeader={(group) =>
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanPickerInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = displayPickerPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
}}
>
{(item) => {
const path = displayPickerPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
return (
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -1,7 +1,9 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { 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(() =>
@@ -17,6 +19,7 @@ type DirectoryPickerInput = {
export function useDirectoryPicker() {
const platform = usePlatform()
const settings = useSettings()
const dialog = useDialog()
return (input: DirectoryPickerInput) => {
@@ -33,6 +36,10 @@ export function useDirectoryPicker() {
const cancel = () => {
if (!selected) input.onSelect(null)
}
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
if (platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
dialog.show(() => <DialogSelectDirectoryV2 {...input} onSelect={onSelect} />, cancel)
return
}
dialog.show(() => <DialogSelectDirectory {...input} onSelect={onSelect} />, cancel)
}
}
@@ -60,11 +60,7 @@ export function NewSessionView(props: {
<Show
when={props.workspace.bar.visible()}
fallback={
<PromptGitStatus
branch={props.workspace.bar.branch()}
noGit={!props.workspace.project.git()}
class="ms-1"
/>
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} />
}
>
<PromptWorkspaceSelector
@@ -94,7 +94,7 @@ export function createTimelineController(input: {
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
const showHeader = createMemo(() => !!(titleValue() || input.session.data.parentID()))
const projection = createTimelineProjection({
messages: input.session.history.messages,
userMessages: input.userMessages,
@@ -1203,8 +1203,6 @@ 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 -2
View File
@@ -5,8 +5,7 @@ const fs = require("fs")
const path = require("path")
const os = require("os")
const forwardedSignals =
process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGHUP"] : ["SIGINT", "SIGTERM", "SIGHUP", "SIGUSR1"]
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
function run(target) {
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
+2 -71
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { mkdir, rm } from "fs/promises"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
@@ -27,7 +27,6 @@ const requestedTarget = process.argv.find((arg) => arg.startsWith("--target="))?
const skipInstall = process.argv.includes("--skip-install")
const skipWebUi = process.argv.includes("--skip-web-ui")
const solidPlugin = createSolidTransformPlugin()
const releaseAssets = new Map<string, Promise<Map<string, string>>>()
const allTargets: {
os: string
@@ -100,7 +99,6 @@ 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"],
@@ -117,9 +115,8 @@ for (const item of targets) {
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
define: {
@@ -157,72 +154,6 @@ 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,
+3 -6
View File
@@ -14,12 +14,9 @@ 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)
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 (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)
}
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
+3 -6
View File
@@ -1,6 +1,5 @@
import { Argument, Command, Flag } from "effect/unstable/cli"
import { Argument, Flag } from "effect/unstable/cli"
import { Spec } from "../framework/spec"
import { GlobalFlags } from "./global-flags"
declare const OPENCODE_CLI_NAME: string | undefined
@@ -27,7 +26,7 @@ const PermissionParams = {
),
}
const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
description: "OpenCode 2.0 preview command line interface",
params: {
...ServerParams,
@@ -71,7 +70,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
description: "Debugging and troubleshooting tools",
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("config", { description: "Show resolved configuration" }),
],
}),
Spec.make("console", {
@@ -278,5 +277,3 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
],
})
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
-12
View File
@@ -1,12 +0,0 @@
export * as GlobalFlags from "./global-flags"
import { Flag, GlobalFlag } from "effect/unstable/cli"
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
flag: Flag.string("cpu-profile").pipe(
Flag.withDescription("Write a CPU profile to this path when the process stops"),
Flag.optional,
),
})
export const all = [CpuProfile] as const
-45
View File
@@ -1,45 +0,0 @@
export * as CpuProfile from "./cpu-profile"
import { Effect, FileSystem } from "effect"
import { Session } from "node:inspector"
import path from "node:path"
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
const target = path.resolve(file)
return Effect.acquireUseRelease(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
const session = new Session()
session.connect()
yield* command(session, "Profiler.enable")
yield* command(session, "Profiler.start")
yield* Effect.logInfo("CPU profile started", { path: target })
return session
}),
() => effect,
(session) =>
Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
session.post("Profiler.stop", (error, result) => {
session.disconnect()
if (error) return reject(error)
Bun.write(target, JSON.stringify(result.profile)).then(() => resolve(), reject)
})
}),
).pipe(
Effect.andThen(Effect.logInfo("CPU profile written", { path: target })),
Effect.catchCause((cause) => Effect.logError("Failed to write CPU profile", { path: target, cause })),
),
)
}
function command(session: Session, method: "Profiler.enable" | "Profiler.start") {
return Effect.tryPromise(
() =>
new Promise<void>((resolve, reject) => {
session.post(method, (error) => (error ? reject(error) : resolve()))
}),
)
}
+2 -20
View File
@@ -1,13 +1,10 @@
import { Effect, FileSystem, Option, Scope } from "effect"
import { Effect, FileSystem, Scope } from "effect"
import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/util/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
import { Npm } from "@opencode-ai/util/npm"
import { GlobalFlags } from "../commands/global-flags"
import { CpuProfile } from "../cpu-profile"
import path from "node:path"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -89,22 +86,7 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
? node.spec.pipe(
Command.withHandler((input) =>
Effect.gen(function* () {
const module = yield* Effect.promise(handler.load)
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
if (!cpuProfile) return yield* module.default(input)
const target = path.resolve(cpuProfile)
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = target
return yield* (
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}),
),
)
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
}),
),
)
-37
View File
@@ -1,37 +0,0 @@
import { Global } from "@opencode-ai/util/global"
import { Effect, Queue } from "effect"
import path from "node:path"
export const listen = Effect.gen(function* () {
const global = yield* Global.Service
if (process.platform === "win32") return
const signals = yield* Queue.dropping<void>(1)
yield* Effect.acquireRelease(
Effect.sync(() => {
const handler = () => Queue.offerUnsafe(signals, undefined)
process.on("SIGUSR1", handler)
return handler
}),
(handler) => Effect.sync(() => process.off("SIGUSR1", handler)),
)
yield* Queue.take(signals).pipe(
Effect.andThen(
Effect.suspend(() => {
const file = path.join(
global.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
return Effect.gen(function* () {
yield* Effect.logInfo("writing heap snapshot", { path: file })
const { writeHeapSnapshot } = yield* Effect.tryPromise(() => import("node:v8"))
yield* Effect.try(() => writeHeapSnapshot(file))
yield* Effect.logInfo("heap snapshot written", { path: file })
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to write heap snapshot", { path: file, cause })))
}),
),
Effect.forever,
Effect.forkScoped({ startImmediately: true }),
)
})
export * as Heap from "./heap"
+6 -10
View File
@@ -12,7 +12,6 @@ import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Config } from "./config"
import { Npm } from "@opencode-ai/util/npm"
import { Heap } from "./heap"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@@ -55,16 +54,13 @@ const Handlers = Runtime.handlers(Commands, {
serve: () => import("./commands/handlers/serve"),
})
Effect.gen(function* () {
yield* Heap.listen
yield* Effect.logInfo("cli starting", {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
local: OPENCODE_LOCAL,
args: process.argv.slice(2),
})
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
Effect.logInfo("cli starting", {
version: OPENCODE_VERSION,
channel: OPENCODE_CHANNEL,
local: OPENCODE_LOCAL,
args: process.argv.slice(2),
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
+1 -1
View File
@@ -84,7 +84,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
database: {
path:
process.env.OPENCODE_DB ??
(["latest", "dev", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
(["latest", "beta", "next", "prod"].includes(OPENCODE_CHANNEL) ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
process.env.OPENCODE_DISABLE_CHANNEL_DB === "true"
? "opencode.db"
+3 -8
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 === "dev" || channel === "beta" || channel === "next") return "service.json"
if (channel === "latest" || channel === "next") return "service.json"
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = OPENCODE_CHANNEL) {
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "latest" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
}
@@ -104,12 +104,7 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
return {
file,
version: input.checkVersion ? OPENCODE_VERSION : undefined,
command: [
...selfCommand(),
"serve",
"--service",
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
],
command: [...selfCommand(), "serve", "--service"],
}
})
+1 -2
View File
@@ -35,8 +35,7 @@ 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-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("0.0.0-next-17403", "0.0.0-next-17403.2", 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")
})
+1 -2
View File
@@ -10,10 +10,9 @@ describe("debug config command", () => {
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("config")
expect(debug.stdout).toContain("List configuration sources")
expect(debug.stdout).toContain("Show resolved configuration")
expect(config.exitCode).toBe(0)
expect(config.stdout).toContain("opencode debug config [flags]")
expect(config.stdout).toContain("List configuration sources")
})
test("prints config entries from the invoking directory without reordering permissions", async () => {
-27
View File
@@ -11,37 +11,12 @@ 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"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
})
test("managed service forwards the CPU profile path to the server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
const profile = path.join(root, "server.cpuprofile")
try {
const previous = process.env.OPENCODE_CPU_PROFILE
process.env.OPENCODE_CPU_PROFILE = profile
try {
const options = await Effect.runPromise(
ServiceConfig.options().pipe(
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
Effect.provide(NodeFileSystem.layer),
),
)
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
} finally {
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
else process.env.OPENCODE_CPU_PROFILE = previous
}
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
test("local channel stores service config with the local service filename", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
try {
@@ -62,8 +37,6 @@ test("local channel stores service config with the local service filename", asyn
test("service filenames share release channels and identify preview channels", () => {
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")
+41 -40
View File
@@ -20,6 +20,7 @@ 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"
@@ -315,7 +316,7 @@ export type Endpoint5_31Output =
| (
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.created"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -335,7 +336,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -348,7 +349,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -361,7 +362,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.moved"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -375,7 +376,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.renamed"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -384,7 +385,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.deleted"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -393,7 +394,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.forked"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -409,7 +410,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -418,7 +419,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -431,7 +432,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -440,7 +441,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -453,7 +454,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -462,7 +463,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -471,7 +472,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -483,7 +484,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -492,7 +493,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -505,7 +506,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.synthetic"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
@@ -519,7 +520,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -533,7 +534,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -542,7 +543,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -560,7 +561,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -575,7 +576,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -597,7 +598,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -621,7 +622,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -634,7 +635,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -649,7 +650,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -663,7 +664,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -678,7 +679,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -692,7 +693,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -706,7 +707,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -722,7 +723,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -758,7 +759,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -797,7 +798,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -812,7 +813,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -826,7 +827,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -840,7 +841,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -854,7 +855,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -863,7 +864,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -872,7 +873,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -881,7 +882,7 @@ export type Endpoint5_31Output =
}
| {
readonly id: Event.ID
readonly created: number
readonly created: DateTime.Utc
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 }
@@ -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: Aug 15, 2026</p>
<p class="effective-date">Effective date: Mar 6, 2026</p>
<p>
Welcome to OpenCode. Please read on to learn the rules and restrictions that govern your use of
@@ -154,11 +154,6 @@ export default function TermsOfService() {
is dangerous, harmful, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or
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>
+5 -43
View File
@@ -435,22 +435,7 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
.map((part) => part.text)
.filter(Boolean)
.join("\n\n")
const pending: UserContent = []
const messages = request.messages.flatMap((input, index) => {
if (input.role !== "tool") return message(input)
const lowered = toolMessage(input)
pending.push(...lowered.media)
if (request.messages[index + 1]?.role === "tool" || pending.length === 0) return lowered.messages
const media = [...pending]
pending.length = 0
return [
...lowered.messages,
{
role: "user" as const,
content: [{ type: "text" as const, text: "Attached media from tool result:" }, ...media],
},
]
})
const messages = request.messages.flatMap(message)
if (!system.length) return messages
return [{ role: "system", content: system }, ...messages]
}
@@ -463,33 +448,10 @@ function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[
return [{ role: "user", content: input.content.flatMap(userPart) }]
case "assistant":
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
case "tool":
return toolMessage(input).messages
}
}
function toolMessage(input: LLMRequest["messages"][number]) {
const media: UserContent = []
const content = input.content.flatMap((part) => {
if (part.type !== "tool-result" || part.result.type !== "content") return toolResultPart(part)
const value = part.result.value.filter((item) => {
if (item.type !== "file") return true
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
return false
})
return toolResultPart({
...part,
result:
value.length === 0
? { type: "text", value: "Media attached in the following user message." }
: { ...part.result, value },
})
})
return {
messages: content.length ? ([{ role: "tool", content }] satisfies LanguageModelV3Message[]) : [],
media,
case "tool": {
const content = input.content.flatMap(toolResultPart)
return content.length ? [{ role: "tool", content }] : []
}
}
}
+10 -10
View File
@@ -1,6 +1,6 @@
export * as Bus from "./bus.js"
import { Cause, Clock, Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Event } from "@opencode-ai/schema/event"
import 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?: number
readonly created?: DateTime.Utc
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 ?? 0,
created: event.created ?? DateTime.makeUnsafe(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 === (event.created ?? 0) &&
stored.created === DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(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: event.created ?? 0,
created: DateTime.toEpochMillis(event.created ?? DateTime.makeUnsafe(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* Clock.currentTimeMillis,
created: yield* DateTime.now,
...(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* Clock.currentTimeMillis,
created: yield* DateTime.now,
...(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: event.created,
created: DateTime.toEpochMillis(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 ?? 0,
created: event.created ?? DateTime.makeUnsafe(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: event.created,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
+90
View File
@@ -0,0 +1,90 @@
export * as ModelRouting from "./model-routing.js"
import { Model } from "./model.js"
import { Provider } from "./provider.js"
export const roles = ["fast", "smart", "vision", "long-context"] as const
export type Role = (typeof roles)[number]
export function resolve(selection: string, available: readonly Model.Info[]) {
if (!isRole(selection)) return exact(selection, available)
return select(selection, available)
}
export function select(role: Role, available: readonly Model.Info[]) {
const candidates = available.filter(
(model) =>
model.status === "active" &&
model.capabilities.tools &&
model.capabilities.input.includes("text") &&
model.capabilities.output.includes("text"),
)
const eligible =
role === "vision"
? candidates.filter((model) => model.capabilities.input.includes("image"))
: role === "fast"
? candidates.filter((model) => !SLOW_MODEL_RE.test(identity(model)))
: candidates
if (eligible.length === 0) return
const sorted = eligible.toSorted((a, b) => {
if (role === "fast") {
const tagged = Number(fast(b)) - Number(fast(a))
if (tagged !== 0) return tagged
const price = cost(a) - cost(b)
if (price !== 0) return price
}
if (role === "smart" || role === "vision") {
const tagged = Number(smart(b)) - Number(smart(a))
if (tagged !== 0) return tagged
}
if (role === "long-context") {
const context = b.limit.context - a.limit.context
if (context !== 0) return context
}
const released = b.time.released - a.time.released
if (released !== 0) return released
return `${a.providerID}/${a.id}`.localeCompare(`${b.providerID}/${b.id}`)
})
const selected = sorted[0]
return Model.Ref.make({ providerID: selected.providerID, id: selected.id })
}
function isRole(selection: string): selection is Role {
return roles.includes(selection as Role)
}
function exact(selection: string, available: readonly Model.Info[]) {
const providerEnd = selection.indexOf("/")
if (providerEnd <= 0) return
const variantStart = selection.indexOf("#", providerEnd + 1)
const providerID = Provider.ID.make(selection.slice(0, providerEnd))
const id = Model.ID.make(selection.slice(providerEnd + 1, variantStart === -1 ? undefined : variantStart))
const variant = variantStart === -1 ? undefined : Model.VariantID.make(selection.slice(variantStart + 1))
if (!id || !providerID || (variantStart !== -1 && !variant)) return
const model = available.find((item) => item.providerID === providerID && item.id === id)
if (!model) return
if (variant && !model.variants.some((item) => item.id === variant)) return
return Model.Ref.make({ providerID, id, variant })
}
function cost(model: Model.Info) {
const price = model.cost[0]
return price ? price.input + price.output : Number.MAX_SAFE_INTEGER
}
function fast(model: Model.Info) {
return FAST_MODEL_RE.test(identity(model))
}
function smart(model: Model.Info) {
return SMART_MODEL_RE.test(identity(model))
}
function identity(model: Model.Info) {
return `${model.id} ${model.family ?? ""} ${model.name}`.toLowerCase()
}
const FAST_MODEL_RE = /\b(nano|flash|lite|mini|small|fast)\b/
const SLOW_MODEL_RE = /\b(haiku)\b/
const SMART_MODEL_RE = /\b(opus|pro|max|ultra|reasoner|reasoning)\b|\b(gpt-5|grok-4|deepseek-v4|kimi-k2)\b/
+3 -31
View File
@@ -1,6 +1,6 @@
export * as PlanPlugin from "./plan.js"
import { Message, ToolFailure } from "@opencode-ai/ai"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Stream } from "effect"
import { Agent } from "../agent.js"
@@ -38,33 +38,13 @@ export const Plugin = define({
})
})
// Compaction and committed reverts can strip reminders while the session's agent stays
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
yield* ctx.session.hook("context", (event) => {
const reminder = lastReminder(event.messages)
const missing = event.agent === plan && reminder !== enter
const stale = event.agent !== plan && reminder === enter
const text = missing ? enter : stale ? leave : undefined
if (!text) return Effect.void
// Before the user's prompt, matching where agent-switch reminders land.
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
event.messages.splice(at, 0, Message.user(text))
return ctx.session
.synthetic({ sessionID: event.sessionID, text, resume: false })
.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to persist Plan mode reminder", { sessionID: event.sessionID, cause }),
),
)
})
yield* ctx.event.subscribe().pipe(
Stream.filter(
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
event.type === "session.created" || event.type === "session.agent.selected",
),
Stream.runForEach((event) => {
const text = switchReminder(event)
const text = reminder(event)
if (!text) return Effect.void
return ctx.session
.synthetic({
@@ -83,7 +63,7 @@ export const Plugin = define({
}),
})
function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
if (event.type === "session.created") {
if (event.data.agent !== plan) return
return enter
@@ -92,11 +72,3 @@ function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected
if (event.data.agent === plan) return enter
if (event.data.previous === plan) return leave
}
function lastReminder(messages: ReadonlyArray<Message>) {
return messages.reduce<string | undefined>((found, message) => {
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
if (part?.type !== "text") return found
return part.text === enter || part.text === leave ? part.text : found
}, undefined)
}
@@ -63,33 +63,6 @@ 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,
+7 -5
View File
@@ -789,10 +789,7 @@ const layer = Layer.effect(
return false
}),
)
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
if (recovered) return
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
@@ -876,7 +873,12 @@ const layer = Layer.effect(
),
),
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(execution.interrupt(sessionID, options)),
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
}),
),
),
revert: {
stage: Effect.fn("Session.revert.stage")(function* (input) {
+11 -22
View File
@@ -1,8 +1,7 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Bus } from "../bus.js"
import { 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"
@@ -12,7 +11,6 @@ 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. */
@@ -21,10 +19,8 @@ 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, options?: { readonly continue?: boolean }) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -49,7 +45,6 @@ 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) =>
@@ -76,13 +71,12 @@ 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, promotable }),
runner.drain({ sessionID, force, continuation }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
@@ -92,7 +86,7 @@ export const layer = Layer.effect(
),
)
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation, promotable)
return yield* drain(sessionID, false, result.continuation)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -101,7 +95,7 @@ export const layer = Layer.effect(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
drain: (sessionID, force) => drain(sessionID, force),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
@@ -133,20 +127,16 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
interrupt: (sessionID, options) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -155,7 +145,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -165,7 +155,6 @@ 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,
}),
+3 -11
View File
@@ -161,7 +161,7 @@ export const admit = Effect.fn("SessionInbox.admit")(function* (
const base = {
id: request.id,
sessionID: request.sessionID,
timeCreated: DateTime.makeUnsafe(event.created),
timeCreated: 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: number
readonly timeCreated: DateTime.Utc
},
) {
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: request.timeCreated,
time_created: DateTime.toEpochMillis(request.timeCreated),
})
.onConflictDoNothing()
.returning({ id: SessionInboxTable.id })
@@ -349,14 +349,6 @@ 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.
+21 -22
View File
@@ -26,7 +26,6 @@ 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(
@@ -71,7 +70,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
agent: event.data.agent,
previous,
time: { created },
time: { created: event.created },
}),
)
})
@@ -86,7 +85,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
model: event.data.model,
previous,
time: { created },
time: { created: event.created },
}),
)
})
@@ -102,7 +101,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
projectID: event.data.projectID,
subpath: event.data.subpath,
previous: yield* adapter.getLocation(),
time: { created },
time: { created: event.created },
}),
)
})
@@ -127,7 +126,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 },
time: { created: event.created },
}),
)
},
@@ -139,7 +138,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.data.metadata,
id: SessionMessage.ID.fromEvent(event.id),
type: "synthetic",
time: { created },
time: { created: event.created },
}),
)
},
@@ -152,7 +151,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
name: event.data.name,
text: event.data.text,
metadata: event.metadata,
time: { created },
time: { created: event.created },
}),
)
},
@@ -165,7 +164,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 },
time: { created: event.created },
}),
)
},
@@ -178,7 +177,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 = created
draft.time.completed = event.created
}),
)
}
@@ -206,7 +205,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
yield* adapter.updateAssistant(
produce(currentAssistant, (draft) => {
draft.retry = undefined
draft.time.completed = created
draft.time.completed = event.created
}),
)
}
@@ -217,7 +216,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
agent: event.data.agent,
model: event.data.model,
metadata: event.metadata,
time: { created },
time: { created: event.created },
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
}),
@@ -226,7 +225,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.step.ended": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.time.completed = event.created
draft.finish = event.data.finish
draft.cost = event.data.cost
draft.tokens = event.data.tokens
@@ -240,7 +239,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
},
"session.step.failed": (event) => {
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
draft.time.completed = created
draft.time.completed = event.created
draft.finish = "error"
draft.error = castDraft(event.data.error)
draft.retry = undefined
@@ -278,7 +277,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "tool",
id: event.data.id,
name: event.data.name,
time: { created },
time: { created: event.created },
state: SessionMessage.ToolStateStreaming.make({ status: "streaming", input: "" }),
}),
),
@@ -297,7 +296,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.executed = event.data.executed
match.providerState = event.data.state
match.time.ran = created
match.time.ran = event.created
match.state = castDraft(
SessionMessage.ToolStateRunning.make({
status: "running",
@@ -316,7 +315,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 = created
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateCompleted.make({
status: "completed",
@@ -334,7 +333,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 = created
match.time.completed = event.created
match.state = castDraft(
SessionMessage.ToolStateError.make({
status: "error",
@@ -355,7 +354,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
type: "reasoning",
text: "",
state: event.data.state,
time: { created },
time: { created: event.created },
}),
),
)
@@ -366,7 +365,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 ?? created, completed: created }
match.time = { created: match.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) match.state = event.data.state
}
})
@@ -390,7 +389,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: "",
recent: event.data.recent ?? "",
time: { created },
time: { created: event.created },
}),
),
"session.compaction.ended": (event) => {
@@ -415,7 +414,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
reason: event.data.reason,
summary: event.data.text,
recent: event.data.recent,
time: { created },
time: { created: event.created },
}),
)
})
@@ -430,7 +429,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 },
time: current?.time ?? { created: event.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: event.created,
time_updated: event.created,
time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(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: event.created,
time_updated: event.created,
time_created: DateTime.toEpochMillis(event.created),
time_updated: DateTime.toEpochMillis(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: event.created,
time_updated: DateTime.toEpochMillis(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: event.created })
.set({ agent: event.data.agent, time_updated: DateTime.toEpochMillis(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: event.created })
.set({ model: event.data.model, time_updated: DateTime.toEpochMillis(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: event.created })
.set({ title: event.data.title, time_updated: DateTime.toEpochMillis(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: DateTime.makeUnsafe(event.created) },
time: { created: 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: DateTime.makeUnsafe(event.created) },
time: { created: event.created },
},
)
}),
@@ -561,7 +561,7 @@ const layer = Layer.effectDiscard(
})
yield* db
.update(SessionTable)
.set({ time_updated: event.created })
.set({ time_updated: DateTime.toEpochMillis(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: event.created,
time_updated: DateTime.toEpochMillis(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: event.created })
.set({ revert: null, time_updated: DateTime.toEpochMillis(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: event.created })
.set({ revert: null, time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
+23 -82
View File
@@ -1,7 +1,6 @@
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> {
@@ -10,41 +9,26 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
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, 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>
readonly wake: (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,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
readonly interrupt: (key: Key, reason?: Reason) => 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 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.
* 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.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
request: Request
pendingWake?: Request
pendingWake: boolean
stopping: boolean
interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
}
/**
@@ -59,7 +43,7 @@ type Execution<E, Reason> = {
* ```
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -73,22 +57,21 @@ export const make = <Key, E, Reason = never>(options: {
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.request = execution.pendingWake
execution.pendingWake = undefined
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
// 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, request: Request) => {
const start = (key: Key, force: boolean) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
request,
pendingWake: false,
stopping: false,
}
executions.set(key, execution)
@@ -104,7 +87,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) => finish(key, execution, exit)),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.exit,
Effect.asVoid,
),
@@ -114,22 +97,12 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, resume: boolean) => {
if (resume && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
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)
@@ -138,58 +111,26 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true, "input").done)
return Deferred.await(start(key, true).done)
})
const wake = (key: Key, request: Request = "input") =>
const wake = (key: Key) =>
Effect.sync(() => {
const execution = executions.get(key)
if (execution !== undefined) {
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
execution.pendingWake = true
return
}
start(key, false, request)
start(key, false)
})
const wakeActive = (key: Key) =>
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
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)
}
if (execution?.owner === undefined || execution.stopping) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.pendingWake = false
execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner)
})
@@ -202,5 +143,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
})
@@ -3,7 +3,6 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { 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"
@@ -30,8 +29,6 @@ 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>
}
+14 -16
View File
@@ -128,25 +128,22 @@ 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
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
if (yield* runPendingCompaction(input.sessionID)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation, promotable)
const result = yield* runSteps(input.sessionID, continuation)
if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false
continuation = undefined
}
@@ -158,15 +155,14 @@ const layer = Layer.effect(
*/
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation: Continuation | undefined,
drainPromotable: SessionInbox.Promotable,
continuation?: Continuation,
) {
// Fresh work may promote queued input; resumed turns and later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : drainPromotable
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
let step = continuation?.step ?? 1
let next = continuation
while (true) {
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -519,14 +515,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */
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.nextPromotable(db, sessionID, promotable)
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -568,7 +564,9 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
@@ -1,5 +1,5 @@
import { type LLMEvent, type ProviderMetadata, type ToolResultValue } from "@opencode-ai/ai"
import { Clock, Effect } from "effect"
import { Effect } from "effect"
import { Bus } from "../../bus.js"
import { Model } from "../../model.js"
import { SessionEvent } from "../event.js"
@@ -81,7 +81,6 @@ const hostedContent = (result: ToolResultValue): NonEmptyContent => {
* and consumers fold by id/ordinal rather than global position.
*/
export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, input: Input) => {
const deltaBatchInterval = 100
const tools = new Map<
string,
{
@@ -124,56 +123,32 @@ 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,
) => {
type Fragment = {
readonly ordinal: number
readonly values: string[]
pending: string
publishedAt?: number
state?: Record<string, unknown>
}
const chunks = new Map<string, Fragment>()
const chunks = new Map<
string,
{ readonly ordinal: number; readonly values: string[]; state?: Record<string, unknown> }
>()
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: [], pending: "", state })
chunks.set(id, { ordinal, values: [], state })
return Effect.succeed(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 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 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(""),
@@ -181,10 +156,9 @@ 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 Array.from(chunks.keys())) yield* end(id)
for (const id of chunks.keys()) yield* end(id)
})
return { start, append, end, flush, has: (id: string) => chunks.has(id) }
}
@@ -201,15 +175,6 @@ 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(
@@ -224,15 +189,6 @@ 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) =>
@@ -395,7 +351,13 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "text-delta":
yield* text.append(event.id, event.text, providerState(event.providerMetadata))
const deltaTextOrdinal = yield* text.append(event.id, event.text, providerState(event.providerMetadata))
yield* bus.publish(SessionEvent.Text.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaTextOrdinal,
delta: event.text,
})
return
case "text-end":
yield* text.end(event.id, providerState(event.providerMetadata))
@@ -411,7 +373,17 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
})
return
case "reasoning-delta":
yield* reasoning.append(event.id, event.text, providerState(event.providerMetadata))
const deltaReasoningOrdinal = yield* reasoning.append(
event.id,
event.text,
providerState(event.providerMetadata),
)
yield* bus.publish(SessionEvent.Reasoning.Delta, {
sessionID: input.sessionID,
assistantMessageID: yield* currentAssistantMessageID(),
ordinal: deltaReasoningOrdinal,
delta: event.text,
})
return
case "reasoning-end":
yield* reasoning.end(event.id, providerState(event.providerMetadata))
@@ -427,6 +399,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
return yield* Effect.die(new Error(`Tool input name changed for ${event.id}: ${tool.name} -> ${event.name}`))
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":
+18 -3
View File
@@ -4,7 +4,9 @@ import { ToolFailure } from "@opencode-ai/ai"
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
import { Effect, Schema, Scope } from "effect"
import { Agent } from "../../agent.js"
import { Catalog } from "../../catalog.js"
import { Config } from "../../config.js"
import { ModelRouting } from "../../model-routing.js"
import { PluginRuntime } from "../../plugin/runtime.js"
import { Permission } from "../../permission.js"
import { SessionSchema } from "../../session/schema.js"
@@ -23,6 +25,10 @@ export const Input = Schema.Struct({
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
model: Schema.optionalKey(Schema.String).annotate({
description:
'Optional model route. Use "fast" for cheap bounded work, "smart" for difficult reasoning, "vision" for image input, or "long-context" for very large inputs. Pass an exact provider/model ID only when the user requests one. Omit this field to use the agent model or inherit the parent model.',
}),
background: Schema.optionalKey(Schema.Boolean).annotate({
description:
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
@@ -47,6 +53,7 @@ export const Plugin = {
effect: Effect.fn("SubagentTool.Plugin")(function* (ctx: PluginContext) {
const runtime = yield* PluginRuntime.Service
const agents = yield* Agent.Service
const catalog = yield* Catalog.Service
const config = yield* Config.Service
const permission = yield* Permission.Service
const scope = yield* Scope.Scope
@@ -163,8 +170,14 @@ export const Plugin = {
})
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Subagent denied: ${agent.id}`, error })))
// Model selection is policy/config/session state, not an LLM-facing tool argument.
const model = agent.model ?? parent.model
const routed = input.model
? ModelRouting.resolve(input.model, yield* catalog.model.available())
: undefined
if (input.model && !routed)
return yield* new ToolFailure({
message: `No available model matches route: ${input.model}`,
})
const model = routed ?? agent.model ?? parent.model
const child = yield* runtime.session
.create({
parentID: context.sessionID,
@@ -181,7 +194,9 @@ export const Plugin = {
)
const background = input.background === true
yield* context.progress({ sessionID: child.id, status: "running" })
yield* context.progress({
metadata: { sessionID: child.id, status: "running" },
})
const run = Effect.gen(function* () {
// The child session owns its agent/model (set at create); prompt only admits input.
+39 -61
View File
@@ -1,6 +1,5 @@
import { APICallError } from "@ai-sdk/provider"
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { createMistral } from "@ai-sdk/mistral"
import { AISDK } from "@opencode-ai/core/aisdk"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
@@ -278,88 +277,67 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
}),
)
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
it.effect("preserves tool result content in AI SDK prompts", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
let body: { messages?: unknown[] } | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
const chunks = [
{
id: "response-1",
created: 0,
model: "pixtral-large-latest",
choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }],
},
{
id: "response-1",
created: 0,
model: "pixtral-large-latest",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
]
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
headers: { "Content-Type": "text/event-stream" },
})
},
{ preconnect: fetch.preconnect },
)
yield* aisdk.hook.sdk((event) => {
event.sdk = createMistral({ apiKey: "test", fetch: mockFetch })
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
})
const resolved = yield* aisdk.model({
...model("@ai-sdk/mistral"),
modelID: Model.ID.make("pixtral-large-latest"),
})
yield* LLMClient.generate(
const resolved = yield* aisdk.model(model("test-ai-sdk"))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
messages: [
Message.user("Inspect the screenshot."),
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
Message.tool({
type: "tool-result",
id: "call_1",
name: "screenshot",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Screenshot captured" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
{ type: "text", text: "attachments" },
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
{
type: "file",
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
mime: "application/pdf",
name: "document.pdf",
},
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
],
},
}),
],
}),
).pipe(Effect.provide(client))
)
expect(body?.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
{
role: "assistant",
content: "",
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "screenshot", arguments: "{}" },
},
],
},
expect(prepared.body.prompt).toEqual([
{
role: "tool",
name: "screenshot",
tool_call_id: "call_1",
content: '[{"type":"text","text":"Screenshot captured"}]',
},
{
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
{
type: "tool-result",
toolCallId: "call_1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "text", text: "attachments" },
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
{
type: "file-data",
data: "JVBERg==",
mediaType: "application/pdf",
filename: "document.pdf",
},
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
{ type: "image-url", url: "https://example.com/pixel.png" },
{ type: "file-url", url: "https://example.com/document.pdf" },
],
},
},
],
},
])
+24 -25
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Ref, Schema, Stream } from "effect"
import { Bus } from "@opencode-ai/core/bus"
import { 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()
const event = yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
yield* bus.publish(SyncMessage, { id: aggregateID, text: "first" })
const rows = yield* db
.select()
.from(EventTable)
@@ -433,7 +433,6 @@ 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)
}),
)
@@ -707,7 +706,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -727,7 +726,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -764,7 +763,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID: envelopeAggregateID,
@@ -798,7 +797,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -807,7 +806,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 5,
aggregateID,
@@ -832,14 +831,14 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(SessionEvent.InstructionsUpdated.type, 2),
seq: 0,
aggregateID,
data: { sessionID: aggregateID, delta: { "core/context": "0".repeat(64) } },
})
expect(received[0]?.created).toBe(0)
expect(received[0]?.created).toEqual(DateTime.makeUnsafe(0))
}),
)
@@ -868,7 +867,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: "unknown.event.1",
seq: 0,
aggregateID: Event.ID.create(),
@@ -896,7 +895,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -916,7 +915,7 @@ describe("Bus", () => {
const id = Event.ID.create()
const replayed = {
id,
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -973,7 +972,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1002,7 +1001,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1013,7 +1012,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 2,
aggregateID,
@@ -1046,7 +1045,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1059,7 +1058,7 @@ describe("Bus", () => {
.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1081,7 +1080,7 @@ describe("Bus", () => {
yield* bus.listen((event) => Effect.sync(() => received.push(event)))
const replayed = {
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1102,7 +1101,7 @@ describe("Bus", () => {
const aggregateID = Session.ID.create()
const replayed = {
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1127,7 +1126,7 @@ describe("Bus", () => {
const id = Event.ID.create()
yield* bus.replay({
id,
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1137,7 +1136,7 @@ describe("Bus", () => {
const exit = yield* bus
.replay({
id,
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1160,7 +1159,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
@@ -1171,7 +1170,7 @@ describe("Bus", () => {
yield* bus.replay(
{
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 1,
aggregateID,
@@ -1233,7 +1232,7 @@ describe("Bus", () => {
yield* bus.replay({
id: Event.ID.create(),
created: 0,
created: DateTime.makeUnsafe(0),
type: Bus.versionedType(DurableMessage.type, 1),
seq: 0,
aggregateID,
+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: 0,
created: DateTime.makeUnsafe(0),
type: Event.Updated.type,
data: {},
} satisfies Payload<typeof Event.Updated>)
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, test } from "bun:test"
import { Money } from "@opencode-ai/schema/money"
import { ModelRouting } from "@opencode-ai/core/model-routing"
import { Model } from "@opencode-ai/core/model"
import { Provider } from "@opencode-ai/core/provider"
const model = (
providerID: string,
id: string,
input: readonly string[],
options: { cost?: number; context?: number; released?: number } = {},
) =>
Model.Info.make({
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
name: id,
capabilities: { tools: true, input: [...input], output: ["text"] },
time: { released: options.released ?? 1 },
cost: [
{
input: Money.USDPerMillionTokens.make(options.cost ?? 1),
output: Money.USDPerMillionTokens.make(options.cost ?? 1),
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
],
limit: { context: options.context ?? 100_000, output: 10_000 },
})
describe("ModelRouting.select", () => {
test("routes fast work to an inexpensive fast family without selecting haiku", () => {
const selected = ModelRouting.select("fast", [
model("anthropic", "claude-haiku-4", ["text"], { cost: 0.1, released: 4 }),
model("google", "gemini-flash", ["text"], { cost: 0.2, released: 3 }),
model("openai", "gpt-5", ["text"], { cost: 2, released: 5 }),
])
expect(selected).toEqual(
Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-flash") }),
)
})
test("routes smart work to a high-capability family", () => {
const selected = ModelRouting.select("smart", [
model("google", "gemini-flash", ["text"], { released: 5 }),
model("anthropic", "claude-opus-4", ["text"], { released: 3 }),
])
expect(selected).toEqual(
Model.Ref.make({ providerID: Provider.ID.make("anthropic"), id: Model.ID.make("claude-opus-4") }),
)
})
test("enforces role capabilities and provider availability through the candidate set", () => {
expect(
ModelRouting.select("vision", [
model("openai", "gpt-5", ["text"], { released: 5 }),
model("google", "gemini-pro-vision", ["text", "image"], { released: 3 }),
]),
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro-vision") }))
expect(
ModelRouting.select("long-context", [
model("openai", "gpt-5", ["text"], { context: 200_000 }),
model("google", "gemini-pro", ["text"], { context: 1_000_000 }),
]),
).toEqual(Model.Ref.make({ providerID: Provider.ID.make("google"), id: Model.ID.make("gemini-pro") }))
})
test("resolves only exact models present in the available catalog", () => {
const available = [model("xai", "grok-4", ["text"])]
expect(ModelRouting.resolve("xai/grok-4", available)).toEqual(
Model.Ref.make({ providerID: Provider.ID.make("xai"), id: Model.ID.make("grok-4") }),
)
expect(ModelRouting.resolve("xai/grok-5", available)).toBeUndefined()
})
})
-180
View File
@@ -1,180 +0,0 @@
import { describe, expect } from "bun:test"
import { Message } from "@opencode-ai/ai"
import { DateTime, Effect, Stream } from "effect"
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
import { Agent } from "@opencode-ai/core/agent"
import { Event } from "@opencode-ai/schema/event"
import { Model } from "@opencode-ai/core/model"
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
import { Provider } from "@opencode-ai/core/provider"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { it } from "../lib/effect"
import { host } from "./host"
const sessionID = Session.ID.make("ses_plan_test")
const plan = Agent.ID.make("plan")
const build = Agent.ID.make("build")
const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({
id: Event.ID.create(),
created: 0,
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
type: "session.agent.selected",
data: { sessionID, agent, previous },
})
/** Runs the plan plugin against stubbed domains, capturing persisted reminders and the context hook. */
const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.AgentSelected> = []) {
const persisted = new Array<string>()
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
yield* PlanPlugin.Plugin.effect(
host({
agent: {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
reload: () => Effect.die("unused agent.reload"),
transform: () => Effect.succeed({ dispose: Effect.void }),
},
tool: {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.succeed({ dispose: Effect.void }),
},
event: {
subscribe: () => Stream.fromIterable(events),
},
session: {
hook: (name, callback) => {
if (name === "context") contextHook = callback as (input: SessionContext) => Effect.Effect<void>
return Effect.succeed({ dispose: Effect.void })
},
synthetic: (input) => {
persisted.push(input.text)
return Effect.succeed(
SessionInbox.Synthetic.make({
id: SessionMessage.ID.make("msg_plan_test"),
sessionID,
timeCreated: DateTime.makeUnsafe(0),
type: "synthetic",
payload: { text: input.text },
delivery: "steer",
}),
)
},
},
}),
)
if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook")
return { persisted, contextHook }
})
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
sessionID,
agent,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
system: [],
messages,
tools: {},
})
const settle = (persisted: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
Effect.gen(function* () {
if (persisted.length >= expected) return
if (remaining === 0) {
return yield* Effect.fail(new Error(`Timed out waiting for ${expected} reminders, saw ${persisted.length}`))
}
yield* Effect.promise(() => Bun.sleep(1))
yield* settle(persisted, expected, remaining - 1)
})
/** The exact reminder texts, derived from plugin behavior rather than duplicated here. */
const reminders = Effect.gen(function* () {
const planRun = yield* run()
yield* planRun.contextHook(request(plan, []))
const buildRun = yield* run()
yield* buildRun.contextHook(request(build, [Message.user(planRun.persisted[0]!)]))
return { enter: planRun.persisted[0]!, leave: buildRun.persisted[0]! }
})
describe("plan plugin reminders", () => {
it.effect("injects enter and leave reminders on agent switches", () =>
Effect.gen(function* () {
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
yield* settle(persisted, 2)
expect(persisted[0]).toContain("You are in Plan mode")
expect(persisted[1]).toContain("NO LONGER in Plan mode")
}),
)
it.effect("reconciles a missing enter reminder into the request and persists it", () =>
Effect.gen(function* () {
const { persisted, contextHook } = yield* run()
const messages = [Message.user("what agent are you?")]
yield* contextHook(request(plan, messages))
expect(messages).toHaveLength(2)
// Inserted before the user's prompt, matching where agent-switch reminders land.
const first = messages[0]?.content[0]
expect(first?.type === "text" && first.text).toContain("You are in Plan mode")
expect(persisted).toHaveLength(1)
}),
)
it.effect("does nothing when the transcript already has a live enter reminder", () =>
Effect.gen(function* () {
const { enter } = yield* reminders
const { persisted, contextHook } = yield* run()
const messages = [Message.user(enter), Message.user("hello")]
yield* contextHook(request(plan, messages))
expect(messages).toHaveLength(2)
expect(persisted).toHaveLength(0)
}),
)
it.effect("reconciles a stale enter reminder with a leave reminder", () =>
Effect.gen(function* () {
const { enter } = yield* reminders
const { persisted, contextHook } = yield* run()
const messages = [Message.user(enter), Message.user("ok implement it")]
yield* contextHook(request(build, messages))
expect(messages).toHaveLength(3)
const middle = messages[1]?.content[0]
expect(middle?.type === "text" && middle.text).toContain("NO LONGER in Plan mode")
expect(persisted).toHaveLength(1)
}),
)
it.effect("does nothing for non-plan sessions without plan history", () =>
Effect.gen(function* () {
const { persisted, contextHook } = yield* run()
const messages = [Message.user("hello")]
yield* contextHook(request(build, messages))
expect(messages).toHaveLength(1)
expect(persisted).toHaveLength(0)
}),
)
it.effect("does nothing when a leave reminder already follows the enter reminder", () =>
Effect.gen(function* () {
const { enter, leave } = yield* reminders
const { persisted, contextHook } = yield* run()
const messages = [Message.user(enter), Message.user(leave), Message.user("continue")]
yield* contextHook(request(build, messages))
expect(messages).toHaveLength(3)
expect(persisted).toHaveLength(0)
}),
)
it.effect("treats reminder text quoted inside a larger message as not live", () =>
Effect.gen(function* () {
const { enter } = yield* reminders
const { persisted, contextHook } = yield* run()
// Mirrors a compaction checkpoint quoting the reminder inside <recent-context>.
const messages = [Message.user(`<conversation-checkpoint>\n${enter}\n</conversation-checkpoint>`)]
yield* contextHook(request(plan, messages))
expect(messages).toHaveLength(2)
expect(persisted).toHaveLength(1)
}),
)
})
+1 -1
View File
@@ -599,7 +599,7 @@ describe("Session.create", () => {
.all()
.pipe(Effect.orDie)).map((event) => ({
id: event.id,
created: event.created,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
+18 -8
View File
@@ -31,7 +31,6 @@ 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(
@@ -42,16 +41,14 @@ const execution = Layer.succeed(
Effect.sync(() => {
executionCalls.push(sessionID)
}),
interrupt: (sessionID, options) =>
interrupt: (sessionID) =>
Effect.sync(() => {
interruptCalls.push(sessionID)
interruptContinuations.push(options?.continue)
}),
wake: (sessionID) =>
Effect.sync(() => {
wakeCalls.push(sessionID)
}),
wakeActive: () => Effect.void,
awaitIdle: () => Effect.void,
}),
)
@@ -180,18 +177,31 @@ describe("Session.prompt", () => {
}),
)
it.effect("forwards interrupt continuation policy", () =>
it.effect("continues after interruption when pending work remains", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
yield* session.synthetic({ sessionID, text: "Continue after interrupt", resume: false })
interruptCalls.length = 0
wakeCalls.length = 0
yield* session.interrupt(sessionID, { continue: true })
expect(interruptCalls).toEqual([sessionID])
expect(wakeCalls).toEqual([sessionID])
}),
)
it.effect("does not continue after interruption without pending work", () =>
Effect.gen(function* () {
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([])
}),
)
@@ -760,7 +770,7 @@ describe("Session.prompt", () => {
yield* Effect.forEach(
recorded.map((event) => ({
id: event.id,
created: event.created,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@@ -269,35 +269,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("replaces a settlement-window wake with a steer continuation", () =>
Effect.scoped(
Effect.gen(function* () {
const settling = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) => Effect.sync(() => requests.push(request)),
settled: () => Deferred.succeed(settling, undefined).pipe(Effect.andThen(Deferred.await(release))),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(settling)
yield* coordinator.wake("session", "input")
const interrupted = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(true) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(interrupted)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("interrupts active execution and clears its pending wake", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -371,193 +342,6 @@ describe("SessionRunCoordinator", () => {
),
)
it.effect("coalesces drain requests with input taking precedence", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "input"])
}),
),
)
it.effect("does not carry a completed input request into a steer drain", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
yield* coordinator.wake("session", "steer")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("an active wake inherits scope without starting idle work", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const release = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Deferred.await(release)
}),
})
yield* coordinator.wakeActive("session")
yield* coordinator.wake("session", "steer")
yield* Deferred.await(firstStarted)
yield* coordinator.wakeActive("session")
yield* Deferred.succeed(release, undefined)
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["steer", "steer"])
}),
),
)
it.effect("coalesces overlapping interrupt continuations into one steer successor", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
const continuation = { continue: { request: "steer" as const, when: Effect.succeed(false) } }
yield* coordinator.wake("session")
yield* Deferred.await(firstStarted)
const first = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
const second = yield* coordinator.interrupt("session", undefined, continuation).pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* coordinator.wake("session", "input")
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(first), Fiber.join(second)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("a continuing interrupt replaces a cleanup-era input wake", () =>
Effect.scoped(
Effect.gen(function* () {
const firstStarted = yield* Deferred.make<void>()
const cleanupStarted = yield* Deferred.make<void>()
const cleanupGate = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.gen(function* () {
requests.push(request)
if (requests.length !== 1) return
yield* Deferred.succeed(firstStarted, undefined)
yield* Effect.never.pipe(
Effect.onInterrupt(() =>
Deferred.succeed(cleanupStarted, undefined).pipe(Effect.andThen(Deferred.await(cleanupGate))),
),
)
}),
})
yield* coordinator.wake("session", "input")
yield* Deferred.await(firstStarted)
const plain = yield* coordinator.interrupt("session").pipe(Effect.forkChild)
yield* Deferred.await(cleanupStarted)
yield* coordinator.wake("session", "input")
const continuing = yield* coordinator
.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
.pipe(Effect.forkChild)
yield* Effect.yieldNow
yield* Deferred.succeed(cleanupGate, undefined)
yield* Effect.all([Fiber.join(plain), Fiber.join(continuing)])
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input", "steer"])
}),
),
)
it.effect("does not start a conditional continuation without eligible work", () =>
Effect.scoped(
Effect.gen(function* () {
const started = yield* Deferred.make<void>()
const requests: SessionRunCoordinator.Request[] = []
const coordinator = yield* SessionRunCoordinator.make({
drain: (_key, _force, request) =>
Effect.sync(() => requests.push(request)).pipe(
Effect.andThen(Deferred.succeed(started, undefined)),
Effect.andThen(Effect.never),
),
})
yield* coordinator.wake("session")
yield* Deferred.await(started)
yield* coordinator.interrupt("session", undefined, {
continue: { request: "steer", when: Effect.succeed(false) },
})
yield* coordinator.awaitIdle("session")
expect(requests).toEqual(["input"])
}),
),
)
it.effect("starts a resume registered during interruption cleanup", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -126,8 +126,7 @@ const execution = (llmClient: Layer.Layer<typeof LLMClient.Service>) =>
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -13,8 +13,6 @@ import { Provider } from "@opencode-ai/core/provider"
import { RelativePath } from "@opencode-ai/core/schema"
import { Snapshot } from "@opencode-ai/core/snapshot"
import { createLLMEventPublisher } from "@opencode-ai/core/session/runner/publish-llm-event"
import { it } from "./lib/effect"
import { TestClock } from "effect/testing"
const sessionID = Session.ID.make("ses_tool_event_test")
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
@@ -203,82 +201,6 @@ test("reasoning state from start, empty delta, and end is merged", async () => {
})
})
it.effect("batches text deltas and flushes pending text before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.textStart({ id: "text" }),
LLMEvent.textDelta({ id: "text", text: "one" }),
LLMEvent.textDelta({ id: "text", text: " two" }),
LLMEvent.textDelta({ id: "text", text: " three" }),
],
publisher.publish,
{ discard: true },
)
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("99 millis")
expect(published.filter((event) => event.type === "session.text.delta")).toHaveLength(0)
yield* TestClock.adjust("1 millis")
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " four" }))
expect(published.filter((event) => event.type === "session.text.delta").map((event) => event.data)).toMatchObject([
{ delta: "one two three four" },
])
yield* publisher.publish(LLMEvent.textDelta({ id: "text", text: " five" }))
yield* publisher.publish(LLMEvent.textEnd({ id: "text" }))
expect(published.slice(-2).map((event) => event.type)).toEqual(["session.text.delta", "session.text.ended.1"])
expect(published.at(-2)?.data).toMatchObject({ delta: " five" })
}),
)
it.effect("batches reasoning deltas and flushes pending reasoning before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.reasoningStart({ id: "reasoning" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: "one" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " two" }),
LLMEvent.reasoningDelta({ id: "reasoning", text: " three" }),
LLMEvent.reasoningEnd({ id: "reasoning" }),
],
publisher.publish,
{ discard: true },
)
expect(
published.filter((event) => event.type === "session.reasoning.delta").map((event) => event.data),
).toMatchObject([{ delta: "one two three" }])
expect(published.slice(-2).map((event) => event.type)).toEqual([
"session.reasoning.delta",
"session.reasoning.ended.1",
])
}),
)
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
LLMEvent.toolInputEnd({ id: "call", name: "read" }),
],
publisher.publish,
{ discard: true },
),
)
expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
text: '{"path":"file.txt"}',
})
})
test("provider-executed tool metadata is flattened using the route key", async () => {
const { published, publisher } = capture("openai")
await Effect.runPromise(
+16 -92
View File
@@ -75,7 +75,7 @@ import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import { ID } from "@opencode-ai/core/model"
import { Location } from "@opencode-ai/core/location"
import { Provider } from "@opencode-ai/core/provider"
import { Cause, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Schema, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { asc, desc, eq } from "drizzle-orm"
@@ -413,8 +413,7 @@ const execution = Layer.effect(
active: coordinator.active,
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
interrupt: (sessionID) => coordinator.interrupt(sessionID),
interrupt: coordinator.interrupt,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -672,7 +671,7 @@ const replaySessionProjection = (id: Session.ID) =>
yield* Effect.forEach(
recorded.map((event) => ({
id: event.id,
created: event.created,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@@ -686,7 +685,7 @@ const replaySessionProjection = (id: Session.ID) =>
type FragmentKind = "text" | "reasoning" | "tool input"
type FragmentFixture = {
readonly delta?: Event.Definition
readonly delta: Event.Definition
readonly completeEvents: LLMEvent[]
readonly partialEvents: LLMEvent[]
readonly expectedAssistant: unknown
@@ -748,6 +747,7 @@ const fragmentFixture = (kind: FragmentKind, id: string, chunks: readonly string
]
const expectedContent = { type: "tool", id, state: { status: "streaming", input: text } }
return {
delta: SessionEvent.Tool.Input.Delta,
partialEvents,
completeEvents: [...partialEvents, LLMEvent.toolInputEnd({ id, name: "echo" })],
expectedAssistant: { type: "assistant", content: [expectedContent] },
@@ -766,37 +766,20 @@ const verifyEphemeralDeltas = (kind: FragmentKind) =>
const expectedContext = [{ type: "user", text: prompt }, fixture.expectedAssistant]
yield* admit(session, prompt)
const bus = yield* Bus.Service
const live = fixture.delta
? yield* bus.subscribe(fixture.delta).pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
: undefined
const live = yield* bus.subscribe(fixture.delta).pipe(Stream.take(32), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* TestLLM.push(fixture.completeEvents)
yield* session.resume(sessionID)
const { db } = yield* Database.Service
const deltas = fixture.delta
? yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
.all()
.pipe(Effect.orDie)
: []
if (live) {
const streamed = Array.from(yield* Fiber.join(live))
expect(streamed).toHaveLength(1)
expect(
streamed
.map((event) => {
if (!event.data || typeof event.data !== "object" || !("delta" in event.data))
throw new Error("Expected delta event")
if (typeof event.data.delta !== "string") throw new Error("Expected string delta")
return event.data.delta
})
.join(""),
).toBe(chunks.join(""))
}
const deltas = yield* db
.select({ type: EventTable.type })
.from(EventTable)
.where(eq(EventTable.type, Bus.versionedType(fixture.delta.type, 1)))
.all()
.pipe(Effect.orDie)
expect(Array.from(yield* Fiber.join(live))).toHaveLength(32)
expect(deltas).toHaveLength(0)
expect(yield* session.context(sessionID)).toMatchObject(expectedContext)
@@ -1400,44 +1383,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("keeps queued input parked across a mid-turn move", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* admit(session, "Echo before moving")
yield* TestLLM.push(
TestLLM.tool("call-move", "echo", { text: "moving" }),
TestLLM.text("Done", "text-after-move"),
TestLLM.text("Handled queue", "text-after-queue"),
)
const tools = yield* blockTools()
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* tools.started
yield* session.prompt({ sessionID, text: "Queued for later", delivery: "queue", resume: false })
yield* SessionInbox.admit(db, bus, {
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
})
yield* tools.release
yield* Fiber.join(run)
// The resumed turn absorbs steers only; queued input waits for the turn to end.
expect(requests).toHaveLength(3)
expect(userTexts(requests[1])).not.toContain("Queued for later")
expect(userTexts(requests[2])).toContain("Queued for later")
}),
)
it.effect("seeds a fork with the parent's newest instruction values", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -1479,7 +1424,7 @@ describe("SessionRunnerLLM", () => {
yield* Effect.forEach(
recorded.map((event) => ({
id: event.id,
created: event.created,
created: DateTime.makeUnsafe(event.created),
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
@@ -3143,24 +3088,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("stops a steer-scoped drain before queued input", () =>
Effect.gen(function* () {
const session = yield* setup
const { db } = yield* Database.Service
yield* session.prompt({ sessionID, text: "Queue for later", delivery: "queue", resume: false })
yield* session.prompt({ sessionID, text: "Steer now", resume: false })
yield* TestLLM.push(TestLLM.stop())
const runner = yield* SessionRunner.Service
yield* runner.drain({ sessionID, force: false, promotable: "steer" })
expect(requests).toHaveLength(1)
expect(userTexts(requests[0])).toEqual(["Steer now"])
expect(yield* SessionInbox.has(db, sessionID, "steer")).toBe(false)
expect(yield* SessionInbox.has(db, sessionID, "queue")).toBe(true)
}),
)
it.effect("promotes queued input after steering continuation ends", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -5235,11 +5162,8 @@ describe("SessionRunnerLLM", () => {
)
for (const kind of fragmentKinds) {
it.effect(
kind === "tool input"
? "does not broadcast provider tool input deltas"
: `batches provider ${kind} deltas without storing projection rewrites`,
() => verifyEphemeralDeltas(kind),
it.effect(`broadcasts provider ${kind} deltas without storing projection rewrites`, () =>
verifyEphemeralDeltas(kind),
)
it.effect(`durably closes partial ${kind} when the provider stream fails`, () => verifyPartialFlushOnFailure(kind))
-1
View File
@@ -115,7 +115,6 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (id) => complete(id).pipe(Effect.exit, Effect.asVoid),
})
+56 -3
View File
@@ -8,6 +8,7 @@ import { Global } from "@opencode-ai/util/global"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Catalog } from "@opencode-ai/core/catalog"
import { Config } from "@opencode-ai/core/config"
import { Location } from "@opencode-ai/core/location"
import { Model } from "@opencode-ai/core/model"
@@ -36,6 +37,7 @@ import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
const childText = "child final response"
const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
const fastModel = Model.Ref.make({ id: Model.ID.make("gemini-flash"), providerID: Provider.ID.make("route") })
const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
const outputSessionID = (value: unknown) =>
@@ -86,7 +88,6 @@ const executionNode = makeGlobalNode({
active: Effect.succeed(new Set()),
resume: complete,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
})
@@ -101,7 +102,7 @@ const subagentPluginSupervisor = makeLocationNode({
PluginSupervisor.Service,
registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))),
),
deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
deps: [Agent.node, Catalog.node, Config.node, Permission.node, PluginRuntime.node, Tool.node],
})
const nodes = LayerNode.group([
@@ -143,6 +144,27 @@ const withSubagent = (location: Location.Ref) =>
})
}),
).pipe(Effect.provide(locations.get(location)))
yield* Catalog.Service.use((catalog) =>
catalog.transform((draft) => {
draft.provider.update(fastModel.providerID, (provider) => {
provider.activation = "enabled"
})
draft.model.update(fastModel.providerID, fastModel.id, (model) => {
Object.assign(model, Model.Info.default(fastModel.providerID, fastModel.id), {
name: "Gemini Flash",
family: Model.Family.make("gemini-flash"),
time: { released: Date.now() },
cost: [
{
input: Money.USDPerMillionTokens.make(0.1),
output: Money.USDPerMillionTokens.make(0.2),
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
],
})
})
}),
).pipe(Effect.provide(locations.get(location)))
})
describe("SubagentTool", () => {
@@ -298,7 +320,7 @@ describe("SubagentTool", () => {
})
const child = yield* sessions.get(outputSessionID(settled.metadata))
expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
expect(progress[0]).toEqual({ sessionID: child.id, status: "running" })
expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
expect(child).toMatchObject({
parentID: parent.id,
location: parent.location,
@@ -321,6 +343,37 @@ describe("SubagentTool", () => {
})
const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
const routed = yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-routed",
name: SubagentTool.name,
input: { agent: "reviewer", description: "fast", prompt: "quick check", model: "fast" },
},
})
expect(yield* sessions.get(outputSessionID(routed.metadata))).toMatchObject({
parentID: parent.id,
model: fastModel,
})
expect(
yield* executeTool(registry, {
sessionID: parent.id,
...toolIdentity,
call: {
type: "tool-call",
id: "call-subagent-missing-model",
name: SubagentTool.name,
input: { agent: "reviewer", description: "missing", prompt: "check", model: "missing/model" },
},
}),
).toEqual({
status: "error",
error: { type: "tool.execution", message: "No available model matches route: missing/model" },
})
}),
),
),
+2 -3
View File
@@ -1,12 +1,11 @@
#!/usr/bin/env bun
import { $ } from "bun"
import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
import { downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel()
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")
if (channel === "beta") await downloadCliToResources("next")
+3 -13
View File
@@ -3,7 +3,7 @@ import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
const CLI_VERSION = "dev"
const CLI_VERSION = "0.0.0-next-16365"
export type Channel = "dev" | "beta" | "prod"
@@ -74,28 +74,18 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyCliToResources(
await copyFile(
join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"),
dest,
)
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) {
const cli = getCurrentCli()
const directory = cli.package.replace("@opencode-ai/", "")
await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), dest)
}
async function copyCliToResources(source: string, dest: string) {
await copyFile(source, dest)
await prepareCli(dest)
}
async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
@@ -27,7 +27,7 @@ test("installs and verifies the bundled CLI version", async () => {
await controller.installOpencode("Debian")
expect(installs).toEqual([["Debian", "0.0.0-dev-16365"]])
expect(installs).toEqual([["Debian", "0.0.0-next-16365"]])
expect(controller.getState().opencodeChecks.Debian?.matchesDesktop).toBe(true)
})
@@ -37,12 +37,12 @@ test("rejects a WSL CLI version that differs from the bundled version", async ()
testControllerOptions({
installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-dev-older",
readCliVersion: async () => "0.0.0-next-older",
}),
)
await expect(controller.installOpencode("Debian")).rejects.toThrow(
"OpenCode update finished but Debian still reports 0.0.0-dev-older; expected 0.0.0-dev-16365",
"OpenCode update finished but Debian still reports 0.0.0-next-older; expected 0.0.0-next-16365",
)
})
@@ -147,7 +147,7 @@ async function waitFor(check: () => boolean) {
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
return {
cli: { version: "0.0.0-dev-16365" },
cli: { version: "0.0.0-next-16365" },
spawnSidecar: async () => ({
stop: async () => undefined,
onExit: () => undefined,
@@ -159,7 +159,7 @@ function testControllerOptions(overrides: Partial<ControllerOptions> = {}): Cont
writeServers: (servers: WslServerConfig[]) => {
persistedServers = servers
},
readCliVersion: async () => "0.0.0-dev-16365",
readCliVersion: async () => "0.0.0-next-16365",
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
...overrides,
}
+1 -1
View File
@@ -3964,7 +3964,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"summary": "Interrupt session execution"
}
},
+1 -1
View File
@@ -660,7 +660,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
identifier: "v2.session.interrupt",
summary: "Interrupt session execution",
description:
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
}),
),
)
+1 -25
View File
@@ -1,25 +1,5 @@
import { expect, test } from "bun:test"
import { isOpenCodeEvent, type OpenCodeEvent, type OpenCodeEventEncoded } from "../src/groups/event.js"
type JsonShape<Value> = Value extends string | number | boolean | null
? Value
: Value extends undefined
? never
: Value extends ReadonlyArray<infer Item>
? ReadonlyArray<JsonShape<Item>>
: Value extends object
? {
readonly [Key in keyof Value as undefined extends Value[Key] ? never : Key]: JsonShape<Value[Key]>
} & {
readonly [Key in keyof Value as undefined extends Value[Key] ? Key : never]?: JsonShape<
Exclude<Value[Key], undefined>
>
}
: Value
// JSON.stringify omits undefined object properties, so normalize them before
// requiring every runtime event shape to fit its encoded wire contract.
const wireReady: [JsonShape<OpenCodeEvent>] extends [JsonShape<OpenCodeEventEncoded>] ? true : false = true
import { isOpenCodeEvent } from "../src/groups/event.js"
test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "server.connected" })).toBe(true)
@@ -27,7 +7,3 @@ test("classifies public events by type", () => {
expect(isOpenCodeEvent({ type: "mcp.resources.changed" })).toBe(true)
expect(isOpenCodeEvent({ type: "mcp.tools.changed" })).toBe(false)
})
test("keeps public event runtime values within the encoded contract", () => {
expect(wireReady).toBe(true)
})
+4 -4
View File
@@ -4,7 +4,7 @@ import { Schema, SchemaTransformation } from "effect"
import { optional } from "./schema.js"
import { ascending } from "./identifier.js"
import { Location } from "./location.js"
import { statics } from "./schema.js"
import { DateTimeUtcFromMillis, statics } from "./schema.js"
export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe(
Schema.brand("Event.ID"),
@@ -60,7 +60,7 @@ export type Data<D extends Definition> = Schema.Schema.Type<D["data"]>
type PayloadBase<D extends Definition> = {
readonly id: ID
readonly type: D["type"]
readonly created: number
readonly created: typeof DateTimeUtcFromMillis.Type
readonly data: Data<D>
readonly location?: Location.Ref
readonly metadata?: Record<string, unknown>
@@ -100,7 +100,7 @@ export function durable<
})
return Schema.Struct({
id: ID,
created: Schema.Finite,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
durable,
@@ -125,7 +125,7 @@ export function ephemeral<
const data = Schema.Struct(input.schema)
return Schema.Struct({
id: ID,
created: Schema.Finite,
created: DateTimeUtcFromMillis,
metadata: optional(Schema.Record(Schema.String, Schema.Unknown)),
type: Schema.Literal(input.type),
location: optional(Location.Ref),
+1 -1
View File
@@ -34,7 +34,7 @@ const IS_PREVIEW = CHANNEL !== "latest"
const VERSION = await (async () => {
if (env.OPENCODE_VERSION) return env.OPENCODE_VERSION
if (IS_PREVIEW) return `0.0.0-${CHANNEL}-${previewBuildNumber()}`
const version = await fetch("https://registry.npmjs.org/@opencode-ai%2fcli/latest")
const version = await fetch("https://registry.npmjs.org/opencode-ai/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
+4 -2
View File
@@ -2,7 +2,7 @@ export * as EventFeed from "./event-feed"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { isOpenCodeEvent, type OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { isOpenCodeEvent, OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { Cause, Context, Effect, Layer, Queue, Schema, Scope, Stream } from "effect"
export const SubscriberCapacity = 4_096
@@ -26,8 +26,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/server/EventFeed") {}
const encode = Schema.encodeUnknownSync(OpenCodeEvent)
export function frame(event: OpenCodeEvent) {
return `data: ${JSON.stringify(event)}\n\n`
return `data: ${JSON.stringify(encode(event))}\n\n`
}
export const make = Effect.fn("EventFeed.make")(function* (
+7 -4
View File
@@ -2,7 +2,8 @@ import { describe, expect, test } from "bun:test"
import { Agent } from "@opencode-ai/core/agent"
import { Bus } from "@opencode-ai/core/bus"
import { Event } from "@opencode-ai/schema/event"
import { Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import { DateTime, Deferred, Effect, Exit, Fiber, Option, Schema, Stream } from "effect"
import { it } from "../../core/test/lib/effect"
import { EventFeed } from "../src/event-feed"
@@ -10,14 +11,14 @@ const Internal = Bus.ephemeral({ type: "test.internal", schema: { value: Schema.
const event = (id: string): Event.Payload<typeof Agent.Event.Updated> => ({
id: Event.ID.make(`evt_${id}`),
created: Date.now(),
created: DateTime.makeUnsafe(Date.now()),
type: Agent.Event.Updated.type,
data: {},
})
const internal = (value: string): Event.Payload<typeof Internal> => ({
id: Event.ID.create(),
created: Date.now(),
created: DateTime.makeUnsafe(Date.now()),
type: Internal.type,
data: { value },
})
@@ -39,7 +40,9 @@ function makeSource() {
describe("EventFeed", () => {
test("preserves the public SSE frame encoding", () => {
const payload = event("wire")
expect(EventFeed.frame(payload)).toBe(`data: ${JSON.stringify(payload)}\n\n`)
expect(EventFeed.frame(payload)).toBe(
`data: ${JSON.stringify(Schema.encodeUnknownSync(OpenCodeEvent)(payload))}\n\n`,
)
})
it.effect("encodes once and delivers the same frame to every subscriber", () =>
@@ -1,7 +1,6 @@
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/client"
import { Project } from "@opencode-ai/schema/project"
import { TextAttributes } from "@opentui/core"
import type { RGBA } from "@opentui/core"
import { useDialog } from "../ui/dialog"
@@ -55,12 +54,10 @@ export function DialogSessionList() {
const response = await client.api.session.list({
...(allProjects
? {}
: current.project.id === Project.ID.global
? { directory: current.directory }
: {
project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
: {
project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
...(query ? { search: query } : {}),
limit: 50,
order: "desc",
+2 -17
View File
@@ -71,7 +71,6 @@ import { DialogImagePreview } from "../dialog-image-preview"
import { useDirectoryRecents } from "../../prompt/directory-recents"
import { directoryRecentValue } from "../../prompt/directory-completion"
import { useWorkingDirectoryActions } from "../../ui/working-directory-actions"
import { truncateFilePath } from "../../ui/file-path"
export type PromptProps = {
sessionID?: string
@@ -1556,12 +1555,6 @@ export function Prompt(props: PromptProps) {
const branch = data.location.vcs.info(location)?.branch.current
return branch ? `${directory}:${branch}` : directory
})
const [locationWidth, setLocationWidth] = createSignal(dimensions().width)
const locationLabelDisplay = createMemo(() => {
const label = locationLabel()
if (!label) return
return truncateFilePath(label, locationWidth())
})
const locationActions = useWorkingDirectoryActions({
directory: () => footerLocation()?.directory,
onMove: () => void move.open(),
@@ -1847,15 +1840,7 @@ export function Prompt(props: PromptProps) {
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
<Slot path="prompt.footer" input={footerInput()}>
<Slot path="prompt.footer.status" input={footerInput()}>
<box
flexGrow={1}
flexShrink={1}
minWidth={0}
onSizeChange={function (this: BoxRenderable) {
const width = this.width
queueMicrotask(() => setLocationWidth(width))
}}
>
<box flexGrow={1} flexShrink={1} minWidth={0}>
<Switch>
<Match when={status() === "running"}>
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
@@ -1892,7 +1877,7 @@ export function Prompt(props: PromptProps) {
</box>
</Match>
<Match when={true}>
<Show when={!props.hint && locationLabelDisplay()} fallback={props.hint ?? <text />}>
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
{(location) => (
<text
id="prompt.footer.location"
-4
View File
@@ -14,10 +14,6 @@ describe("truncateFilePath", () => {
expect(truncateFilePath(path, 19)).toBe("…/dialog-select.tsx")
})
test("preserves the working directory and branch suffix", () => {
expect(truncateFilePath("~/code/experiments/category-theory:main", 30)).toBe("…/experi…/category-theory:main")
})
test("uses remaining width for part of a long parent segment", () => {
const path = "/private/var/folders/run-17f048ec-dbb2-4b36-860c-98637bb51a8d/files"
expect(truncateFilePath(path, 40)).toBe("/…/run-17f048ec-dbb2-4b36-860c-98…/files")
+1 -10
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { channelsForRef, resolveChannel, validGitHubClaims } from "./index"
import { channelsForRef, validGitHubClaims } from "./index"
const claims = {
repository: "anomalyco/opencode",
@@ -19,10 +19,6 @@ describe("GitHub publish authorization", () => {
expect(channelsForRef(claims.ref)).toEqual(["dev", "latest"])
})
test("maps V2 development to the dev channel", () => {
expect(channelsForRef("refs/heads/v2")).toEqual(["dev"])
})
test("rejects another repository or workflow", () => {
expect(validGitHubClaims({ ...claims, repository_id: "1" })).toBe(false)
expect(
@@ -37,8 +33,3 @@ describe("GitHub publish authorization", () => {
).toBe(false)
})
})
test("routes the retired next channel to beta", () => {
expect(resolveChannel("next")).toBe("beta")
expect(resolveChannel("dev")).toBe("dev")
})
+4 -8
View File
@@ -42,7 +42,7 @@ export default {
const segments = url.pathname.split("/").filter(Boolean)
if (segments.length === 2 && segments[0] === "api" && validIdentifier(segments[1])) {
return channel(env.DB, resolveChannel(segments[1]))
return channel(env.DB, segments[1])
}
if (
segments.length === 3 &&
@@ -50,7 +50,7 @@ export default {
validIdentifier(segments[1]) &&
validIdentifier(segments[2])
) {
return artifactName(env.DB, resolveChannel(segments[1]), segments[2])
return artifactName(env.DB, segments[1], segments[2])
}
if (
segments.length === 4 &&
@@ -59,7 +59,7 @@ export default {
validIdentifier(segments[2]) &&
validIdentifier(segments[3])
) {
return artifactDistribution(env.DB, resolveChannel(segments[1]), segments[2], segments[3])
return artifactDistribution(env.DB, segments[1], segments[2], segments[3])
}
return new Response("Not found", { status: 404 })
},
@@ -344,7 +344,7 @@ export function validGitHubClaims(claims: JWTPayload): claims is GitHubClaims {
export function channelsForRef(ref: string) {
if (ref === "refs/heads/dev") return ["dev", "latest"]
if (ref === "refs/heads/v2") return ["dev"]
if (ref === "refs/heads/v2") return ["next"]
if (ref === "refs/heads/beta") return ["beta"]
if (ref === "refs/heads/ci") return ["ci"]
if (ref === "refs/heads/fix/npm-native-binary-install") return ["fix/npm-native-binary-install"]
@@ -352,10 +352,6 @@ export function channelsForRef(ref: string) {
return snapshot ? [snapshot] : []
}
export function resolveChannel(channel: string) {
return channel === "next" ? "beta" : channel
}
function validMutation(request: Request) {
const origin = request.headers.get("Origin")
if (origin && origin !== new URL(request.url).origin) return json({ error: "Invalid origin" }, 403)
+2 -2
View File
@@ -15,7 +15,7 @@ network. Its types and methods are generated from the same contract as the
## Install
```sh
bun add @opencode-ai/client@beta
bun add @opencode-ai/client@next
```
## Create a client
@@ -119,7 +119,7 @@ OpenCode provides a first-class Effect client through the
and decodes responses into OpenCode schema values.
```sh
bun add @opencode-ai/client@beta effect
bun add @opencode-ai/client@next effect
```
### Create a client
+3 -3
View File
@@ -106,7 +106,7 @@ visible from the plugin file, for example:
```sh
cd .opencode
bun add @opencode-ai/plugin@beta
bun add @opencode-ai/plugin@next
```
Match the plugin package version to the OpenCode release you target.
@@ -400,7 +400,7 @@ manifest is:
"type": "module",
"exports": "./src/index.ts",
"dependencies": {
"@opencode-ai/plugin": "beta"
"@opencode-ai/plugin": "next"
}
}
```
@@ -430,7 +430,7 @@ OpenCode provides a first-class Effect API for plugins through the
plugin package and export an `effect` function instead of `setup`:
```sh
bun add @opencode-ai/plugin@beta effect
bun add @opencode-ai/plugin@next effect
```
```ts title=".opencode/plugins/reviewer-effect.ts"
+4 -4
View File
@@ -18,19 +18,19 @@ description: "Get started with OpenCode."
<CodeGroup>
```bash npm
npm install -g @opencode-ai/cli@beta
npm install -g @opencode-ai/cli@next
```
```bash bun
bun install -g --trust @opencode-ai/cli@beta
bun install -g --trust @opencode-ai/cli@next
```
```bash pnpm
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@beta
pnpm add -g --allow-build=@opencode-ai/cli @opencode-ai/cli@next
```
```bash yarn
yarn global add @opencode-ai/cli@beta
yarn global add @opencode-ai/cli@next
```
```bash curl
+2 -2
View File
@@ -35,10 +35,10 @@ beta compatibility bug rather than an expected migration requirement.
## Install the beta
Install the beta from the `beta` distribution tag:
Install the beta from the `next` distribution tag:
```bash
npm install -g @opencode-ai/cli@beta
npm install -g @opencode-ai/cli@next
```
Start it in your project with:
@@ -119,7 +119,7 @@ Its private service configuration is stored separately at:
The database normally lives at:
```text
~/.local/share/opencode/opencode.db
~/.local/share/opencode/opencode-next.db
```
`OPENCODE_DB` can override the database location.
+1 -1
View File
@@ -3964,7 +3964,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"summary": "Interrupt session execution"
}
},
+1 -1
View File
@@ -3964,7 +3964,7 @@
}
}
},
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes pending steering input while queued work remains parked.",
"description": "Interrupt active execution owned by this OpenCode process. Idle interruption is a no-op. When continue=true, execution resumes if durable inbox work remains after interruption.",
"summary": "Interrupt session execution"
}
},
Executable
+360
View File
@@ -0,0 +1,360 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs/promises"
const model = "opencode/gpt-5.3-codex"
interface PR {
number: number
title: string
author: { login: string }
labels: Array<{ name: string }>
}
interface FailedPR {
number: number
title: string
reason: string
}
async function commentOnPR(prNumber: number, reason: string) {
const body = `⚠️ **Blocking Beta Release**
This PR cannot be merged into the beta branch due to: **${reason}**
Please resolve this issue to include this PR in the next beta release.`
try {
await $`gh pr comment ${prNumber} --body ${body}`
console.log(` Posted comment on PR #${prNumber}`)
} catch (err) {
console.log(` Failed to post comment on PR #${prNumber}: ${err}`)
}
}
async function conflicts() {
const out = await $`git diff --name-only --diff-filter=U`.text().catch(() => "")
return out
.split("\n")
.map((x) => x.trim())
.filter(Boolean)
}
async function cleanup() {
try {
await $`git merge --abort`
} catch {}
try {
await $`git checkout -- .`
} catch {}
try {
await $`git clean -fd`
} catch {}
}
function lines(prs: PR[]) {
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
}
function group(title: string) {
if (process.env.GITHUB_ACTIONS !== "true") {
console.log(title)
return { [Symbol.dispose]() {} }
}
console.log(`::group::${title}`)
return {
[Symbol.dispose]() {
console.log("::endgroup::")
},
}
}
async function typecheck() {
console.log(" Running typecheck...")
try {
await $`bun typecheck`
return true
} catch (err) {
console.log(`Typecheck failed: ${err}`)
return false
}
}
async function build() {
console.log(" Running final build smoke check...")
try {
await $`./script/build.ts --single`.cwd("packages/opencode")
return true
} catch (err) {
console.log(`Build failed: ${err}`)
return false
}
}
async function validate() {
if (!(await typecheck())) return false
if (!(await build())) return false
return true
}
async function commitSmokeChanges() {
const out = await $`git status --porcelain`.text()
if (!out.trim()) {
console.log("Smoke check passed")
return true
}
try {
await $`git add -A`
await $`git commit -m "Fix beta integration"`
} catch (err) {
console.log(`Failed to commit smoke fixes: ${err}`)
return false
}
if (!(await validate())) return false
const left = await $`git status --porcelain`.text()
if (!left.trim()) {
console.log("Smoke check passed")
return true
}
console.log(`Smoke check left uncommitted changes:\n${left}`)
return false
}
async function install() {
console.log(" Regenerating bun.lock...")
try {
await fs.rm("bun.lock", { force: true })
await $`bun install`
await $`git add bun.lock`
return true
} catch (err) {
console.log(`Install failed: ${err}`)
return false
}
}
async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
const done = lines(prs.filter((x) => applied.includes(x.number)))
const next = lines(prs.slice(idx + 1))
const prompt = [
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
`PR #${pr.number}: ${pr.title}`,
`Start with these conflicted files: ${files.join(", ")}.`,
`Merged PRs on HEAD:\n${done}`,
`Pending PRs after this one (context only):\n${next}`,
"IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
"Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
"Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
"If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
"If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
"If a PR already changed an import, keep that change.",
"After resolving the conflicts, run `bun typecheck` at the repo root.",
"If typecheck fails, you may also update any files reported by typecheck.",
"Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
"Fix any merge-caused typecheck errors before finishing.",
"Keep the merge in progress, do not abort the merge, and do not create a commit.",
"When done, leave the working tree with no unmerged files and a passing typecheck.",
].join("\n")
try {
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(` opencode failed: ${err}`)
return false
}
const left = await conflicts()
if (left.length > 0) {
console.log(` Conflicts remain: ${left.join(", ")}`)
return false
}
if (files.includes("bun.lock") && !(await install())) return false
if (!(await typecheck())) return false
console.log(" Conflicts resolved with opencode")
return true
}
async function smoke(prs: PR[], applied: number[]) {
console.log("\nRunning final smoke check...")
if (await validate()) return commitSmokeChanges()
console.log("\nTrying to fix final smoke check with opencode...")
const done = lines(prs.filter((x) => applied.includes(x.number)))
const prompt = [
"The beta merge batch is complete, but the deterministic final smoke check failed.",
`Merged PRs on HEAD:\n${done}`,
"Run `bun typecheck` at the repo root.",
"Run `./script/build.ts --single` in `packages/opencode`.",
"Fix any merge-caused issues until both commands pass.",
"Do not create a commit.",
].join("\n")
try {
await $`opencode run -m ${model} ${prompt}`
} catch (err) {
console.log(`Smoke fix failed: ${err}`)
return false
}
if (!(await validate())) return false
return commitSmokeChanges()
}
async function main() {
console.log("Fetching open PRs with beta label...")
const stdout =
await $`gh pr list --state open --draft=false --label beta --json number,title,author,labels --limit 100`.text()
const prs: PR[] = JSON.parse(stdout).sort((a: PR, b: PR) => a.number - b.number)
console.log(`Found ${prs.length} open PRs with beta label`)
if (prs.length === 0) {
console.log("No team PRs to merge")
return
}
console.log("Fetching latest dev branch...")
await $`git fetch origin dev`
console.log("Checking out beta branch...")
await $`git checkout -B beta origin/dev`
const applied: number[] = []
const failed: FailedPR[] = []
for (const [idx, pr] of prs.entries()) {
console.log()
using _ = group(`Processing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
console.log(" Fetching PR head...")
try {
await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}`
} catch (err) {
console.log(` Failed to fetch: ${err}`)
failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" })
await commentOnPR(pr.number, "Fetch failed")
continue
}
console.log(" Merging...")
try {
await $`git merge --no-commit --no-ff pr/${pr.number}`
} catch {
const files = await conflicts()
if (files.length > 0) {
console.log(" Failed to merge (conflicts)")
if (!(await fix(pr, files, prs, applied, idx))) {
await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
await commentOnPR(pr.number, "Merge conflicts with dev branch")
continue
}
} else {
console.log(" Failed to merge")
await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge failed" })
await commentOnPR(pr.number, "Merge failed")
continue
}
}
try {
await $`git rev-parse -q --verify MERGE_HEAD`.text()
} catch {
console.log(" No changes, skipping")
continue
}
try {
await $`git add -A`
} catch {
console.log(" Failed to stage changes")
failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" })
await commentOnPR(pr.number, "Failed to stage changes")
continue
}
const commitMsg = `Apply PR #${pr.number}: ${pr.title}`
try {
await $`git commit -m ${commitMsg}`
} catch (err) {
console.log(` Failed to commit: ${err}`)
failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" })
await commentOnPR(pr.number, "Failed to commit changes")
continue
}
console.log(" Applied successfully")
applied.push(pr.number)
}
console.log("\n--- Summary ---")
console.log(`Applied: ${applied.length} PRs`)
applied.forEach((num) => console.log(` - PR #${num}`))
if (failed.length > 0) {
console.log(`Failed: ${failed.length} PRs`)
failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`))
throw new Error(`${failed.length} PR(s) failed to merge`)
}
console.log("\nChecking if beta branch has changes...")
await $`git fetch origin beta`
const localTree = (await $`git rev-parse beta^{tree}`.text()).trim()
const remoteTrees = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
const matchIdx = remoteTrees.indexOf(localTree)
if (matchIdx !== -1) {
if (matchIdx !== 0) {
console.log(`Beta branch contains this sync, but additional commits exist after it. Leaving beta branch as is.`)
} else {
console.log("Beta branch has identical contents, no push needed")
}
return
}
if (!(await smoke(prs, applied))) throw new Error("Final smoke check failed")
await $`git fetch origin beta`
const validatedTree = (await $`git rev-parse beta^{tree}`.text()).trim()
const remoteTreesAfterSmoke = (await $`git log origin/dev..origin/beta --format=%T`.text()).split("\n")
const matchIdxAfterSmoke = remoteTreesAfterSmoke.indexOf(validatedTree)
if (matchIdxAfterSmoke !== -1) {
if (matchIdxAfterSmoke !== 0) {
console.log(
`Beta branch contains this validated sync, but additional commits exist after it. Leaving beta branch as is.`,
)
} else {
console.log("Validated beta branch now matches remote contents, no push needed")
}
return
}
console.log("Force pushing validated beta branch...")
await $`git push origin beta --force --no-verify`
console.log("Successfully synced beta branch")
}
main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})
+24 -22
View File
@@ -35,38 +35,40 @@ if (Script.release && !Script.preview) {
await prepareReleaseFiles()
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
if (Script.channel !== "beta") {
console.log("\n=== schema ===\n")
await $`bun ./packages/schema/script/publish.ts`
console.log("\n=== codemode ===\n")
await $`bun ./packages/codemode/script/publish.ts`
console.log("\n=== codemode ===\n")
await $`bun ./packages/codemode/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== theme ===\n")
await $`bun ./packages/theme/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== ai ===\n")
await $`bun ./packages/ai/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== util ===\n")
await $`bun ./packages/util/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== protocol ===\n")
await $`bun ./packages/protocol/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== client ===\n")
await $`bun ./packages/client/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== cli ===\n")
await $`bun ./packages/cli/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== plugin ===\n")
await $`bun ./packages/plugin/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`
console.log("\n=== core ===\n")
await $`bun ./packages/core/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
console.log("\n=== ui ===\n")
await $`bun ./packages/ui/script/publish.ts`
}
if (Script.release) {
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`