Compare commits

...
Author SHA1 Message Date
LukeParkerDev 5c2eaade2c perf(desktop): remember the bundled CLI version between launches
Every launch spawned the bundled 200 MB CLI executable for --version and
waited ~380 ms for it before the background service, WSL support and,
because windows are created after every layer builds, the window itself.
The version only changes when an update replaces the executable, so keep
it in the settings store keyed by the executable's path, size and mtime
and only spawn when that identity changes.

Packaged build, warm service, 5 launches (median ms since spawn):
renderer process 851 -> 512, window visible 1085 -> 778,
shell visible 1289 -> 961.
2026-09-18 22:41:37 +10:00
Shoubhit Dash db80806651 refactor(core): move native compaction mechanisms into a plugin (#49575) 2026-09-18 17:15:44 +05:30
Luke Parker 47f66de8dd perf(app): load draft image bytes on demand (#49703) 2026-09-18 19:32:15 +10:00
opencode c076066c33 sync release versions for v2.0.8 2026-09-18 09:12:33 +00:00
opencode-agent[bot] 5c09d051e5 chore: update nix node_modules hashes 2026-09-18 08:45:27 +00:00
Luke Parker 0a6111291e refactor(desktop): send IPC messages by structured clone (#49701) 2026-09-18 18:28:20 +10:00
Luke Parker 609044ef0a feat(app): scan pairing QR code from add server dialog (#49699) 2026-09-18 17:36:30 +10:00
Luke Parker 4f32bfca57 fix(desktop): copy IPC payloads out of the shared msgpack buffer (#49696) 2026-09-18 17:14:29 +10:00
Luke Parker 90112f52db fix(app): deliver text and oversized attachments by path (#49682) 2026-09-18 06:30:57 +00:00
Luke Parker d4303a9ca5 feat(app): show provider metrics in debug bar (#49674) 2026-09-18 15:51:08 +10:00
Kit Langton 735556eab8 fix(tui): open completed subagent sessions (#49675) 2026-09-18 05:42:40 +00:00
Kit Langton 10cac9ab5d fix(tui): show update installation progress (#49676) 2026-09-17 22:33:43 -07:00
Kit Langton 2d0ce64111 fix(client): reconcile stale tools at settlement (#49672) 2026-09-17 22:23:45 -07:00
Luke Parker 3355c93efd fix(app): create local sessions in project root (#49668) 2026-09-18 14:12:18 +10:00
Luke Parker 469e1c035e feat(app): stream large attachments to the server with progress (#49647) 2026-09-18 14:05:30 +10:00
opencode-agent[bot]andrekram1-node b2e3569add test(codemode): isolate OpenAPI fixtures (#49667)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 23:01:36 -05:00
James Long 0ac458b3b3 refactor(theme): rename surface tokens to raised (#49655) 2026-09-17 22:05:42 -04:00
Brendan DukeandAiden Cline b278ef6b82 fix(tui): include reasoning tokens in throughput (#48689)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-09-17 20:12:06 -05:00
Dax 3e3a4ae46b feat(tui): add /btw side question command (#49646) 2026-09-17 21:03:09 -04:00
Dax Raad fe0d9579a7 fix(tui): preserve block tool spacing 2026-09-17 20:18:52 -04:00
Aiden Cline a2594ddefb fix(tui): sync model preferences across clients (#49611) 2026-09-17 14:54:34 -05:00
Aiden Cline 3555cef1c8 chore(client): regenerate policy types (#49613) 2026-09-17 14:38:09 -05:00
opencode 0fa881efd0 sync release versions for v2.0.7 2026-09-17 19:29:59 +00:00
opencode-agent[bot]andrekram1-node 37ff501cc6 fix(cli): keep updates client-owned (#49577)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-17 12:47:49 -04:00
Dax fa126d68e8 feat(core): enforce permission policies 2026-09-17 12:39:20 -04:00
opencode-agent[bot]andJames Long a0571a8b14 fix(tui): keep upgrade as a search keyword (#49567)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-09-17 11:32:16 -04:00
Dax Raad cae475df37 sync 2026-09-17 10:47:52 -04:00
Shoubhit Dash 19781a4447 fix(acp): propagate request cancellation and close sessions cleanly (#49563) 2026-09-17 20:16:07 +05:30
Brendan Allan e32de90d88 fix(app): prevent settings project cache reload loop (#49564) 2026-09-17 14:45:03 +00:00
Kit Langton 0ee5ef6c43 fix(tui): keep spinner glyph from shrinking beside long labels (#49395) 2026-09-17 07:43:07 -07:00
a72f988c90 fix(tui): switch vertical tabs sooner (#49440)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-17 09:43:02 -05:00
Dax Raad 2a132d6f50 fix(tui): include provider latency in TPS 2026-09-17 10:40:12 -04:00
Dax Raad 23418b3885 docs: reorder sidebar navigation 2026-09-17 10:20:57 -04:00
Dax Raad b4ac938c3d docs: improve installation guide 2026-09-17 10:10:20 -04:00
Dax Raad 4b25739e93 docs(cli): drop command-name leads in command descriptions 2026-09-17 09:44:31 -04:00
Dax Raad 2f97e1324f fix(www): restore OpenTUI Mono docs font 2026-09-17 09:31:40 -04:00
Dax Raad 0018f08832 feat(cli): add reload command 2026-09-17 09:21:41 -04:00
Dax Raad b52f241294 feat(location): reload configuration 2026-09-17 09:18:51 -04:00
Dax Raad 254ace8594 docs: add CLI settings reference 2026-09-17 09:17:14 -04:00
usrnk1 657f700f5d feat(desktop): disable security code autofill (#49531) 2026-09-17 20:35:14 +08:00
Victor Navarro 7e1d28b549 feat(cli): support custom Console logins (#49542) 2026-09-17 13:50:32 +02:00
opencode 040d2ffb48 sync release versions for v2.0.6 2026-09-17 11:17:33 +00:00
222 changed files with 4179 additions and 1364 deletions
+1
View File
@@ -184,6 +184,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
+35 -36
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.8",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -456,12 +456,11 @@
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
"msgpackr-extract": "3.0.4",
},
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +497,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +513,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +532,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +545,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +559,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +574,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +613,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +643,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +658,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +682,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +703,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +725,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +760,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +780,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +814,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +833,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +879,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +893,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +928,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +963,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +996,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1037,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
"x86_64-linux": "sha256-TlCPtuIvTNF01fLI0yHCLzVkx53yF9xxvAObDMGeo8M=",
"aarch64-linux": "sha256-aEjyPIXHeA5w1iZXbPKJAOIJQBfXg+LAx/y7C5uFz4g=",
"aarch64-darwin": "sha256-lthMTio2qNy+H2tL9cAPmqC41O+wdSADI61cnW4dIb0=",
"x86_64-darwin": "sha256-ONFbRQnwzOYHo0Pyh327QfJG2uzqzNEn/SS2vbYYMdM="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.5",
"version": "2.0.8",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.8",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -485,6 +485,7 @@ export function stepStarted(message: SessionMessageAssistant) {
assistantMessageID: message.id,
agent: message.agent,
model: message.model,
started: message.time.created,
})
}
@@ -108,6 +108,15 @@ test("non-Git folders show their status without offering worktree actions", asyn
).toBeEnabled()
})
test("submits locally after changing a new worktree draft to Local", async ({ page }) => {
const mock = await openDraft(page, "create", { currentDirectory: workspace })
await page.getByRole("button", { name: "New worktree", exact: true }).click()
await page.getByRole("menuitem", { name: "Local repository", exact: true }).click()
await page.locator('[data-component="composer-editor"]').fill("Run locally")
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.calls.find((call) => call.type === "session")?.directory).toBe(directory)
})
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
const mock = await openDraft(page, "create")
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
@@ -296,7 +305,12 @@ test("new worktree sign-in completes before the draft can send", async ({ page,
expect(attempts).toHaveLength(1)
})
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
async function openDraft(
page: Page,
worktree = "main",
options: { git?: boolean; direction?: "ltr" | "rtl"; currentDirectory?: string } = {},
) {
const currentDirectory = options.currentDirectory ?? directory
const project = {
id: "proj_new_summary",
worktree: directory,
@@ -315,7 +329,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
await mockOpenCodeServer(page, {
directory,
directory: currentDirectory,
project,
sessions,
provider: {
@@ -442,7 +456,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
},
)
await page.addInitScript(
({ directory, server, draftID, secondDraftID, worktree }) => {
({ directory, currentDirectory, server, draftID, secondDraftID, worktree }) => {
if (!localStorage.getItem("opencode.global.dat:server"))
localStorage.setItem(
"opencode.global.dat:server",
@@ -455,12 +469,12 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
{ type: "draft", draftID, server, directory: currentDirectory, worktree },
{ type: "draft", draftID: secondDraftID, server, directory: currentDirectory, worktree },
]),
)
},
{ directory, server, draftID, secondDraftID, worktree },
{ directory, currentDirectory, server, draftID, secondDraftID, worktree },
)
if (options.direction) await openWithDirection(page, draftPath, options.direction)
if (!options.direction) await page.goto(draftPath)
@@ -284,7 +284,7 @@ for (const delivery of ["steer", "queue"] as const) {
await expect(thinking).toHaveCount(0)
// The next assistant step still belongs to U1: U2 has been admitted, not delivered.
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model })
mock.emit("session.step.started", { sessionID, assistantMessageID: assistantID, agent: "build", model, started: Date.now() })
for (const tool of [
{ id: "tool_queue_read", name: "read", input: { path: "src/queue.ts" } },
{ id: "tool_queue_grep", name: "grep", input: { pattern: "retry", path: "src" } },
@@ -341,7 +341,7 @@ for (const delivery of ["steer", "queue"] as const) {
)
const later = { sessionID, assistantMessageID: "msg_queue_follow_up_assistant" }
mock.emit("session.step.started", { ...later, agent: "build", model })
mock.emit("session.step.started", { ...later, agent: "build", model, started: Date.now() })
mock.emit("session.text.started", { ...later, ordinal: 0 })
mock.emit("session.text.ended", { ...later, ordinal: 0, text: "A3: Now checking the retry path for U2." })
const response = transcript
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.8",
"description": "",
"type": "module",
"exports": {
@@ -16,6 +16,86 @@
}
}
[data-component="upload-row"] {
display: flex;
flex-direction: column;
gap: 4px;
& + & {
margin-top: 8px;
}
[data-slot="upload-row-label"] {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
font-size: 13px;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
}
[data-slot="upload-row-name"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--v2-text-text-base);
font-weight: 530;
}
[data-slot="upload-row-percent"] {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-variant-numeric: tabular-nums;
}
[data-slot="upload-row-track"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-component="upload-progress"] {
flex: 1;
height: 4px;
overflow: hidden;
border-radius: 999px;
background: var(--v2-background-bg-layer-02);
}
[data-slot="upload-progress-bar"] {
height: 100%;
border-radius: inherit;
background: var(--v2-icon-icon-base);
transition: width 160ms ease-out;
}
[data-slot="upload-row-cancel"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
svg {
width: 12px;
height: 12px;
}
}
}
[data-component="composer-attachments"] {
timeline-scope: --composer-attachments-scroll;
@@ -1,8 +1,12 @@
import { onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createBlobReference } from "@/runtime/persistence/drafts"
import { uuid } from "@/runtime/persistence/uuid"
import type { ComposerAttachment, ComposerPrompt } from "../types"
import type { ComposerPrompt } from "../types"
import type { ImageAttachmentPart, PathAttachmentPart } from "../state"
import type { AttachmentDestination } from "./destination"
import { uploads } from "./uploads"
type PromptTarget = {
current: () => ComposerPrompt
@@ -16,9 +20,11 @@ export type ComposerAttachmentConfig = {
onFile: (file: File) => Promise<unknown>,
) => Promise<void>
directory: () => string
destination: () => AttachmentDestination
isDialogActive: () => boolean
duplicate: () => void
onError: (error: unknown) => void
onUploadError: (error: unknown) => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
onDragCancel?: (callback: () => void) => () => void
@@ -43,9 +49,23 @@ export function createComposerAttachments(
if (!editor) return
return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) }
}
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
// store. Everything else, including text, reaches the model as a path on the server that its
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
const add = async (file: File, target = capture(), clipboard = false) => {
if (!target) return false
const mime = await attachmentMime(file)
const destination = input.destination()
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
const sourcePath = input.getPathForFile?.(file) || undefined
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
void stage(file, mime, target, destination)
return true
}
const addInline = async (file: File, mime: string, target: NonNullable<ReturnType<typeof capture>>, clipboard: boolean) => {
const blob = input.store ? await input.store(file) : await createBlobReference(file)
const sourcePath = input.getPathForFile?.(file) || undefined
// Native clipboard images arrive with a fresh timestamped filename on every paste, so identical
@@ -64,17 +84,40 @@ export function createComposerAttachments(
input.duplicate()
return true
}
const attachment: ComposerAttachment = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath,
mime,
blob,
}
const attachment: ImageAttachmentPart = { type: "image", id: uuid(), filename: file.name, sourcePath, mime, blob }
target.prompt.set([...target.prompt.current(), attachment], target.cursor)
return true
}
const addPath = (
target: NonNullable<ReturnType<typeof capture>>,
attachment: Pick<PathAttachmentPart, "filename" | "mime" | "path">,
) => {
if (target.prompt.current().some((part) => part.type === "path" && part.path === attachment.path)) {
input.duplicate()
return true
}
target.prompt.set([...target.prompt.current(), { type: "path", id: uuid(), ...attachment }], target.prompt.cursor())
return true
}
const stage = async (
file: File,
mime: string,
target: NonNullable<ReturnType<typeof capture>>,
destination: AttachmentDestination,
) => {
const id = uuid()
setPending("ids", (ids) => [...ids, id])
const path = await uploads
.track({ id, filename: file.name, mime, size: file.size }, (report, signal) =>
destination.upload(file, report, signal),
)
.catch((error: unknown) => {
input.onUploadError(error)
return undefined
})
.finally(() => setPending("ids", (ids) => ids.filter((item) => item !== id)))
if (path) addPath(target, { filename: file.name, mime, path })
}
const addAttachments = async (files: File[], target = capture()) => {
return files.reduce(async (result, file) => {
const previous = await result
@@ -153,6 +196,11 @@ export function createComposerAttachments(
addAttachments,
handlePaste,
handleDrop,
/** Uploads still in flight for this composer; sending waits for them. */
pending: () => uploads.items().filter((item) => pending.ids.includes(item.id)),
cancel(id: string) {
uploads.items().find((item) => item.id === id)?.cancel()
},
pick(fallback: () => void) {
if (!input.picker) {
fallback()
@@ -165,6 +213,16 @@ export function createComposerAttachments(
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// The server rejects inline attachments above this size, so larger media takes the path route.
const MAX_INLINE_BYTES = 20 * 1024 * 1024
// Mirrors the media the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
const imageExtensions = new Map([
["gif", "image/gif"],
["jpeg", "image/jpeg"],
@@ -182,8 +240,8 @@ const textMimes = new Set([
"application/yaml",
])
// Text-like files normalize to text/plain so the server inlines their content; every other
// file keeps a binary type and is delivered to the model by path or as native media.
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
// a binary type. Delivery is decided separately: native media inline, everything else by path.
async function attachmentMime(file: File) {
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
if (imageMimes.has(type) || type === "application/pdf") return type
@@ -1,70 +0,0 @@
import type { Accessor } from "solid-js"
import { blobBytes, blobDataUrl } from "@/runtime/persistence/drafts"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
import type { ImageAttachmentPart } from "../state"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Copies a file into the server's temporary directory and returns its absolute path there. */
upload: (file: { name: string; data: Uint8Array }) => Promise<string>
}
export type DeliveredAttachment =
| { type: "inline"; attachment: ImageAttachmentPart; dataUrl: string }
| { type: "path"; attachment: ImageAttachmentPart; path: string }
// An attachment travels inline when the model reads its bytes natively. Anything else reaches
// the model as a path on the server, which its tools can open, instead of being rejected.
export function deliverAttachments(attachments: ImageAttachmentPart[], destination: AttachmentDestination) {
return Promise.all(attachments.map((attachment) => deliver(attachment, destination)))
}
async function deliver(
attachment: ImageAttachmentPart,
destination: AttachmentDestination,
): Promise<DeliveredAttachment> {
if (native(attachment.mime, destination.input)) {
return { type: "inline", attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) }
}
if (destination.local && attachment.sourcePath) return { type: "path", attachment, path: attachment.sourcePath }
const path = await destination.upload({ name: attachment.filename, data: await blobBytes(attachment.blob) })
return { type: "path", attachment, path }
}
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
// Mirrors the attachment kinds the server forwards to the model as message content.
function native(mime: string, input: AttachmentDestination["input"]) {
if (mime === "text/plain") return true
if (imageMimes.has(mime)) return input.image
if (mime === "application/pdf") return input.pdf
return false
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file) => {
const info = await sdk.api.server.info()
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const written = await sdk.api.file.write({
location: { directory: location().directory },
path: `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`,
payload: file.data,
})
return written.data.path
},
})
}
@@ -0,0 +1,57 @@
import type { Accessor } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { authTokenFromCredentials } from "@/runtime/server/api"
import { useWorkspaceLocation } from "@/workspaces/location"
import type { ComposerControls } from "../adapter"
// Where a prompt is headed: the model that reads it and the server that runs its tools.
export type AttachmentDestination = {
/** Input modalities the selected model reads natively. */
input: { image: boolean; pdf: boolean }
/** The server shares the client's filesystem, so an attachment's source path resolves as-is. */
local: boolean
/** Streams a file into the server's temporary directory and returns its absolute path there. */
upload: (file: File, report: (loaded: number) => void, signal: AbortSignal) => Promise<string>
}
export function useAttachmentDestination(controls: Accessor<ComposerControls>) {
const server = useServer()
const sdk = useServerSDK()
const location = useWorkspaceLocation()
return (): AttachmentDestination => ({
input: controls().model.selection.current()?.capabilities.input ?? { image: false, pdf: false },
local: server.isLocal,
upload: async (file, report, signal) => {
const info = await sdk.api.server.info({ signal })
// One directory per upload keeps the original filename without collisions; the server
// normalizes the separators and returns the resolved path.
const url = new URL("/api/experimental/fs/write", server.conn.http.url)
url.searchParams.set("location[directory]", location().directory)
url.searchParams.set("path", `${info.paths.tmp}/uploads/${crypto.randomUUID()}/${file.name}`)
return write(url, file, server.conn.http.password, report, signal)
},
})
}
// fetch cannot report upload progress and Chromium only streams request bodies over HTTP/2, so
// the one request that needs both goes through XMLHttpRequest. The browser streams the File
// from disk; nothing is buffered in the renderer.
function write(url: URL, file: File, password: string | undefined, report: (loaded: number) => void, signal: AbortSignal) {
return new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open("POST", url)
xhr.responseType = "json"
xhr.setRequestHeader("content-type", "application/octet-stream")
if (password) xhr.setRequestHeader("authorization", `Basic ${authTokenFromCredentials({ password })}`)
xhr.upload.addEventListener("progress", (event) => report(event.loaded))
xhr.addEventListener("load", () => {
if (xhr.status !== 200) return reject(new Error(`Upload failed with status ${xhr.status}`))
resolve((xhr.response as { data: { path: string } }).data.path)
})
xhr.addEventListener("error", () => reject(new Error("Upload failed")))
xhr.addEventListener("abort", () => reject(new DOMException("Upload aborted", "AbortError")))
signal.addEventListener("abort", () => xhr.abort(), { once: true })
xhr.send(file)
})
}
@@ -0,0 +1,116 @@
import { createEffect, createRoot, For, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { Icon } from "@opencode/ui/icon"
import { Toast, toaster } from "@opencode/ui/toast"
import { useLanguage } from "@/runtime/i18n/language"
export type Upload = {
id: string
filename: string
mime: string
size: number
loaded: number
cancel: () => void
}
// Uploads outlive the composer that started them, so one process-wide list feeds every chip
// and the single progress toast.
const [state, setState] = createStore<{ items: Upload[] }>({ items: [] })
export const uploads = {
items: () => state.items,
/** Runs `work` while the upload is listed. Resolves to undefined when the user cancels it. */
async track<T>(
input: Pick<Upload, "id" | "filename" | "mime" | "size">,
work: (report: (loaded: number) => void, signal: AbortSignal) => Promise<T>,
): Promise<T | undefined> {
const controller = new AbortController()
setState("items", (items) => [...items, { ...input, loaded: 0, cancel: () => controller.abort() }])
try {
return await work((loaded) => setState("items", (item) => item.id === input.id, "loaded", loaded), controller.signal)
} catch (error) {
if (controller.signal.aborted) return undefined
throw error
} finally {
setState("items", (items) => items.filter((item) => item.id !== input.id))
}
},
}
// Sonner builds toast content outside the app's Solid tree: no context and no owner. This host
// lives inside the providers, lends the toast its language instance, and gives the content a
// root of its own so progress stays reactive.
export function UploadToastHost() {
const language = useLanguage()
let active: { id: number; dispose: () => void } | undefined
const dismiss = () => {
if (!active) return
toaster.dismiss(active.id)
active.dispose()
active = undefined
}
createEffect(
on(
() => state.items.length > 0,
(uploading) => {
if (!uploading) return dismiss()
if (active) return
const id = toaster.show(
(props) =>
createRoot((dispose) => {
active = { id: props.toastId, dispose }
return <UploadToast toastId={props.toastId} language={language} />
}),
{ persistent: true, resize: () => state.items.length },
)
active ??= { id, dispose: () => {} }
},
),
)
onCleanup(dismiss)
return null
}
function UploadToast(props: { toastId: number; language: ReturnType<typeof useLanguage> }) {
const percent = (item: Upload) => (item.size === 0 ? 100 : Math.floor((item.loaded / item.size) * 100))
return (
<Toast toastId={props.toastId}>
<Toast.Content>
<For each={state.items}>
{(item) => (
<div data-component="upload-row">
<div data-slot="upload-row-label">
<span data-slot="upload-row-name" title={item.filename}>
{item.filename}
</span>
<span data-slot="upload-row-percent">
{props.language.t("prompt.toast.uploading.percent", { percent: percent(item) })}
</span>
</div>
<div data-slot="upload-row-track">
<div
data-component="upload-progress"
role="progressbar"
aria-label={item.filename}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent(item)}
>
<div data-slot="upload-progress-bar" style={{ width: `${percent(item)}%` }} />
</div>
<button
type="button"
data-slot="upload-row-cancel"
aria-label={props.language.t("prompt.toast.uploading.cancel")}
onClick={() => item.cancel()}
>
<Icon name="outline-xmark" />
</button>
</div>
</div>
)}
</For>
</Toast.Content>
</Toast>
)
}
@@ -167,7 +167,7 @@ function ComposerStory(props: {
? buildPromptRequest({
prompt: draft.prompt,
context: draft.context.items,
attachments: [],
images: [],
text: value,
sessionDirectory: "C:/repo",
})
+6 -6
View File
@@ -7,7 +7,7 @@ import type {
ComposerPersistedState,
ComposerPrompt,
} from "../types"
import { promptLength } from "../prompt-parts"
import { isAttachment, promptLength } from "../prompt-parts"
export type ComposerStateStore = [
Store<ComposerPersistedState> | Accessor<Store<ComposerPersistedState>>,
@@ -48,7 +48,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
setStore()((state) => ({
prompt: [
{ type: "text", content, start: 0, end: content.length },
...state.prompt.filter((part) => part.type === "image"),
...state.prompt.filter(isAttachment),
],
cursor: content.length,
retry: undefined,
@@ -83,7 +83,7 @@ export function createComposerEditorActions(input: ComposerStateStoreInput) {
clearRetry()
},
removeAttachment(id: string) {
setStore()("prompt", (parts) => parts.filter((part) => part.type !== "image" || part.id !== id))
setStore()("prompt", (parts) => parts.filter((part) => !isAttachment(part) || part.id !== id))
clearRetry()
},
}
@@ -93,7 +93,7 @@ function insertText(prompt: ComposerPrompt, cursor: number, content: string): Co
let position = 0
let inserted = false
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
if (isAttachment(part)) return [part]
const start = position
position += part.content.length
if (inserted) return [part]
@@ -121,7 +121,7 @@ function insertMention(
}
let position = 0
const parts = prompt.flatMap<ComposerPrompt[number]>((part) => {
if (part.type === "image") return [part]
if (isAttachment(part)) return [part]
const partStart = position
position += part.content.length
if (part.type !== "text" || start < partStart || end > position) return [part]
@@ -139,7 +139,7 @@ function insertMention(
function withOffsets(prompt: ComposerPrompt): ComposerPrompt {
let offset = 0
return prompt.map((part) => {
if (part.type === "image") return part
if (isAttachment(part)) return part
const next = { ...part, start: offset, end: offset + part.content.length }
offset = next.end
return next
+59 -14
View File
@@ -1,9 +1,10 @@
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { ProviderIcon } from "@opencode/ui/provider-icon"
import { useI18n } from "@opencode/ui/context/i18n"
import { Button } from "@opencode/ui/button"
@@ -12,6 +13,8 @@ import { Menu } from "@opencode/ui/menu"
import { Tooltip } from "@opencode/ui/tooltip"
import { ScrollView } from "@opencode/ui/scroll-view"
import { AttachmentCard } from "@opencode/session-ui/attachment-card"
import { ProgressCircle } from "@opencode/ui/progress-circle"
import type { Upload } from "../attachments/uploads"
import { CommentCard } from "@opencode/session-ui/comment-card"
import { typeLabel } from "@opencode/session-ui/message-file"
import { Skill } from "@opencode/schema/skill"
@@ -24,6 +27,7 @@ import type {
ComposerSuggestion,
} from "../types"
import type { ComposerEditorModel, ComposerSelectControl } from "./interaction"
import { isAttachment } from "../prompt-parts"
import "../attachments/attachments.css"
import "./editor.css"
@@ -148,11 +152,13 @@ export function ComposerEditor(props: ComposerEditorProps) {
<Show when={state.mode === "normal"}>
<ComposerAttachments
attachments={props.controller.attachments()}
uploads={props.controller.uploads()}
comments={props.controller.comments()}
activeCommentID={state.activeContextID}
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
onAttachmentClick={props.controller.openAttachment}
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)}
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
onCommentRemove={(comment) => props.controller.removeContext(comment.key)}
/>
@@ -191,9 +197,9 @@ export function ComposerEditor(props: ComposerEditorProps) {
onInput={(event) => {
const cursor = composerCursor(event.currentTarget)
const prompt = parseComposerEditor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
const attachments = props.controller.parts().filter(isAttachment)
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor)
}}
onKeyDown={(event) => {
if (!view.draftOnly && props.controller.onKeyDown(event)) return
@@ -348,7 +354,7 @@ function renderComposerEditor(editor: HTMLDivElement, prompt: ComposerPrompt) {
const active = document.activeElement === editor
editor.replaceChildren(
...prompt.flatMap<Node>((part) => {
if (part.type === "image") return []
if (isAttachment(part)) return []
if (part.type === "text") return [document.createTextNode(part.content)]
const mention = document.createElement("span")
mentionParts.set(mention, part)
@@ -475,17 +481,22 @@ function composerCursor(editor: HTMLDivElement) {
export function ComposerAttachments(props: {
attachments: ComposerAttachment[]
uploads?: Upload[]
comments?: ComposerComment[]
activeCommentID?: string
removeLabel: string
onAttachmentClick?: (attachment: ComposerAttachment) => void
onAttachmentRemove: (attachment: ComposerAttachment) => void
onUploadCancel?: (upload: Upload) => void
onCommentClick?: (comment: ComposerComment) => void
onCommentRemove?: (comment: ComposerComment) => void
}) {
const i18n = useI18n()
const percent = (upload: Upload) => (upload.size === 0 ? 100 : Math.floor((upload.loaded / upload.size) * 100))
return (
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
<Show
when={props.attachments.length > 0 || (props.uploads?.length ?? 0) > 0 || (props.comments?.length ?? 0) > 0}
>
<div data-component="composer-attachments" data-slot="composer-attachments" class="relative">
<div
data-slot="composer-attachments-scroll"
@@ -522,22 +533,34 @@ export function ComposerAttachments(props: {
<For each={props.attachments}>
{(attachment) => (
<div class="relative group shrink-0">
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
<Tooltip
value={attachment.type === "path" ? attachment.path : attachment.filename}
placement="top"
contentClass="break-all"
>
<Show
when={attachment.mime.startsWith("image/")}
when={attachment.type === "image" && attachment.mime.startsWith("image/") ? attachment : undefined}
fallback={
<AttachmentCard title={attachment.filename}>
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
</AttachmentCard>
}
>
<img
src={attachment.blob.url}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
{(image) => {
// Restored drafts and history carry image ids only; bytes load when shown.
const [url] = createResource(() => image().blob, resolveBlobUrl)
return (
<>
<img
src={url() ?? ""}
alt={attachment.filename}
class="w-[58px] h-[46px] rounded-[6px] object-cover"
onClick={() => props.onAttachmentClick?.(attachment)}
/>
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
</>
)
}}
</Show>
</Tooltip>
<button
@@ -551,6 +574,28 @@ export function ComposerAttachments(props: {
</div>
)}
</For>
<For each={props.uploads ?? []}>
{(upload) => (
<div class="relative group shrink-0" data-slot="composer-upload">
<Tooltip value={upload.filename} placement="top" contentClass="break-all">
<AttachmentCard title={upload.filename}>
<span class="inline-flex items-center gap-1">
<ProgressCircle percentage={percent(upload)} />
{i18n.t("ui.promptInput.uploading", { percent: percent(upload) })}
</span>
</AttachmentCard>
</Tooltip>
<button
type="button"
onClick={() => props.onUploadCancel?.(upload)}
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={i18n.t("ui.promptInput.cancelUpload")}
>
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
</button>
</div>
)}
</For>
</div>
<div
data-slot="composer-attachments-fade-left"
@@ -2,6 +2,7 @@ import { createEffect, type Accessor } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useFilteredList } from "@opencode/ui/hooks"
import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments"
import type { Upload } from "../attachments/uploads"
import { createComposerEditorActions, type ComposerStateStoreInput } from "./actions"
import type {
ComposerAttachment,
@@ -18,7 +19,7 @@ import {
type ComposerInteractionCommand,
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, promptLength } from "../prompt-parts"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
import type { ComposerQueue } from "../adapter"
export type ComposerSelectControl = {
@@ -74,7 +75,7 @@ export function createComposerEditor(input: {
const draft = createComposerEditorActions(input.store)
const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode)
function addPart(part: ComposerPersistedState["prompt"][number]) {
if (part.type === "image") return false
if (isAttachment(part)) return false
if (part.type === "file" || part.type === "agent") {
draft.addMention(part)
return true
@@ -169,7 +170,7 @@ export function createComposerEditor(input: {
if (!action || state.popover.type !== "command-menu") result.commands.forEach(execute)
if (action && event.item.kind === "command" && state.popover.type !== "command-menu") {
draft.setPrompt(
draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image"),
draft.state.prompt.filter(isAttachment),
0,
)
}
@@ -315,7 +316,13 @@ export function createComposerEditor(input: {
return draft.state.context.items.filter((item) => !!item.comment?.trim())
},
attachments(): ComposerAttachment[] {
return draft.state.prompt.filter((part): part is ComposerAttachment => part.type === "image")
return draft.state.prompt.filter(isAttachment)
},
uploads(): Upload[] {
return attachments?.pending() ?? []
},
cancelUpload(id: string) {
attachments?.cancel(id)
},
toggleContext(id: string) {
dispatch({ type: "context.active", id })
@@ -336,11 +343,12 @@ export function createComposerEditor(input: {
canSubmit() {
if (input.view.submit.available?.() === false) return false
if (input.view.draftOnly) return false
if (attachments?.pending().length) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
}
if (persisted.prompt.some((part) => part.type === "image")) return true
if (persisted.prompt.some(isAttachment)) return true
if (persisted.context.items.some((item) => !!item.comment?.trim())) return true
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
},
@@ -369,6 +377,7 @@ export function createComposerEditor(input: {
submit(options?: { alternate?: boolean }) {
if (input.view.submit.available?.() === false) return
if (input.view.draftOnly) return
if (attachments?.pending().length) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},
+4 -4
View File
@@ -1,6 +1,6 @@
import type { Prompt } from "@/composer/state"
import type { SelectedLineRange } from "@/workspaces/files/model"
import { clonePrompt } from "../prompt-parts"
import { clonePrompt, isAttachment } from "../prompt-parts"
import type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
export type { PromptHistoryComment, PromptHistoryEntry } from "../schema"
@@ -35,9 +35,9 @@ export function prependHistoryEntry(
.map((part) => ("content" in part ? part.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
const hasAttachments = prompt.some(isAttachment)
const hasComments = comments.some((comment) => !!comment.comment.trim())
if (!text && !hasImages && !hasComments) return entries
if (!text && !hasAttachments && !hasComments) return entries
const entry = {
prompt: clonePrompt(prompt),
@@ -86,7 +86,7 @@ function isPromptEqual(entryA: PromptHistoryStoredEntry, entryB: PromptHistorySt
if (partA.type === "skill") {
if (partB.type !== "skill" || partA.id !== partB.id || partA.name !== partB.name) return false
}
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
if (isAttachment(partA) && partA.id !== (isAttachment(partB) ? partB.id : "")) return false
}
if (entryA.comments.length !== entryB.comments.length) return false
for (let i = 0; i < entryA.comments.length; i++) {
+17 -6
View File
@@ -11,18 +11,19 @@ import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { usePlatform } from "@/runtime/platform/platform"
import { useWorkspaceLocation } from "@/workspaces/location"
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
import { useData, useServer } from "@/runtime/server/current"
import { createSessionTabs } from "@/session/helpers"
import { showToast } from "@/shell/notifications/toast"
import { formatServerError } from "@/runtime/server/errors"
import { Skill } from "@opencode/schema/skill"
import type { ComposerAdapter, ComposerControls, ComposerQueue } from "./adapter"
import type { ImageAttachmentPart } from "./state"
import { isAttachment } from "./prompt-parts"
import type { PromptHistoryComment } from "./history/entry"
import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/deliver"
import { useAttachmentDestination } from "./attachments/destination"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -73,7 +74,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
}, [])
})
const attachments = createMemo(() =>
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
prompt.current().filter(isAttachment),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
@@ -266,7 +267,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
resetHistory: () => controller.resetHistory(),
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
closePopover: () => controller.dispatch({ type: "popover.close" }),
destination: useAttachmentDestination(adapter.controls),
delivery: (alternate) => {
const queue = options?.queue
if (!queue) return "steer"
@@ -321,8 +321,12 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
onContextRemove(item) {
if (item?.commentID) comments.remove(item.path, item.commentID)
},
openAttachment: (attachment) =>
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename })),
openAttachment: (attachment) => {
if (attachment.type !== "image") return
void resolveBlobUrl(attachment.blob).then((src) => {
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
})
},
openContext(key) {
const item = controller.contextItem(key)
if (item) openComment(item, adapter.controls(), layout, files, comments)
@@ -340,8 +344,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
attachments: {
picker: platform.openAttachmentPickerDialog,
directory: () => sdk().directory,
destination: useAttachmentDestination(adapter.controls),
isDialogActive: () => !!dialog.active,
duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }),
onUploadError: (error) =>
showToast({
variant: "error",
title: language.t("prompt.toast.uploadFailed.title"),
description: composerErrorMessage(language, error),
}),
onError: (error) =>
showToast({
variant: "error",
+7 -2
View File
@@ -1,4 +1,9 @@
import type { Prompt } from "./state"
import type { ContentPart, ImageAttachmentPart, PathAttachmentPart, Prompt } from "./state"
/** Parts that sit beside the text rather than inside it. */
export function isAttachment(part: ContentPart): part is ImageAttachmentPart | PathAttachmentPart {
return part.type === "image" || part.type === "path"
}
export function clonePrompt(prompt: Prompt): Prompt {
return prompt.map((part) =>
@@ -17,7 +22,7 @@ export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
isAttachment(part) ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}
+17 -22
View File
@@ -1,15 +1,10 @@
import { describe, expect, test } from "bun:test"
import { Skill } from "@opencode/schema/skill"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import type { DeliveredAttachment } from "./attachments/deliver"
import { buildPromptRequest } from "./request"
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>): DeliveredAttachment {
return {
type: "inline",
attachment: { type: "image", id: `img_${filename}`, filename, mime, blob: { id: filename, url: "" }, ...extra },
dataUrl: `data:${mime};base64,AAA`,
}
function inline(filename: string, mime: string, extra?: Partial<ImageAttachmentPart>) {
return { type: "image" as const, id: `img_${filename}`, filename, mime, dataUrl: `data:${mime};base64,AAA`, ...extra }
}
describe("buildPromptRequest", () => {
@@ -30,7 +25,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
attachments: [inline("a.png", "image/png")],
images: [inline("a.png", "image/png")],
text: "hello @src/foo.ts @planner",
sessionDirectory: "/repo",
})
@@ -52,7 +47,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
attachments: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
images: [inline("a.png", "image/png"), inline("b.pdf", "application/pdf")],
text: "check these",
sessionDirectory: "/repo",
})
@@ -67,7 +62,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt: [],
context: [],
attachments: [
images: [
inline("opencode.global.dat", "text/plain", {
sourcePath: "C:\\Users\\Luke\\AppData\\Roaming\\ai.opencode.desktop.beta\\opencode.global.dat",
}),
@@ -95,7 +90,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
attachments: [],
images: [],
text: "@docs",
sessionDirectory: "/repo/app",
})
@@ -117,7 +112,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:dup", type: "file", path: "src/foo.ts" },
{ key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
],
attachments: [],
images: [],
text: "@src/foo.ts",
sessionDirectory: "/repo",
})
@@ -139,7 +134,7 @@ describe("buildPromptRequest", () => {
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
attachments: [],
images: [],
text: "look",
sessionDirectory: "/repo",
})
@@ -155,7 +150,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src\\foo.ts",
sessionDirectory: "D:\\projects\\myapp", // Windows path
})
@@ -176,7 +171,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@file#name.txt",
sessionDirectory: "C:\\Users\\test\\Documents", // Windows path
})
@@ -197,7 +192,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src/app.ts",
sessionDirectory: "/home/user/project",
})
@@ -211,7 +206,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@README.md",
sessionDirectory: "/Users/kelvin/Projects/opencode",
})
@@ -226,7 +221,7 @@ describe("buildPromptRequest", () => {
{ key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" },
{ key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" },
],
attachments: [],
images: [],
text: "test",
sessionDirectory: "D:\\workspace\\app",
})
@@ -248,7 +243,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@D:\\other\\project\\file.ts",
sessionDirectory: "C:\\current\\project",
})
@@ -275,7 +270,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@src\\App.tsx",
sessionDirectory: "C:\\project",
})
@@ -300,7 +295,7 @@ describe("buildPromptRequest", () => {
const result = buildPromptRequest({
prompt,
context: [],
attachments: [],
images: [],
text: "@..\\..\\shared\\util.ts",
sessionDirectory: "C:\\projects\\myapp\\src",
})
@@ -330,7 +325,7 @@ describe("buildPromptRequest", () => {
},
],
context: [],
attachments: [],
images: [],
text: "@review",
sessionDirectory: "/repo",
})
+11 -11
View File
@@ -1,14 +1,13 @@
import { getFilename } from "@opencode/util/path"
import type { FileSelection } from "@/workspaces/files/model"
import { encodeFilePath } from "@/workspaces/files/path"
import type { AgentPart, FileAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, PathAttachmentPart, Prompt, SkillPart } from "@/composer/state"
import {
formatAttachmentReference,
formatCommentNote,
type PromptAttachmentReference,
type PromptComment,
} from "@/composer/comment-note"
import type { DeliveredAttachment } from "@/composer/attachments/deliver"
// Network fields feed both boundaries; display fields keep desktop-only rendering details in the local echo.
type PromptRequest = {
@@ -35,7 +34,7 @@ type ContextFile = {
type BuildPromptRequestInput = {
prompt: Prompt
context: ContextFile[]
attachments: DeliveredAttachment[]
images: (Omit<ImageAttachmentPart, "blob"> & { dataUrl: string })[]
text: string
sessionDirectory: string
}
@@ -63,6 +62,7 @@ const parseCommentMentions = (comment: string) => {
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
const isSkillAttachment = (part: Prompt[number]): part is SkillPart => part.type === "skill"
const isPathAttachment = (part: Prompt[number]): part is PathAttachmentPart => part.type === "path"
export function buildPromptRequest(input: BuildPromptRequestInput): PromptRequest {
const skills = input.prompt.filter(isSkillAttachment).map((attachment) => ({
@@ -113,15 +113,15 @@ export function buildPromptRequest(input: BuildPromptRequestInput): PromptReques
return [file, ...mentions]
})
const inline = input.attachments.flatMap((item) =>
item.type === "inline"
? [{ uri: item.dataUrl, mime: item.attachment.mime, name: item.attachment.sourcePath ?? item.attachment.filename }]
: [],
)
const inline = input.images.map((attachment) => ({
uri: attachment.dataUrl,
mime: attachment.mime,
name: attachment.sourcePath ?? attachment.filename,
}))
// Like comments, path references reach the model as text and the message UI through metadata.
const attachments = input.attachments.flatMap((item) =>
item.type === "path" ? [{ name: item.attachment.filename, mime: item.attachment.mime, path: item.path }] : [],
)
const attachments = input.prompt
.filter(isPathAttachment)
.map((part) => ({ name: part.filename, mime: part.mime, path: part.path }))
return {
text: [
+6 -2
View File
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
).toEqual(value)
})
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
const value = Schema.decodeUnknownSync(
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
)({
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
],
})
expect(value.prompt).toHaveLength(3)
expect(value.prompt).toHaveLength(6)
expect(value.prompt[0]).toEqual({
...image,
sourcePath: "/image.png",
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
})
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
const encoded = Schema.encodeSync(ComposerStore)(value)
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
expect(
+22 -4
View File
@@ -61,11 +61,12 @@ const ImageFields = {
}
const Image = Persistence.struct({
...ImageFields,
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
})
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
// inline data remains usable, but unresolved references are not renderable.
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
export const ImageAttachmentPart = Schema.Struct({
...ImageFields,
blob: Persistence.optional(
@@ -94,7 +95,24 @@ export const ImageAttachmentPart = Schema.Struct({
)
export type ImageAttachmentPart = typeof ImageAttachmentPart.Type
export const ContentPart = Schema.Union([TextPart, FileAttachmentPart, AgentPart, SkillPart, ImageAttachmentPart])
// A file the model receives as a path on the server: its bytes never enter the draft store.
export const PathAttachmentPart = Persistence.struct({
type: Schema.Literal("path"),
id: Schema.String,
filename: Schema.String,
mime: Schema.String,
path: Schema.String,
})
export type PathAttachmentPart = typeof PathAttachmentPart.Type
export const ContentPart = Schema.Union([
TextPart,
FileAttachmentPart,
AgentPart,
SkillPart,
ImageAttachmentPart,
PathAttachmentPart,
])
export type ContentPart = typeof ContentPart.Type
export const Prompt = Persistence.array(ContentPart)
export type Prompt = typeof Prompt.Type
+9
View File
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
start: 5,
end: 12,
},
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
{
type: "image",
id: "missing-blob",
filename: "missing.png",
mime: "image/png",
blob: { id: "content-hash-without-a-url", url: "" },
},
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
{
type: "image",
id: "legacy",
+1
View File
@@ -23,6 +23,7 @@ export type {
FileAttachmentPart,
FileContextItem,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
PromptModel,
SkillPart,
@@ -1,5 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt } from "./prompt-parts"
import { appendPrompt, clonePrompt, isAttachment } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@@ -22,7 +22,7 @@ export function createComposerSubmission(input: {
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
-10
View File
@@ -3,7 +3,6 @@ import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import type { AttachmentDestination } from "./attachments/deliver"
import { createMemoryComposerState } from "./state"
import { createComposerSubmit } from "./submit"
@@ -49,14 +48,6 @@ function controls(): ComposerControls {
}
}
const destination: AttachmentDestination = {
input: { image: true, pdf: true },
local: false,
upload: async () => {
throw new Error("native attachments must not upload")
},
}
function submitInput(
adapter: ActiveComposerAdapter | NewSessionComposerAdapter,
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
@@ -73,7 +64,6 @@ function submitInput(
resetHistory() {},
setMode() {},
closePopover() {},
destination: () => destination,
notify,
comments: { capture: () => [], clear() {}, restore() {} },
})
+32 -32
View File
@@ -8,7 +8,8 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
import { createComposerSubmission } from "./submission-state"
import { buildPromptRequest } from "./request"
import { setCursorPosition } from "./editor/dom"
import { deliverAttachments, type AttachmentDestination } from "./attachments/deliver"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
const submitting = new WeakSet<object>()
@@ -34,7 +35,6 @@ type ComposerSubmitInput = {
resetHistory: () => void
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
destination: () => AttachmentDestination
delivery?: (alternate: boolean) => ComposerDelivery
notify: {
missingSelection: () => void
@@ -59,12 +59,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!value) {
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
}
if (submitting.has(input.adapter.state)) return
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
const value = {
...read,
images: await Promise.all(
read.images.map(async (image) => ({
...image,
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
})),
),
}
submitting.add(input.adapter.state)
const comments = input.comments.capture()
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
@@ -87,16 +97,10 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(
session,
value,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
() => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
},
).then(
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -129,13 +133,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(
session,
value,
command,
input.destination(),
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -162,6 +162,9 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
})),
metadata: {
displayText: value.text,
attachments: value.prompt.flatMap((part) =>
part.type === "path" ? [{ name: part.filename, mime: part.mime, path: part.path }] : [],
),
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
@@ -196,7 +199,7 @@ function readSubmission(
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
const comments = context.filter((item) => !!item.comment?.trim()).length
if (!text.trim() && images.length === 0 && comments === 0) return
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
const controls = input.adapter.controls()
const model = controls.model.selection.current()
@@ -304,10 +307,9 @@ async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
command: { command: string; arguments: string },
destination: AttachmentDestination,
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value, destination)
const request = await buildSubmissionRequest(session, value)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
@@ -346,11 +348,10 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value, destination)
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
// the remainder of a running turn. A steer targets that turn, so its
// selection applies now; a queued follow-up must not reconfigure the turn it
@@ -384,15 +385,14 @@ async function sendPrompt(
await sending
}
async function buildSubmissionRequest(
session: ComposerSession,
value: ComposerSubmission,
destination: AttachmentDestination,
) {
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
return buildPromptRequest({
prompt: value.prompt,
context: value.context,
attachments: await deliverAttachments(value.images, destination),
images,
text: value.text,
sessionDirectory: session.directory,
})
@@ -1,4 +1,5 @@
import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types"
import { isAttachment } from "../prompt-parts"
export type ComposerInteractionState = {
mode: "normal" | "shell"
@@ -236,7 +237,7 @@ function populated(persisted: ComposerPersistedState) {
return (
!!promptText(persisted).trim() ||
persisted.context.items.length > 0 ||
persisted.prompt.some((part) => part.type === "file" || part.type === "image")
persisted.prompt.some((part) => part.type === "file" || isAttachment(part))
)
}
+10 -2
View File
@@ -1,9 +1,17 @@
import type { AgentPart, ComposerStore, FileAttachmentPart, ImageAttachmentPart, Prompt, SkillPart } from "./state"
import type {
AgentPart,
ComposerStore,
FileAttachmentPart,
ImageAttachmentPart,
PathAttachmentPart,
Prompt,
SkillPart,
} from "./state"
export type ComposerFilePart = FileAttachmentPart
export type ComposerAgentPart = AgentPart
export type ComposerSkillPart = SkillPart
export type ComposerAttachment = ImageAttachmentPart
export type ComposerAttachment = ImageAttachmentPart | PathAttachmentPart
export type ComposerPrompt = Prompt
export type ComposerComment = ComposerStore["context"]["items"][number]
export type ComposerPersistedState = ComposerStore
@@ -48,7 +48,8 @@ export function createNewSessionComposerAdapter(props: {
submitted: props.submitted,
async start(selection, submission, message) {
const draftID = props.draftID
const projectDirectory = location().directory
const currentDirectory = location().directory
const projectDirectory = data.location.info({ directory: currentDirectory })?.project.canonical ?? currentDirectory
const worktree = props.worktree()
const branch = props.branch()
const mcp = props.mcp.capture()
+13
View File
@@ -74,6 +74,7 @@ export const dict = {
"command.category.workspace": "Worktree",
"command.category.settings": "Settings",
"command.logs.export": "Export logs",
"command.debugBar.toggle": "Toggle debug bar",
"theme.scheme.system": "System",
"theme.scheme.light": "Light",
@@ -366,6 +367,9 @@ export const dict = {
"prompt.action.stop": "Stop",
"prompt.toast.attachmentDuplicate.title": "This file has already been uploaded",
"prompt.toast.uploading.percent": "{{percent}}%",
"prompt.toast.uploading.cancel": "Cancel upload",
"prompt.toast.uploadFailed.title": "Upload failed",
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
@@ -963,7 +967,16 @@ export const dict = {
"sidebar.empty.description": "Open a project to get started",
"debugBar.ariaLabel": "Development performance diagnostics",
"debugBar.providerAriaLabel": "Provider performance diagnostics",
"debugBar.na": "n/a",
"debugBar.ttft.label": "TTFT",
"debugBar.ttft.tip": "Time from provider request dispatch to the first model output.",
"debugBar.ttfa.label": "TTFA",
"debugBar.ttfa.tip": "Time from provider request dispatch to the first answer text.",
"debugBar.tps.label": "TPS",
"debugBar.tps.tip": "Output tokens per second after the first model output.",
"debugBar.e2e.label": "E2E",
"debugBar.e2e.tip": "Time from provider request dispatch until its response stream ended.",
"debugBar.nav.label": "NAV",
"debugBar.nav.tip":
"Last completed route transition touching a session page, measured from router start until the first paint after it settles.",
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
function memoryDriver() {
const documents = new Map<string, string>()
@@ -287,15 +287,34 @@ describe("draft store image retention", () => {
expect(await released(store, 5, shared.url)).toBe(true)
})
test("loading a document pins the images it references", async () => {
test("loading a document pins the images it references without fetching their bytes", async () => {
const { memory, store } = fresh()
const reads: string[] = []
const getBlob = memory.driver.getBlob
memory.driver.getBlob = (id) => {
reads.push(id)
return getBlob(id)
}
const id = await memory.driver.putBlob(image(6))
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
expect(loaded).toEqual({ id })
expect(reads).toEqual([])
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
const url = (await resolveBlobUrl(loaded))!
expect(url.startsWith("blob:")).toBe(true)
expect(reads).toEqual([id])
expect(await resolveBlobUrl(loaded)).toBe(url)
expect(reads).toEqual([id])
await tick()
expect(await released(store, 6, url)).toBe(false)
await store.removeItem("loaded")
await tick()
expect(await released(store, 6, url)).toBe(true)
})
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
fresh()
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
})
})
+22 -13
View File
@@ -42,6 +42,18 @@ const refs = new Map<string, Set<string>>()
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
// ones); live references still carry the original.
const aliases = new Map<string, string>()
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
// that renders or sends an image resolves its URL through here, so a history full of large
// attachments costs nothing at startup.
let loader: ((id: string) => Promise<string | undefined>) | undefined
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
export function resolveBlobUrl(blob: { id: string; url?: string }) {
if (blob.url) return Promise.resolve(blob.url)
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
if (existing) return Promise.resolve(existing.url)
return loader?.(blob.id) ?? Promise.resolve(undefined)
}
function blobUrl(id: string, blob: Blob, grace?: number) {
const existing = retained.get(id)
@@ -120,7 +132,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
const loading = new Map<string, Promise<string | undefined>>()
const loadBlobUrl = (id: string) => {
const existing = retained.get(id)
if (existing) return existing.url
if (existing) return Promise.resolve(existing.url)
const pending = loading.get(id)
if (pending) return pending
const next = driver
@@ -130,6 +142,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
loading.set(id, next)
return next
}
loader = loadBlobUrl
const putBlob = async (blob: Blob) => {
const id = await driver.putBlob(blob)
return { id, url: blobUrl(id, blob, grace) }
@@ -221,9 +234,11 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
if (ref.kind === "text" && Array.isArray(ref.ids)) {
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
}
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
// only an image already pinned in this page gets its URL back immediately.
if (typeof ref.id === "string") {
const url = await loadBlobUrl(ref.id)
if (url) return { ...item, blob: { id: ref.id, url } }
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
}
}
return Object.fromEntries(
@@ -421,17 +436,11 @@ function referenced(json: string) {
return ids
}
async function blobData(blob: BlobReference) {
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
return kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
}
export async function blobBytes(blob: BlobReference) {
return new Uint8Array(await (await blobData(blob)).arrayBuffer())
}
export async function blobDataUrl(blob: BlobReference, mime: string) {
const data = await blobData(blob)
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
const url = kept ? undefined : await resolveBlobUrl(blob)
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener("error", () => reject(reader.error))
@@ -0,0 +1,23 @@
import { createResource } from "solid-js"
import { usePlatform } from "@/runtime/platform/platform"
export function createCameraAvailability() {
const platform = usePlatform()
const supported = platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
const [available, actions] = createResource(
async () => {
if (!supported || !navigator.mediaDevices.enumerateDevices) return false
const denied = await navigator.permissions?.query({ name: "camera" }).then(
(permission) => permission.state === "denied",
() => false,
)
if (denied) return false
return navigator.mediaDevices.enumerateDevices().then(
(devices) => devices.some((device) => device.kind === "videoinput"),
() => false,
)
},
{ initialValue: false },
)
return { supported, available, refetch: actions.refetch }
}
+141 -56
View File
@@ -4,7 +4,17 @@ import { Divider } from "@opencode/ui/divider"
import { TextInput } from "@opencode/ui/text-input"
import { useDialog } from "@opencode/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import {
type Component,
Show,
Suspense,
createEffect,
createMemo,
createSignal,
lazy,
onCleanup,
onMount,
} from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerHealthPreview,
@@ -18,8 +28,12 @@ import { useTabs } from "@/shell/tabs/tabs"
import { useCheckServerHealth } from "@/runtime/server/health"
import { usePlatform } from "@/runtime/platform/platform"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import { decodePairingCode } from "./pairing"
import "@/settings/settings.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
type FormMode = "list" | "add" | "edit"
export const DialogServer: Component<{
@@ -29,6 +43,8 @@ export const DialogServer: Component<{
}> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform()
const camera = createCameraAvailability()
const form = createFormController({
onSelect: (server) => {
props.onSave?.(server)
@@ -75,64 +91,112 @@ export const DialogServer: Component<{
</DialogHeader>
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
<Show
when={!form.state.scanning()}
fallback={
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
<PairingScanner
onCancel={() => {
form.scan.stop()
void camera.refetch()
}}
onScan={form.scan.complete}
/>
</Suspense>
}
>
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.value()}
placeholder={language.t("dialog.server.add.placeholder")}
invalid={!!form.state.error()}
disabled={form.state.busy()}
autofocus
list="dialog-server-addresses"
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
onInput={(event) => form.change.value(event.currentTarget.value)}
onKeyDown={keyDown}
/>
<datalist id="dialog-server-addresses">
{form.state.urls().map((url) => (
<option value={url} />
))}
</datalist>
<Show when={form.state.error()}>
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
{form.state.error()}
</span>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<Show when={props.mode === "add" && platform.platform === "web"}>
<div class="flex w-full min-w-0 flex-col gap-2">
<Button
variant="neutral"
size="large"
class="!w-full self-stretch"
disabled={form.state.busy() || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading
? "dialog-server-camera-unavailable"
: undefined
}
onClick={form.scan.start}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.available.latest && !camera.available.loading}>
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
)}
</span>
</Show>
</div>
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<TextInput
type="text"
appearance="large"
class="!w-full self-stretch"
value={form.state.name()}
placeholder={language.t("dialog.server.add.namePlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.name(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<TextInput
type="password"
appearance="large"
class="!w-full self-stretch"
value={form.state.password()}
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
disabled={form.state.busy()}
onInput={(event) => form.change.password(event.currentTarget.value)}
onKeyDown={keyDown}
/>
</div>
</div>
</Show>
</DialogBody>
<DialogFooter>
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</Button>
</DialogFooter>
<Show when={!form.state.scanning()}>
<DialogFooter>
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
{submitLabel()}
</Button>
</DialogFooter>
</Show>
</Dialog>
)
}
@@ -149,6 +213,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list" as FormMode,
originalUrl: undefined as string | undefined,
values: { url: "", name: "", password: "" },
urls: [] as string[],
scanning: false,
error: "",
status: undefined as boolean | undefined,
})
@@ -161,6 +227,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
mode: "list",
originalUrl: undefined,
values: { url: "", name: "", password: "" },
urls: [],
scanning: false,
error: "",
status: undefined,
})
@@ -265,6 +333,16 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
setStore("error", "")
request.mutate()
}
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
healthPreview.cancel()
setStore({
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
urls: pairing.urls,
scanning: false,
error: "",
})
request.mutate()
}
createEffect(() => {
if (store.mode !== "edit") return
@@ -281,6 +359,8 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
value: () => store.values.url,
name: () => store.values.name,
password: () => store.values.password,
urls: () => store.urls,
scanning: () => store.scanning,
error: () => store.error,
status: () => store.status,
},
@@ -289,6 +369,11 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
name: (value: string) => change("name", value),
password: (value: string) => change("password", value),
},
scan: {
start: () => setStore("scanning", true),
stop: () => setStore("scanning", false),
complete: pair,
},
start: { add: startAdd, edit: startEdit },
reset,
submit,
@@ -0,0 +1,41 @@
.server-connect-scanner {
display: flex;
flex-direction: column;
gap: 20px;
p {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
button {
min-height: 44px;
width: 100%;
}
.server-connect-error {
color: var(--v2-state-fg-danger);
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
@@ -4,6 +4,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingCode } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
+1 -23
View File
@@ -41,8 +41,7 @@
color: var(--v2-text-text-muted);
}
form,
.server-connect-scanner {
form {
display: flex;
flex-direction: column;
gap: 20px;
@@ -95,25 +94,4 @@
background: var(--v2-background-bg-layer-01);
user-select: all;
}
.server-connect-video {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 12px;
background: var(--v2-background-bg-deep);
}
video {
width: 100%;
height: 100%;
object-fit: cover;
}
.server-connect-video [role="status"] {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
}
+9 -22
View File
@@ -1,4 +1,4 @@
import { createResource, lazy, Show, Suspense } from "solid-js"
import { lazy, Show, Suspense } from "solid-js"
import { createStore } from "solid-js/store"
import { useMutation } from "@tanstack/solid-query"
import { Button } from "@opencode/ui/button"
@@ -10,6 +10,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
@@ -19,23 +20,7 @@ export function ConnectServerScreen() {
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const cameraSupported =
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
const [camera, cameraActions] = createResource(
async () => {
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
const denied = await navigator.permissions?.query({ name: "camera" }).then(
(permission) => permission.state === "denied",
() => false,
)
if (denied) return false
return navigator.mediaDevices.enumerateDevices().then(
(devices) => devices.some((device) => device.kind === "videoinput"),
() => false,
)
},
{ initialValue: false },
)
const camera = createCameraAvailability()
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const connectionError = () =>
language.t(
@@ -78,7 +63,7 @@ export function ConnectServerScreen() {
<PairingScanner
onCancel={() => {
setState("scanning", false)
void cameraActions.refetch()
void camera.refetch()
}}
onScan={(pairing) => {
setState({
@@ -154,13 +139,15 @@ export function ConnectServerScreen() {
<Button
variant="neutral"
size="large"
disabled={request.isPending || !camera.latest}
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
disabled={request.isPending || !camera.available.latest}
aria-describedby={
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
}
onClick={() => setState("scanning", true)}
>
{language.t("server.connect.scan")}
</Button>
<Show when={!camera.latest && !camera.loading}>
<Show when={!camera.available.latest && !camera.available.loading}>
<p id="server-connect-camera-unavailable">
{language.t(
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
@@ -2,7 +2,6 @@ import { createEffect, createMemo, on, type Accessor } from "solid-js"
import type { ComposerControls } from "@/composer/adapter"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerModel } from "@/composer/model"
import { useAttachmentDestination } from "@/composer/attachments/deliver"
import { useSettings } from "@/settings/model"
import { createActiveComposerAdapter } from "./adapter"
import { createSessionQueue } from "./queue"
@@ -30,7 +29,6 @@ export function createSessionComposerController(input: {
draft: adapter.state,
working: adapter.working,
behavior: settings.general.followUpBehavior,
destination: useAttachmentDestination(input.controls),
restoreFocus: (cursor) => {
const target = editor
if (!target) return
+40 -28
View File
@@ -5,11 +5,11 @@ import type { SessionInboxInfo } from "@opencode/client/promise"
import { SessionMessage } from "@opencode/schema/session-message"
import type { ComposerDelivery } from "@/composer/adapter"
import type { ComposerStateTarget } from "@/composer/submission-state"
import type { ImageAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, promptLength } from "@/composer/prompt-parts"
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
import { buildPromptRequest } from "@/composer/request"
import { deliverAttachments, type AttachmentDestination } from "@/composer/attachments/deliver"
import { createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
import { readPromptPresentation } from "@/composer/comment-note"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useWorkspaceLocation } from "@/workspaces/location"
@@ -30,7 +30,6 @@ export function createSessionQueue(input: {
draft: ComposerStateTarget
working: Accessor<boolean>
behavior: Accessor<ComposerDelivery>
destination: () => AttachmentDestination
restoreFocus: (cursor: number) => void
}) {
const data = useData()
@@ -61,7 +60,6 @@ export function createSessionQueue(input: {
change.item,
change.prompt,
change.text,
input.destination(),
)
// Admit before cancelling so a failed replacement never discards the original.
const admitted = await data.session.prompt({
@@ -187,15 +185,15 @@ export function createSessionQueue(input: {
if (!editing || mutation.isPending) return
const prompt = clonePrompt(input.draft.current())
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
if (!text.trim() && !images.length) return cancelEdit()
const attachments = prompt.filter(isAttachment)
if (!text.trim() && !attachments.length) return cancelEdit()
const item = queued().find((entry) => entry.id === editing.id)
const original = item ? queuedPromptAttachments(item) : []
const pristine =
item &&
text.trim() === queuedPromptText(item) &&
images.length === original.length &&
images.every((image, index) => image.id === original[index].id)
attachments.length === original.length &&
attachments.every((attachment, index) => attachment.id === original[index].id)
if (pristine && delivery === "queue") return cancelEdit()
mutation.mutate({
type: "edit",
@@ -251,7 +249,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
.map((item) => ({
id: item.id,
text: queuedPromptText(item),
attachments: item.payload.files?.length ?? 0,
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
}))
}
@@ -261,18 +259,32 @@ export function queuedPromptText(item: QueuedPrompt) {
}
// Inline attachments are the files the composer added itself, so they return
// to it as image parts that an edit can remove or extend. Mentions and
// `file://` context stay in the payload; see editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): ImageAttachmentPart[] {
return (item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map((file, index) => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}))
// to it as image parts that an edit can remove or extend, and path references
// return as path parts. Mentions and `file://` context stay in the payload; see
// editedPromptInput.
export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPart | PathAttachmentPart)[] {
return [
...(item.payload.files ?? [])
.filter((file) => isComposerAttachment(file))
.map(
(file, index): ImageAttachmentPart => ({
type: "image",
id: `${item.id}:file:${index}`,
filename: file.name ?? "attachment",
mime: file.mime,
blob: createLegacyBlobReference(`data:${file.mime};base64,${file.data}`),
}),
),
...(readPromptPresentation(item.payload.metadata)?.attachments ?? []).map(
(file, index): PathAttachmentPart => ({
type: "path",
id: `${item.id}:path:${index}`,
filename: file.name,
mime: file.mime,
path: file.path,
}),
),
]
}
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
@@ -292,13 +304,13 @@ async function editedPromptInput(
item: QueuedPrompt | undefined,
prompt: Prompt,
text: string,
destination: AttachmentDestination,
) {
const attachments = await deliverAttachments(
prompt.filter((part): part is ImageAttachmentPart => part.type === "image"),
destination,
const images = await Promise.all(
prompt
.filter((part): part is ImageAttachmentPart => part.type === "image")
.map(async (part) => ({ ...part, dataUrl: await blobDataUrl(part.blob, part.mime) })),
)
const request = buildPromptRequest({ prompt, context: [], attachments, text, sessionDirectory: directory })
const request = buildPromptRequest({ prompt, context: [], images, text, sessionDirectory: directory })
const payload = item?.payload
const display = item ? queuedPromptText(item) : ""
const notes = payload && display && payload.text.startsWith(display) ? payload.text.slice(display.length) : ""
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/runtime/server/registry"
import type { SshItem } from "@/servers/ssh/types"
import { settingsServers } from "./inventory"
import { settingsProjects, settingsServers } from "./inventory"
const ssh: SshItem = {
config: { id: "build", target: "dev@example.com", name: "Build server" },
@@ -17,6 +17,30 @@ const connection: ServerConnection.Ssh = {
http: { url: "http://127.0.0.1:4000", password: "secret" },
}
test("settings project inventory reads metadata without acquiring directory stores", () => {
const projects = Array.from({ length: 40 }, (_, index) => ({
id: `project-${index}`,
worktree: `/projects/${index}`,
name: `Project ${index}`,
icon: { color: "orange" },
commands: { start: "bun install" },
time: { created: 1, updated: 1 },
sandboxes: [],
worktrees: [],
}))
const tracked = { ...projects[0], expanded: true, icon: { override: "local-icon" } }
const inventory = settingsProjects({
projects: { list: () => [tracked], closed: () => [projects[1].worktree] },
sync: { data: { project: projects } },
})
expect(inventory).toHaveLength(39)
expect(inventory[0]).toBe(tracked)
expect(inventory.some((project) => project.id === projects[1].id)).toBe(false)
expect(inventory[1]).toEqual({ ...projects[2], expanded: false })
expect(inventory[38]).toEqual({ ...projects[39], expanded: false })
})
describe("settings server inventory", () => {
test("includes saved SSH servers before they connect", () => {
expect(settingsServers([], [], [ssh])).toEqual([
@@ -8,7 +8,7 @@ import type { ServerCtx } from "@/runtime/server/runtime"
import { pathKey } from "@/workspaces/path-key"
export function settingsProjects(context: {
projects: Pick<ServerCtx["projects"], "list" | "closed" | "resolve">
projects: Pick<ServerCtx["projects"], "list" | "closed">
sync: { data: Pick<ServerCtx["sync"]["data"], "project"> }
}) {
const tracked = context.projects.list()
@@ -16,9 +16,10 @@ export function settingsProjects(context: {
const closed = new Set(context.projects.closed().map(pathKey))
return [
...tracked,
// Inventory reads must not allocate directory stores: async cache hydration can trigger an eviction/reload loop.
...context.sync.data.project
.filter((project) => !paths.has(pathKey(project.worktree)) && !closed.has(pathKey(project.worktree)))
.map((project) => context.projects.resolve({ worktree: project.worktree, expanded: false })),
.map((project) => ({ ...project, expanded: false })),
]
}
+6
View File
@@ -1517,6 +1517,12 @@
color: var(--v2-state-fg-danger);
}
.settings-server-dialog-hint {
font-size: 13px;
line-height: var(--line-height-base);
color: var(--v2-text-text-muted);
}
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
width: min(280px, 100%);
}
+197 -89
View File
@@ -1,10 +1,20 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount, Show } from "solid-js"
import { useIsRouting, useLocation, useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, on, onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode/ui/tooltip"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { base64Encode } from "@opencode/util/encode"
import {
applyProviderMetricEvent,
isProviderMetricEvent,
projectedProviderMetrics,
type ProviderMetrics,
type ProviderMetricState,
} from "./provider-metrics"
type Mem = Performance & {
memory?: {
@@ -39,12 +49,23 @@ const time = (n?: number) => {
return `${Math.round(n)}`
}
const fixed = (n?: number, digits = 0) => {
if (n === undefined || Number.isNaN(n)) return
return n.toFixed(digits)
}
const mb = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
const v = n / 1024 / 1024
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
}
const duration = (n?: number) => {
if (n === undefined || Number.isNaN(n)) return
if (n < 1_000) return `${Math.round(n)}ms`
return `${(n / 1_000).toFixed(n < 10_000 ? 1 : 0)}s`
}
const bad = (n: number | undefined, limit: number, low = false) => {
if (n === undefined || Number.isNaN(n)) return false
return low ? n < limit : n > limit
@@ -80,6 +101,7 @@ function Cell(props: {
}}
>
<div
dir="ltr"
classList={{
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
}}
@@ -87,6 +109,7 @@ function Cell(props: {
{props.label}
</div>
<div
dir="ltr"
classList={{
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
@@ -136,8 +159,12 @@ function ToggleCell(props: {
"flex-col items-center": !props.inline,
}}
>
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">{props.label}</span>
<span class="text-[11px] leading-none font-bold">{props.value}</span>
<span dir="ltr" class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">
{props.label}
</span>
<span dir="ltr" class="text-[11px] leading-none font-bold">
{props.value}
</span>
</span>
</button>
)
@@ -149,9 +176,11 @@ function ToggleCell(props: {
)
}
export function DebugBar(props: { inline?: boolean } = {}) {
export function DebugBar(props: { diagnostics?: boolean; inline?: boolean } = {}) {
const language = useLanguage()
const platform = usePlatform()
const global = useGlobal()
const params = useParams<{ serverKey?: string; id?: string }>()
const location = useLocation()
const routing = useIsRouting()
const [state, setState] = createStore({
@@ -175,8 +204,55 @@ export function DebugBar(props: { inline?: boolean } = {}) {
dur: undefined as number | undefined,
pending: false,
},
live: undefined as ProviderMetrics | undefined,
})
const target = createMemo(
() => {
if (!params.serverKey || !params.id) return
const connection = global.servers
.list()
.find((item) => base64Encode(ServerConnection.key(item)) === params.serverKey)
if (!connection) return
return { ctx: global.ensureServerCtx(connection), id: params.id }
},
undefined,
{ equals: (a, b) => a?.ctx === b?.ctx && a?.id === b?.id },
)
// History comes from the already-loaded message projection; live requests refine it in place.
const projected = createMemo(() => {
const current = target()
if (!current) return
return projectedProviderMetrics(current.ctx.data.session.message.list(current.id))
})
const metrics = () => state.live ?? projected()
// Missed events during an outage are never replayed; the refreshed projection must win.
createEffect(
on(
() => target()?.ctx.sdk.connection.status(),
(status) => {
if (status !== "connected") setState("live", undefined)
},
{ defer: true },
),
)
createEffect(
on(target, (current) => {
setState("live", undefined)
if (!current) return
const accumulator: ProviderMetricState = {}
onCleanup(
current.ctx.sdk.event.listen((event) => {
if (!isProviderMetricEvent(event) || event.data.sessionID !== current.id) return
applyProviderMetricEvent(accumulator, event)
if (accumulator.latest) setState("live", accumulator.latest)
}),
)
}),
)
const na = () => language.t("debugBar.na").toUpperCase()
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
const heapv = () => {
@@ -204,6 +280,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
let two = 0
createEffect(() => {
if (!props.diagnostics) return
const busy = routing()
const next = `${location.pathname}${location.search}`
@@ -248,6 +325,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
})
onMount(() => {
if (!props.diagnostics) return
const obs: PerformanceObserver[] = []
const fps: Array<{ at: number; dur: number }> = []
const long: Array<{ at: number; dur: number }> = []
@@ -448,7 +526,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
return (
<aside
aria-label={language.t("debugBar.ariaLabel")}
aria-label={language.t(props.diagnostics ? "debugBar.ariaLabel" : "debugBar.providerAriaLabel")}
classList={{
"pointer-events-auto hidden overflow-hidden text-text-strong md:block": true,
"mt-[-6px] w-full shrink-0 px-3 py-1": !!props.inline,
@@ -467,102 +545,132 @@ export function DebugBar(props: { inline?: boolean } = {}) {
}}
>
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
label={language.t("debugBar.tps.label")}
tip={language.t("debugBar.tps.tip")}
value={fixed(metrics()?.tps, 1) ?? na()}
dim={metrics()?.tps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
label={language.t("debugBar.ttft.label")}
tip={language.t("debugBar.ttft.tip")}
value={duration(metrics()?.ttft) ?? na()}
dim={metrics()?.ttft === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
label={language.t("debugBar.ttfa.label")}
tip={language.t("debugBar.ttfa.tip")}
value={duration(metrics()?.ttfa) ?? na()}
dim={metrics()?.ttfa === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
label={language.t("debugBar.e2e.label")}
tip={language.t("debugBar.e2e.tip")}
value={duration(metrics()?.e2e) ?? na()}
dim={metrics()?.e2e === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={state.focus}
<Show when={props.diagnostics}>
<Cell
label={language.t("debugBar.nav.label")}
tip={language.t("debugBar.nav.tip")}
value={navv()}
bad={bad(state.nav.dur, 400)}
dim={state.nav.dur === undefined && !state.nav.pending}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
<Cell
label={language.t("debugBar.fps.label")}
tip={language.t("debugBar.fps.tip")}
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
bad={bad(state.fps, 50, true)}
dim={state.fps === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.frame.label")}
tip={language.t("debugBar.frame.tip")}
value={time(state.gap) ?? na()}
bad={bad(state.gap, 50)}
dim={state.gap === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.jank.label")}
tip={language.t("debugBar.jank.tip")}
value={state.jank === undefined ? na() : `${state.jank}`}
bad={bad(state.jank, 8)}
dim={state.jank === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.long.label")}
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
value={longv()}
bad={bad(state.long.block, 200)}
dim={state.long.count === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.delay.label")}
tip={language.t("debugBar.delay.tip")}
value={time(state.delay) ?? na()}
bad={bad(state.delay, 100)}
dim={state.delay === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.inp.label")}
tip={language.t("debugBar.inp.tip")}
value={time(state.inp) ?? na()}
bad={bad(state.inp, 200)}
dim={state.inp === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.cls.label")}
tip={language.t("debugBar.cls.tip")}
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
bad={bad(state.cls, 0.1)}
dim={state.cls === undefined}
inline={props.inline}
/>
<Cell
label={language.t("debugBar.mem.label")}
tip={
state.heap.used === undefined
? language.t("debugBar.mem.tipUnavailable")
: language.t("debugBar.mem.tip", {
used: mb(state.heap.used) ?? na(),
limit: mb(state.heap.limit) ?? na(),
})
}
value={heapv()}
bad={bad(heap(), 0.8)}
dim={state.heap.used === undefined}
inline={props.inline}
span={platform.setForceFocus ? 2 : 3}
/>
<ToggleCell
active={language.direction() === "rtl"}
inline={props.inline}
label={language.t("debugBar.direction.label")}
tip={language.t("debugBar.direction.tip")}
value={language.t(`debugBar.direction.${language.direction()}`)}
onClick={() => language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")}
/>
<Show when={platform.setForceFocus}>
<ToggleCell
active={state.focus}
inline={props.inline}
label={language.t("debugBar.focus.label")}
tip={language.t("debugBar.focus.tip")}
value={language.t(state.focus ? "debugBar.focus.on" : "debugBar.focus.off")}
onClick={() => void toggleFocus()}
/>
</Show>
</Show>
</div>
</aside>
@@ -0,0 +1,153 @@
import { expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode/client/promise"
import type { ProviderMetricEvent } from "./provider-metrics"
import { foldProviderMetrics, projectedProviderMetrics } from "./provider-metrics"
const durable = { aggregateID: "ses_test", seq: 0, version: 1 } as const
const events: ProviderMetricEvent[] = [
{
id: "evt_started",
created: 1_000,
type: "session.step.started",
durable,
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 1_000,
},
},
{
id: "evt_reasoning",
created: 1_300,
type: "session.reasoning.started",
durable: { ...durable, seq: 1 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_text",
created: 1_800,
type: "session.text.started",
durable: { ...durable, seq: 2 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant", ordinal: 0 },
},
{
id: "evt_streamed",
created: 3_800,
type: "session.step.streamed",
durable: { ...durable, seq: 3 },
data: { sessionID: "ses_test", assistantMessageID: "msg_assistant" },
},
{
id: "evt_ended",
created: 4_000,
type: "session.step.ended",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
finish: "stop",
cost: 0,
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
},
},
]
test("calculates provider response metrics from durable events", () => {
expect(foldProviderMetrics(events)).toEqual({
tps: 50,
ttft: 300,
ttfa: 800,
e2e: 2_800,
})
})
test("ignores failed attempts without usage", () => {
expect(
foldProviderMetrics([
...events.slice(0, 4),
{
id: "evt_failed",
created: 4_000,
type: "session.step.failed",
durable: { ...durable, seq: 4 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
error: { type: "aborted", message: "Step interrupted" },
},
},
]),
).toBeUndefined()
})
test("keeps completed metrics while the next provider attempt runs", () => {
expect(
foldProviderMetrics([
...events,
{
id: "evt_retry",
created: 5_000,
type: "session.step.started",
durable: { ...durable, seq: 5 },
data: {
sessionID: "ses_test",
assistantMessageID: "msg_assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
started: 5_000,
},
},
]),
).toEqual(foldProviderMetrics(events))
})
const assistant: SessionMessageAssistant = {
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{ type: "reasoning", text: "Think", time: { created: 1_300, completed: 1_700 } },
{ type: "text", text: "Answer" },
],
tokens: { input: 200, output: 100, reasoning: 20, cache: { read: 0, write: 0 } },
time: { created: 1_000, streamed: 3_800, completed: 4_000 },
}
test("derives a baseline from the latest completed projected request", () => {
const messages: SessionMessageInfo[] = [
{ id: "msg_user", type: "user", text: "Hi", time: { created: 1 } },
assistant,
{ ...assistant, id: "msg_running", tokens: undefined, time: { created: 5_000 } },
]
// Reasoning ended at 1_700, so TPS spans 1_700 → 3_800 = 100 / 2.1s.
expect(projectedProviderMetrics(messages)).toEqual({ tps: 100 / 2.1, ttft: 300, ttfa: 700, e2e: 2_800 })
})
const tool = (created: number): SessionMessageAssistant["content"][number] => ({
type: "tool",
id: "call_1",
name: "read",
state: { status: "running", input: {}, metadata: {} },
time: { created, ran: created + 100 },
})
test("leaves text-first history unavailable until a live request", () => {
const unavailable = { tps: undefined, ttft: undefined, ttfa: undefined, e2e: 2_800 }
expect(projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }] }])).toEqual(unavailable)
expect(
projectedProviderMetrics([{ ...assistant, content: [{ type: "text", text: "Answer" }, tool(2_500)] }]),
).toEqual(unavailable)
})
test("uses the first tool call as first output for tool-first history", () => {
expect(projectedProviderMetrics([{ ...assistant, content: [tool(1_800)] }])).toEqual({
tps: 50,
ttft: 800,
ttfa: undefined,
e2e: 2_800,
})
})
@@ -0,0 +1,135 @@
import type {
SessionLogItem,
SessionMessageAssistant,
SessionMessageInfo,
TokenUsageInfo,
} from "@opencode/client/promise"
type ProviderMetricEventType =
| "session.step.started"
| "session.step.streamed"
| "session.step.ended"
| "session.step.failed"
| "session.text.started"
| "session.reasoning.started"
| "session.tool.input.started"
const types: ReadonlySet<string> = new Set<ProviderMetricEventType>([
"session.step.started",
"session.step.streamed",
"session.step.ended",
"session.step.failed",
"session.text.started",
"session.reasoning.started",
"session.tool.input.started",
])
export type ProviderMetricEvent = Extract<SessionLogItem, { type: ProviderMetricEventType }>
export type ProviderMetrics = {
tps?: number
ttft?: number
ttfa?: number
e2e?: number
}
type Attempt = {
assistantMessageID: string
started: number
first?: number
answer?: number
streamed?: number
tokens?: TokenUsageInfo
}
export type ProviderMetricState = { attempt?: Attempt; latest?: ProviderMetrics }
export function isProviderMetricEvent(event: { type: string }): event is ProviderMetricEvent {
return types.has(event.type)
}
export function applyProviderMetricEvent(state: ProviderMetricState, event: ProviderMetricEvent) {
if (event.type === "session.step.started") {
state.attempt = {
assistantMessageID: event.data.assistantMessageID,
started: event.data.started,
}
return
}
if (!state.attempt || event.data.assistantMessageID !== state.attempt.assistantMessageID) return
if (
event.type === "session.text.started" ||
event.type === "session.reasoning.started" ||
event.type === "session.tool.input.started"
) {
state.attempt.first ??= event.created
if (event.type === "session.text.started") state.attempt.answer ??= event.created
return
}
if (event.type === "session.step.streamed") {
state.attempt.streamed = event.created
return
}
// Interrupted or failed attempts without usage would publish misleading partial numbers.
if (!event.data.tokens || state.attempt.first === undefined || state.attempt.streamed === undefined) return
state.attempt.tokens = event.data.tokens
state.latest = attemptMetrics(state.attempt)
}
export function foldProviderMetrics(events: readonly ProviderMetricEvent[]) {
const state: ProviderMetricState = {}
events.forEach((event) => applyProviderMetricEvent(state, event))
return state.latest
}
/**
* Baseline from already-loaded history. Text parts carry no start timestamp yet, so TTFT, TTFA,
* and TPS stay unavailable for text-first requests until a live request supplies them.
*/
export function projectedProviderMetrics(messages: readonly SessionMessageInfo[]): ProviderMetrics | undefined {
const message = messages.findLast(
(item): item is SessionMessageAssistant =>
item.type === "assistant" && item.time.streamed !== undefined && item.tokens !== undefined,
)
if (!message) return
// Content is chronological; only a non-text head carries the first-output time.
const head = message.content[0]
const first = head && head.type !== "text" ? head.time?.created : undefined
// Reasoning ends when the answer starts, so a reasoning part right before the first text
// approximates the live `session.text.started` timestamp.
const text = message.content.findIndex((item) => item.type === "text")
const before = text > 0 ? message.content[text - 1] : undefined
const answer = first !== undefined && before?.type === "reasoning" ? before.time?.completed : undefined
return attemptMetrics({
assistantMessageID: message.id,
started: message.time.created,
first,
answer,
streamed: message.time.streamed,
tokens: message.tokens,
})
}
function attemptMetrics(attempt: Attempt): ProviderMetrics {
const ttft = elapsed(attempt.started, attempt.first)
const ttfa = elapsed(attempt.started, attempt.answer)
const e2e = elapsed(attempt.started, attempt.streamed)
// Output tokens exclude reasoning, so measure them from the answer start when one exists.
const generation = elapsed(attempt.answer ?? attempt.first, attempt.streamed)
const output = attempt.tokens?.output
return {
tps: generation && output && generation > 0 && output > 0 ? output / (generation / 1_000) : undefined,
ttft,
ttfa,
e2e,
}
}
function elapsed(start: number | undefined, end: number | undefined) {
if (start === undefined || end === undefined) return
return Math.max(0, end - start)
}
+23 -10
View File
@@ -5,11 +5,14 @@ import { ResizeHandle } from "@opencode/ui/resize-handle"
import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar"
import { usePlatform } from "@/runtime/platform/platform"
import { ToastRegion } from "@/shell/notifications/toast"
import { UploadToastHost } from "@/composer/attachments/uploads"
import { TitlebarRightProvider } from "@/shell/titlebar/right-slot"
import { useSettingsSurface } from "@/settings/surface"
import { useSettings } from "@/settings/model"
import { SshAuthentication } from "@/servers/ssh/authentication"
import { useUpdaterInstall } from "@/shell/updates/download"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar })))
@@ -18,6 +21,8 @@ export default function Layout(props: ParentProps) {
const settings = useSettingsSurface()
const preferences = useSettings()
const installUpdate = useUpdaterInstall()
const command = useCommand()
const language = useLanguage()
const mobile = createMediaQuery("(max-width: 767px)")
const [state, setState] = createStore({
debugTools: false,
@@ -34,14 +39,21 @@ export default function Layout(props: ParentProps) {
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
const debugTools = {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
command.register("debug-bar", () => [
{
id: "debugBar.toggle",
title: language.t("command.debugBar.toggle"),
category: language.t("command.category.view"),
onSelect: debugTools.toggle,
},
])
return (
<TitlebarRightProvider>
@@ -105,12 +117,13 @@ export default function Layout(props: ParentProps) {
</SshAuthentication>
</main>
</div>
<Show when={import.meta.env.DEV && state.debugTools}>
<Show when={state.debugTools}>
<Suspense>
<DebugBar inline />
<DebugBar diagnostics={import.meta.env.DEV} inline />
</Suspense>
</Show>
<ToastRegion />
<UploadToastHost />
</div>
</TitlebarRightProvider>
)
@@ -34,10 +34,15 @@ describe("Composer attachment ownership", () => {
addPart: () => false,
setDraggingType() {},
directory: () => "C:/repo",
destination: () => ({
input: { image: true, pdf: true },
local: false,
upload: () => Promise.reject(new Error("native attachments must not upload")),
}),
isDialogActive: () => false,
warn() {},
duplicate() {},
onError: rejectTest,
onUploadError: rejectTest,
store: () => stored.promise,
})
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test"
import { resolveObjectURL } from "node:buffer"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
const documents = new Map([
@@ -20,7 +20,19 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
return { store, documents }
}
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
test("loading history and a draft reads no image bytes", async () => {
let reads = 0
const { store } = fixture("history-cache-lazy", async () => {
reads++
return new Blob(["shared screenshot"])
})
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
expect(reads).toBe(0)
})
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
const pending = Promise.withResolvers<Blob | null>()
const started = Promise.withResolvers<void>()
let reads = 0
@@ -29,55 +41,33 @@ test("deduplicates concurrent history and draft reads without invalidating eithe
started.resolve()
return pending.promise
})
const history = store.getItem("history")
const draft = store.getItem("draft")
await store.getItem("history")
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
await started.promise
pending.resolve(new Blob(["shared screenshot"]))
const [saved, active] = await Promise.all([history, draft])
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
const [a, b] = await Promise.all([first, second])
expect(a).toBe(b!)
expect(reads).toBe(1)
await store.removeItem("history")
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
expect(reads).toBe(1)
})
test("hydrates repeated references once within one history document", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-repeated", async () => {
reads++
return new Blob(["repeated screenshot"])
})
documents.set(
"history",
JSON.stringify({
entries: Array.from({ length: 100 }, () => ({
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
})),
}),
)
const value = JSON.parse((await store.getItem("history"))!)
expect(value.entries).toHaveLength(100)
expect(
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
).toBe(1)
expect(reads).toBe(1)
})
test("reuses a live URL on remount but reads the latest document", async () => {
test("a document re-read while its image is live gets the URL back without a read", async () => {
let reads = 0
const { store, documents } = fixture("history-cache-remount", async () => {
reads++
return new Blob(["saved screenshot"])
})
const first = JSON.parse((await store.getItem("history"))!)
const url = await resolveBlobUrl({ id: "history-cache-remount" })
const changed = JSON.parse(documents.get("history")!)
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
documents.set("history", JSON.stringify(changed))
const second = JSON.parse((await store.getItem("history"))!)
expect(second.entries[0].prompt[0].content).toBe("new admission")
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
expect(reads).toBe(1)
})
@@ -89,33 +79,34 @@ test("reuses a just-stored attachment without a round trip", async () => {
})
const reference = await store.putBlob(new Blob(["pending admission"]))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
expect(reads).toBe(0)
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
})
test("does not retain a missing blob result", async () => {
let reads = 0
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
expect(reads).toBe(2)
})
test("retries after a failed blob read", async () => {
let reads = 0
const { store } = fixture("history-cache-failure", async () => {
fixture("history-cache-failure", async () => {
if (++reads === 1) throw new Error("temporary storage failure")
return new Blob(["recovered"])
})
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
expect(reads).toBe(2)
})
test("keeps different blob IDs independent", async () => {
const reads: string[] = []
const store = createDraftStore({
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
createDraftStore({
get: async () => null,
set: async () => [],
remove: async () => {},
putBlob: async () => "unused",
@@ -124,10 +115,8 @@ test("keeps different blob IDs independent", async () => {
return new Blob([id])
},
})
const value = JSON.parse((await store.getItem("history"))!)
expect(value[0].blob.url).not.toBe(value[1].blob.url)
expect(
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
).toEqual(reads)
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
expect(urls[0]).not.toBe(urls[1])
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
})
@@ -5,7 +5,7 @@ import { Schema } from "effect"
import type { Platform } from "@/runtime/platform/platform"
import { createComposerReady, createComposerState } from "@/composer/state"
import { ServerScope } from "@/runtime/server/scope"
import { createDraftStore } from "@/runtime/persistence/drafts"
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { flushPersisted } from "@/runtime/persistence/persist"
import { Persist, persisted } from "@/runtime/persistence/storage"
@@ -103,15 +103,11 @@ describe("prompt persistence", () => {
}),
}))
await root.session.ready.promise
// Bytes stay in the store until the image is shown or sent.
expect(root.session.current()).toEqual([
{
type: "image",
id: "image",
filename: "image.png",
mime: "image/png",
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
},
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
])
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
flushPersisted()
await Bun.sleep(0)
@@ -237,7 +233,7 @@ describe("prompt persistence", () => {
})
})
test("moves image data URLs into blobs and hydrates object URLs", async () => {
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
const documents = new Map<string, string>()
const blobs = new Map<string, Blob>()
const store = createDraftStore({
@@ -258,8 +254,8 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
expect(documents.get("prompt")).not.toContain("dataUrl")
const value = JSON.parse((await store.getItem("prompt"))!)
expect(value.prompt[0].blob.id).toBe("1")
expect(value.prompt[0].blob.url).toStartWith("blob:")
expect(value.prompt[0].blob).toEqual({ id: "1" })
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
})
test("does not let delayed blob migration overwrite a newer draft", async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"bin": {
+8 -2
View File
@@ -6,10 +6,16 @@ import path from "node:path"
import { fileURLToPath } from "node:url"
import { UpdateArtifact } from "../../../script/update-artifact"
if (Script.channel !== "beta") throw new Error("AUR publishing requires the beta channel")
if (Script.channel !== "beta" && Script.channel !== "latest") {
throw new Error("AUR publishing requires the beta or latest channel")
}
const name = "opencode-beta"
const command = "opencode"
if (!/^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)) throw new Error("Expected a beta release version")
const valid =
Script.channel === "beta"
? /^\d+\.\d+\.\d+-beta[.-]\d+(?:\.\d+)?$/.test(Script.version)
: /^\d+\.\d+\.\d+$/.test(Script.version)
if (!valid) throw new Error(`Expected a ${Script.channel} release version`)
const dir = fileURLToPath(new URL("..", import.meta.url))
const root = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(dir, "dist"))
+1 -1
View File
@@ -147,7 +147,7 @@ if (Script.channel === "latest" && Script.release && !dryRun) {
await $`docker buildx build --platform linux/amd64,linux/arm64 --tag ghcr.io/anomalyco/opencode:${Script.version} --push .`
}
if (Script.channel === "beta" && Script.release) {
if ((Script.channel === "beta" || Script.channel === "latest") && Script.release) {
await $`bun ./script/publish-aur.ts ${dryRun ? ["--dry-run"] : []}`.env({ ...process.env, OPENCODE_CLI_DIST: root })
}
+22 -35
View File
@@ -1,42 +1,28 @@
import {
RequestError,
type Agent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type CloseSessionRequest,
type DeleteSessionRequest,
type ForkSessionRequest,
type InitializeRequest,
type ListSessionsRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type ResumeSessionRequest,
type SetSessionConfigOptionRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import { agent, RequestError, type Stream } from "@agentclientprotocol/sdk"
import type { OpenCodeClient } from "@opencode/client/promise"
import { ACPConnection } from "./connection"
import { ACPError } from "./error"
import { ACPService } from "./service"
export function create(client: OpenCodeClient, connection: AgentSideConnection) {
const service = ACPService.make({ client, connection })
return {
initialize: (params: InitializeRequest) => run(service.initialize(params)),
authenticate: (params: AuthenticateRequest) => run(service.authenticate(params)),
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
prompt: (params: PromptRequest) => run(service.prompt(params)),
cancel: (params: CancelNotification) => run(service.cancel(params)),
} satisfies Agent
export function connect(client: OpenCodeClient, stream: Stream) {
const connection = agent({ name: "opencode" })
.onRequest("initialize", (ctx) => run(service.initialize(ctx.params)))
.onRequest("authenticate", (ctx) => run(service.authenticate(ctx.params)))
.onRequest("session/new", (ctx) => run(service.newSession(ctx.params)))
.onRequest("session/load", (ctx) => run(service.loadSession(ctx.params)))
.onRequest("session/list", (ctx) => run(service.listSessions(ctx.params)))
.onRequest("session/delete", (ctx) => run(service.deleteSession(ctx.params)))
.onRequest("session/resume", (ctx) => run(service.resumeSession(ctx.params)))
.onRequest("session/close", (ctx) => run(service.closeSession(ctx.params)))
.onRequest("session/fork", (ctx) => run(service.forkSession(ctx.params)))
.onRequest("session/set_config_option", (ctx) => run(service.setSessionConfigOption(ctx.params)))
.onRequest("session/set_mode", (ctx) => run(service.setSessionMode(ctx.params)))
.onRequest("session/prompt", (ctx) => run(service.prompt(ctx.params, ctx.signal)))
.onNotification("session/cancel", (ctx) => run(service.cancel(ctx.params)))
.connect(stream)
// Inbound dispatch starts after the stream's async read loop yields, so handlers never observe this before assignment.
const service = ACPService.make({ client, connection: ACPConnection.make(connection) })
return connection
}
async function run<A>(promise: Promise<A>) {
@@ -52,6 +38,7 @@ async function run<A>(promise: Promise<A>) {
function isACPError(error: unknown): error is ACPError.Error {
return (
error instanceof ACPError.SessionNotFoundError ||
error instanceof ACPError.SessionDirectoryMismatchError ||
error instanceof ACPError.InvalidConfigOptionError ||
error instanceof ACPError.InvalidModelError ||
error instanceof ACPError.InvalidEffortError ||
+31
View File
@@ -0,0 +1,31 @@
import {
methods,
type AgentConnection,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SendRequestOptions,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from "@agentclientprotocol/sdk"
export type Connection = {
readonly signal?: AbortSignal
sessionUpdate(params: SessionNotification): Promise<void>
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
writeTextFile?(params: WriteTextFileRequest, options?: SendRequestOptions): Promise<WriteTextFileResponse>
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
}
export function make(connection: AgentConnection): Connection {
return {
signal: connection.signal,
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
requestPermission: (params, options) =>
connection.client.request(methods.client.session.requestPermission, params, options),
writeTextFile: (params, options) => connection.client.request(methods.client.fs.writeTextFile, params, options),
extNotification: (method, params) => connection.client.notify(method, params),
}
}
export * as ACPConnection from "./connection"
+11
View File
@@ -5,6 +5,11 @@ export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundErro
sessionId: Schema.String,
}) {}
export class SessionDirectoryMismatchError extends Schema.TaggedError<SessionDirectoryMismatchError>()(
"ACPSessionDirectoryMismatchError",
{ sessionId: Schema.String, cwd: Schema.String },
) {}
export class InvalidConfigOptionError extends Schema.TaggedError<InvalidConfigOptionError>()(
"ACPInvalidConfigOptionError",
{ configId: Schema.String },
@@ -37,6 +42,7 @@ export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>
export type Error =
| SessionNotFoundError
| SessionDirectoryMismatchError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
@@ -49,6 +55,11 @@ export function toRequestError(error: Error): RequestError {
switch (error._tag) {
case "ACPSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPSessionDirectoryMismatchError":
return RequestError.invalidParams(
{ sessionId: error.sessionId, cwd: error.cwd },
`session ${error.sessionId} does not belong to cwd: ${error.cwd}`,
)
case "ACPInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPInvalidModelError":
+8 -5
View File
@@ -1,10 +1,11 @@
import type { AgentSideConnection, PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
import type {
EventSubscribeOutput,
OpenCodeClient,
SessionMessageAssistant,
SessionMessageInfo,
} from "@opencode/client/promise"
import type { ACPConnection } from "./connection"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ACPError } from "./error"
import { replyPermission, syncEditedFiles } from "./permission"
@@ -17,11 +18,11 @@ import {
type ToolInput,
} from "./tool"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
export type TurnControl = {
cancelled: boolean
// Aborted whenever the turn is cancelled or closed, so it also cancels the turn's outbound client requests.
readonly admission: AbortController
}
@@ -160,6 +161,7 @@ export async function streamTurn(input: {
clientSessionID: input.sessionID,
cwd: input.cwd,
tool,
signal: control.admission.signal,
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
})
continue
@@ -269,6 +271,7 @@ export async function streamTurn(input: {
toolName: current.name,
toolInput: current.input,
metadata: event.data.metadata ?? {},
signal: control.admission.signal,
}).catch(() => {})
await send({
sessionUpdate: "tool_call_update",
@@ -417,7 +420,7 @@ function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
}
export async function replayMessages(
connection: Pick<AgentSideConnection, "sessionUpdate">,
connection: Pick<Connection, "sessionUpdate">,
sessionID: string,
cwd: string,
messages: readonly SessionMessageInfo[],
@@ -426,7 +429,7 @@ export async function replayMessages(
}
async function replayMessage(
connection: Pick<AgentSideConnection, "sessionUpdate">,
connection: Pick<Connection, "sessionUpdate">,
sessionID: string,
cwd: string,
message: SessionMessageInfo,
+31 -23
View File
@@ -1,12 +1,13 @@
import type { AgentSideConnection, PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
import { Patch } from "@opencode/util/patch"
import { Result } from "effect"
import { isAbsolute, resolve } from "node:path"
import type { ACPConnection } from "./connection"
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
type Connection = Pick<ACPConnection.Connection, "requestPermission" | "writeTextFile">
type Tool = { readonly name: string; readonly input: ToolInput }
const options: PermissionOption[] = [
@@ -25,31 +26,34 @@ export async function replyPermission(input: {
readonly tool?: Tool
readonly toolCallPrefix?: string
readonly titlePrefix?: string
readonly signal?: AbortSignal
}) {
const toolName = input.tool?.name ?? input.event.data.action
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
const toolCallID = input.event.data.source?.id ?? input.event.data.id
const title = permissionTitle(toolName, toolInput, previews)
const result = await input.connection
.requestPermission({
sessionId: input.clientSessionID ?? input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
})
.catch(() => undefined)
const request = {
sessionId: input.clientSessionID ?? input.sessionID,
toolCall: {
...pendingToolCall({
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
toolName,
state: {
input: toolInput,
title: prefixedTitle(input.titlePrefix, title),
},
cwd: input.cwd,
}),
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
...(previews.length > 0 ? { content: previews } : {}),
},
options,
}
// An already-cancelled turn skips the round-trip; the SDK would still send the request and then cancel it.
const result = input.signal?.aborted
? undefined
: await input.connection.requestPermission(request, { cancellationSignal: input.signal }).catch(() => undefined)
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
const reply = selected === "once" || selected === "always" ? selected : "reject"
await input.client.permission.reply({
@@ -66,13 +70,14 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
}
export async function syncEditedFiles(input: {
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
readonly connection: Pick<ACPConnection.Connection, "writeTextFile">
readonly writeTextFile: boolean
readonly sessionID: string
readonly cwd: string
readonly toolName: string
readonly toolInput: ToolInput
readonly metadata: Readonly<Record<string, unknown>>
readonly signal?: AbortSignal
}) {
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
const files = Array.isArray(input.metadata.files)
@@ -89,7 +94,10 @@ export async function syncEditedFiles(input: {
const target = resolvePath(path, input.cwd)
const file = Bun.file(target)
if (!(await file.exists())) return
await input.connection.writeTextFile?.({ sessionId: input.sessionID, path: target, content: await file.text() })
await input.connection.writeTextFile?.(
{ sessionId: input.sessionID, path: target, content: await file.text() },
{ cancellationSignal: input.signal },
)
}),
)
}
+54 -31
View File
@@ -7,9 +7,9 @@ import {
type SessionInfo,
type SessionMessageInfo,
} from "@opencode/client/promise"
import { FSUtil } from "@opencode/util/fs-util"
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
AuthenticateResponse,
AuthMethod,
@@ -46,6 +46,7 @@ import {
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import type { ACPConnection } from "./connection"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -60,9 +61,6 @@ import { ACPError } from "./error"
export const AuthMethodID = "opencode-login"
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile" | "extNotification" | "signal">>
type Catalog = {
readonly providers: ConfigOptionProvider[]
readonly models: ModelInfo[]
@@ -102,15 +100,18 @@ export interface Interface {
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
prompt(input: PromptRequest): Promise<PromptResponse>
prompt(input: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>
cancel(input: CancelNotification): Promise<void>
}
export function make(input: { readonly client: OpenCodeClient; readonly connection: Connection }): Interface {
export function make(input: {
readonly client: OpenCodeClient
readonly connection: ACPConnection.Connection
}): Interface {
const sessions = new Map<string, Attached>()
const catalogs = new Map<string, Promise<Catalog>>()
const registeredMcp = new Map<string, Set<string>>()
const active = new Map<string, TurnControl>()
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
const capabilities = { writeTextFile: false, childSessionUpdates: false }
const catalog = (cwd: string) => {
@@ -136,6 +137,15 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
registeredMcp.delete(sessionID)
}
const cancelTurn = (sessionID: string) => {
const turn = active.get(sessionID)
if (turn) {
turn.control.cancelled = true
turn.control.admission.abort()
}
return input.client.session.interrupt({ sessionID })
}
const attach = async (session: SessionInfo, cwd: string, mcpServers: readonly McpServer[]) => {
const currentCatalog = await catalog(cwd)
sessions.get(session.id)?.abort.abort()
@@ -216,7 +226,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return { sessionId: state.id, configOptions: configOptions(state) }
},
loadSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const session = await getSession(input.client, params.sessionId, params.cwd)
const state = await attach(session, session.location.directory, params.mcpServers)
await replay(state)
return { configOptions: configOptions(state) }
@@ -246,18 +256,17 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
return {}
},
resumeSession: async (params) => {
const session = await getSession(input.client, params.sessionId)
const session = await getSession(input.client, params.sessionId, params.cwd)
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
return { configOptions: configOptions(state) }
},
closeSession: async (params) => {
detach(params.sessionId)
const turn = active.get(params.sessionId)
if (turn) {
turn.cancelled = true
turn.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
await cancelTurn(params.sessionId).catch((error) => {
if (!isSessionNotFoundError(error)) throw error
})
await turn?.turn.catch(() => {})
detach(params.sessionId)
return {}
},
forkSession: async (params) => {
@@ -303,7 +312,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
return {}
},
prompt: async (params) => {
prompt: async (params, signal) => {
const state = await requireSession(params.sessionId)
if (active.has(state.id)) {
throw new ACPError.ServiceFailureError({
@@ -319,8 +328,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
capabilities.childSessionUpdates && extNotification
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
: undefined
active.set(state.id, control)
const response = await streamTurn({
// A `$/cancel_request` for this prompt behaves like `session/cancel` for its turn.
const cancel = () => void cancelTurn(state.id).catch(() => {})
const turn = streamTurn({
client: input.client,
connection: input.connection,
sessionID: state.id,
@@ -333,19 +343,23 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessionSignal: state.abort.signal,
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
...(childSessionUpdate ? { childSessionUpdate } : {}),
}).finally(() => {
if (active.get(state.id) === control) active.delete(state.id)
})
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
.then(async (response) => {
await sendUsageUpdate(input.client, input.connection, state, response.usage?.totalTokens).catch(() => {})
return response
})
.finally(() => {
signal?.removeEventListener("abort", cancel)
if (active.get(state.id)?.control === control) active.delete(state.id)
})
active.set(state.id, { control, turn })
signal?.addEventListener("abort", cancel, { once: true })
// The cancel may already be buffered behind the awaits above.
if (signal?.aborted) cancel()
return turn
},
cancel: async (params) => {
const current = active.get(params.sessionId)
if (current) {
current.cancelled = true
current.admission.abort()
}
await input.client.session.interrupt({ sessionID: params.sessionId }).catch(() => {})
await cancelTurn(params.sessionId).catch(() => {})
},
}
}
@@ -472,11 +486,15 @@ async function selectMode(client: OpenCodeClient, state: Attached, modeID: strin
await client.session.switchAgent({ sessionID: state.id, agent: modeID })
}
async function getSession(client: OpenCodeClient, sessionID: string) {
return client.session.get({ sessionID }).catch((error) => {
async function getSession(client: OpenCodeClient, sessionID: string, cwd: string) {
const session = await client.session.get({ sessionID }).catch((error) => {
if (isSessionNotFoundError(error)) throw new ACPError.SessionNotFoundError({ sessionId: sessionID })
throw error
})
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory)) {
throw new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
}
return session
}
async function messages(client: OpenCodeClient, sessionID: string) {
@@ -542,7 +560,12 @@ function stableStringify(value: unknown): string {
.join(",")}}`
}
async function sendUsageUpdate(client: OpenCodeClient, connection: Connection, session: Attached, used?: number) {
async function sendUsageUpdate(
client: OpenCodeClient,
connection: ACPConnection.Connection,
session: Attached,
used?: number,
) {
if (!used) return
const model = session.catalog.models.find(
(item) => item.providerID === session.model.providerID && item.id === session.model.id,
+10
View File
@@ -163,6 +163,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Argument.optional,
),
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
answer: Flag.string("answer").pipe(
Flag.withDescription("Provider form answer (key=value; repeat for multiple fields)"),
Flag.atMost(100),
),
},
}),
Spec.make("logout", {
@@ -484,6 +488,12 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
}),
],
}),
Spec.make("reload", {
description: "Reload configuration",
params: {
...ServerParams,
},
}),
Spec.make("pair", {
description: "Show server pairing information",
params: {
+2 -3
View File
@@ -1,4 +1,4 @@
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ndJsonStream } from "@agentclientprotocol/sdk"
import { OpenCode } from "@opencode/client/promise"
import { Service } from "@opencode/client/effect/service"
import { Effect } from "effect"
@@ -26,8 +26,7 @@ export default Runtime.handler(
process.stdin.on("error", (error) => controller.error(error))
},
})
const stream = ndJsonStream(input, output)
const connection = new AgentSideConnection((connection) => ACP.create(client, connection), stream)
const connection = ACP.connect(client, ndJsonStream(input, output))
process.stdin.resume()
yield* Effect.promise(() => connection.closed)
// EOF owns this stdio process; exiting also closes the private server's lease pipe.
@@ -1,29 +1,72 @@
import { confirm, log, multiselect, password, select, text, type Option } from "@clack/prompts"
import { Effect } from "effect"
import { Effect, Schema } from "effect"
import { Form } from "@opencode/schema/form"
import { formValidateValue } from "@opencode/tui/util/form"
import type { FormAnswer, FormField, FormFields } from "@opencode/client"
import { openUrl, prompt, requireInteractive } from "../../../ui/prompt"
const skip = Symbol("skip")
const custom = Symbol("custom")
const decodeAnswer = Schema.decodeUnknownOption(Schema.fromJsonString(Form.Value))
export const answerForm = Effect.fn("cli.auth.form")(function* (fields: FormFields | undefined) {
export const answerForm = Effect.fn("cli.auth.form")(function* (
fields: FormFields | undefined,
input: ReadonlyArray<string> = [],
) {
const supplied = new Map<string, string>()
for (const item of input) {
const separator = item.indexOf("=")
if (separator < 1) return yield* Effect.fail(new Error("Expected --answer key=value"))
const key = item.slice(0, separator)
if (!fields?.some((field) => field.key === key)) {
return yield* Effect.fail(new Error(`Unknown form field: ${key}`))
}
supplied.set(key, item.slice(separator + 1))
}
if (!fields) return undefined
yield* requireInteractive("Authentication form input requires an interactive terminal")
const answer: FormAnswer = {}
for (const field of fields) {
if (!active(field, answer)) continue
const value = yield* answerField(field)
if (!active(field, answer)) {
if (supplied.has(field.key)) {
return yield* Effect.fail(new Error(`Form field is not active: ${field.key}`))
}
continue
}
const provided = supplied.get(field.key)
const value =
provided !== undefined
? yield* suppliedAnswer(field, provided)
: field.type !== "external" && field.hidden
? field.default
: yield* answerField(field)
if (field.type !== "external") {
const invalid = formValidateValue(field, value)
if (invalid) return yield* Effect.fail(new Error(`${field.key}: ${invalid}`))
}
if (value !== undefined) answer[field.key] = value
}
return answer
})
const suppliedAnswer = Effect.fn("cli.auth.form.answer")(function* (field: FormField, input: string) {
if (field.type === "external") {
return yield* Effect.fail(new Error(`External form field requires interactive confirmation: ${field.key}`))
}
if (field.type === "string") return input
const value = decodeAnswer(input)
if (value._tag === "None") {
return yield* Effect.fail(new Error(`Expected a JSON value for form field: ${field.key}`))
}
return typeof value.value === "object" ? [...value.value] : value.value
})
export const secret = Effect.fn("cli.auth.secret")(function* (message: string) {
yield* requireInteractive("API key input requires an interactive terminal")
return yield* prompt<string>(() => password({ message, validate: (value) => (!value ? "Required" : undefined) }))
})
const answerField = Effect.fn("cli.auth.form.field")(function* (field: FormField) {
yield* requireInteractive("Authentication form input requires an interactive terminal")
const message = field.title ?? field.key
if (field.description) log.info(field.description)
if (field.type === "external") {
@@ -33,6 +33,7 @@ export default Runtime.handler(
login({
target: Option.getOrUndefined(input.target),
method: Option.getOrUndefined(input.method),
answer: input.answer,
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
}).pipe(handlePromptErrors),
@@ -42,6 +43,7 @@ export default Runtime.handler(
const login = Effect.fn("cli.auth.login.run")(function* (input: {
target?: string
method?: string
answer?: ReadonlyArray<string>
server?: string
standalone: boolean
}) {
@@ -53,7 +55,7 @@ const login = Effect.fn("cli.auth.login.run")(function* (input: {
const methods = connectMethods(integration)
if (methods.length === 0) yield* Effect.fail(new Error(`${integration.name} has no interactive login methods`))
const method = yield* chooseMethod(methods, input.method)
const answer = method.type === "command" ? undefined : yield* answerForm(method.form)
const answer = yield* answerForm(method.type === "command" ? undefined : method.form, input.answer)
yield* authenticate(client, integration, method, answer)
outro("Done")
})
+15 -2
View File
@@ -47,7 +47,14 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
const update = yield* updater.run().pipe(Effect.forkScoped)
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -92,7 +99,13 @@ export default Runtime.handler(Commands, (input) =>
),
{ signal },
),
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
)
},
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
@@ -0,0 +1,23 @@
import { EOL } from "node:os"
import { Effect, Option } from "effect"
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { ServerConnection } from "../../services/server-connection"
export default Runtime.handler(
Commands.commands.reload,
Effect.fn("cli.reload")(function* (input: Runtime.Input<typeof Commands.commands.reload>) {
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
standalone: input.standalone,
})
const client = OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) })
yield* Effect.tryPromise({
try: (signal) => client.location.reload({ signal }),
catch: (cause) => cause,
})
process.stdout.write("Configuration reloaded" + EOL)
}),
)
+1
View File
@@ -55,6 +55,7 @@ const Handlers = Runtime.handlers(Commands, {
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
reload: () => import("./commands/handlers/reload"),
session: {
list: () => import("./commands/handlers/session/list"),
delete: () => import("./commands/handlers/session/delete"),
-16
View File
@@ -13,7 +13,6 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -159,21 +158,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.run().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
+4 -15
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -13,7 +13,7 @@ export type RunResult = { readonly type: "available" | "installed"; readonly ver
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly run: () => Effect.Effect<RunResult | undefined>
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
@@ -24,18 +24,6 @@ export interface Interface {
| undefined
}
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
export function decodePolicy(text: string): Policy | undefined {
@@ -287,10 +275,11 @@ const make = Effect.gen(function* () {
})
const run = Effect.fn("cli.updater.run")(
function* () {
function* (onInstall: (version: string) => void = () => {}) {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
onInstall(result.version)
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
@@ -0,0 +1,84 @@
import type { CloseSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
import { describe, expect, test } from "bun:test"
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
// The mock model holds any completion whose request mentions "hold" until the test releases it,
// so cancellation can be exercised while the model turn is genuinely in flight.
function heldModel() {
const started = Promise.withResolvers<void>()
const release = Promise.withResolvers<string>()
return {
started: started.promise,
release: () => release.resolve("released"),
respond(request: unknown) {
if (!JSON.stringify(request).includes("hold")) return "accepted"
started.resolve()
return release.promise
},
}
}
describe("acp cancel subprocess", () => {
test("$/cancel_request cancels the in-flight prompt and the session stays usable", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const session = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
await acp.notify("$/cancel_request", { requestId: prompt.id })
expect(expectOk(await prompt.response).stopReason).toBe("cancelled")
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [{ type: "text", text: "again" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
test("session/close settles the active prompt before responding and leaves other sessions alone", async () => {
const model = heldModel()
await using fixture = await createAcpFixture({ respond: model.respond })
const acp = fixture.spawn()
await initialize(acp)
const closing = await newSession(acp, fixture.home)
const other = await newSession(acp, fixture.home)
const prompt = acp.send<PromptResponse>("session/prompt", {
sessionId: closing.sessionId,
prompt: [{ type: "text", text: "hold" }],
})
await model.started
const order: string[] = []
const close = acp
.request<CloseSessionResponse>("session/close", { sessionId: closing.sessionId })
.then((response) => {
order.push("close")
return response
})
const cancelled = await prompt.response.then((response) => {
order.push("prompt")
return response
})
expect(expectOk(cancelled).stopReason).toBe("cancelled")
expect(expectOk(await close)).toEqual({})
expect(order).toEqual(["prompt", "close"])
model.release()
const next = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: other.sessionId,
prompt: [{ type: "text", text: "still here" }],
}),
)
expect(next.stopReason).toBe("end_turn")
}, 60_000)
})
@@ -51,6 +51,12 @@ describe("acp lifecycle subprocess", () => {
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const mismatched = await acp.request<LoadSessionResponse>("session/load", {
cwd: fixture.root,
sessionId: session.sessionId,
mcpServers: [],
})
expect(mismatched.error?.code).toBe(-32602)
}, 60_000)
test("list request includes a live ACP-created session", async () => {
@@ -1,20 +1,25 @@
import { describe, expect, test } from "bun:test"
import type { AgentSideConnection, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import type {
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
} from "@agentclientprotocol/sdk"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { ACPConnection } from "../../src/acp/connection"
import { streamTurn } from "../../src/acp/event"
import { syncEditedFiles } from "../../src/acp/permission"
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
Partial<Pick<AgentSideConnection, "writeTextFile">>
type SessionUpdateParams = SessionNotification
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission" | "writeTextFile">
type Fixture = ReturnType<typeof createSseFixture>
describe("acp permission behavior", () => {
test("does not sync edits when writeTextFile was not advertised", async () => {
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const writes: WriteTextFileRequest[] = []
await syncEditedFiles({
connection: {
@@ -216,7 +221,7 @@ describe("acp permission behavior", () => {
const file = path.join(cwd, "file.ts")
await fs.writeFile(file, "before")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const writes: WriteTextFileRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_edit", inboxID: id }))
@@ -306,7 +311,7 @@ describe("acp permission behavior", () => {
"*** End Patch",
].join("\n")
const permissionRequests: RequestPermissionRequest[] = []
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
const writes: WriteTextFileRequest[] = []
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_patch", inboxID: id }))
@@ -536,6 +541,55 @@ describe("acp permission behavior", () => {
await fixture.stop()
}
})
test("cancelling the turn cancels its pending permission request and rejects the permission", async () => {
const requested = Promise.withResolvers<AbortSignal | undefined>()
const fixture = createSseFixture({
onPrompt({ id, send }) {
send(durableEvent("session.inbox.delivered", { sessionID: "ses_cancel", inboxID: id }))
send(permissionAsked("ses_cancel", "perm_cancel"))
},
onPermissionReply({ send }) {
send(durableEvent("session.execution.interrupted", { sessionID: "ses_cancel", reason: "user" }))
},
})
const connection = {
sessionUpdate: async () => {},
// Behaves like a client answering the agent's `$/cancel_request` with a cancelled outcome.
requestPermission: (_request, options) =>
new Promise<RequestPermissionResponse>((resolve) => {
options?.cancellationSignal?.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), {
once: true,
})
requested.resolve(options?.cancellationSignal)
}),
} satisfies Connection
const control = { cancelled: false, admission: new AbortController() }
const result = streamTurn({
client: fixture.client,
connection,
sessionID: "ses_cancel",
cwd: "/workspace",
start: { type: "input", id: "input_cancel" },
writeTextFile: false,
control,
submit: (signal) =>
fixture.client.session.prompt({ sessionID: "ses_cancel", id: "input_cancel", text: "hello" }, { signal }),
})
try {
const signal = await withTimeout(requested.promise, "permission was not requested")
expect(signal?.aborted).toBe(false)
control.cancelled = true
control.admission.abort()
expect(await withTimeout(result, "cancelled turn did not finish")).toMatchObject({ stopReason: "cancelled" })
expect(permissionReplies(fixture)).toEqual([["perm_cancel", "reject"]])
} finally {
await result.catch(() => undefined)
await fixture.stop()
}
})
})
function startTurn(fixture: Fixture, connection: Connection, sessionID: string, inboxID: string, cwd = "/workspace") {
+2 -2
View File
@@ -1,4 +1,4 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import {
OpenCode,
type AgentInfo,
@@ -114,7 +114,7 @@ export function makeSession(
export function makeACPFixture(options: FixtureOptions = {}) {
const requests: FixtureRequest[] = []
const updates: Parameters<AgentSideConnection["sessionUpdate"]>[0][] = []
const updates: SessionNotification[] = []
const encoder = new TextEncoder()
let eventController: ReadableStreamDefaultController<Uint8Array> | undefined
const models = options.models ?? [testModel, secondModel]
@@ -94,20 +94,24 @@ describe("acp service lifecycle", () => {
})
const loaded = await fixture.service.loadSession({
cwd: "/ignored",
cwd: "/workspace",
sessionId: "ses_loaded",
mcpServers: [],
})
const resumed = await fixture.service.resumeSession({
cwd: "/ignored",
cwd: "/workspace",
sessionId: "ses_resume",
mcpServers: [],
})
const forked = await fixture.service.forkSession({
cwd: "/ignored",
cwd: "/workspace",
sessionId: "ses_loaded",
mcpServers: [],
})
const mismatched = await fixture.service
.loadSession({ cwd: "/elsewhere", sessionId: "ses_loaded", mcpServers: [] })
.catch((error: unknown) => error)
expect(mismatched).toMatchObject({ _tag: "ACPSessionDirectoryMismatchError", cwd: "/elsewhere" })
expect(currentValue(loaded, "model")).toBe("test/second-model")
expect(currentValue(loaded, "effort")).toBe("medium")
@@ -214,7 +218,8 @@ describe("acp service lifecycle", () => {
])
})
test("cancel preserves the attachment while close removes it and interrupts best-effort", async () => {
test("cancel preserves the attachment while close removes it and surfaces interrupt failures", async () => {
const interrupt = { fail: true }
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -223,8 +228,14 @@ describe("acp service lifecycle", () => {
if (request.method === "POST" && request.path === "/api/session/ses_lifecycle/model") {
return new Response(null, { status: 204 })
}
if (request.method === "POST" && request.path === "/api/session/missing/interrupt") {
return Response.json(
{ _tag: "SessionNotFoundError", sessionID: "missing", message: "session not found" },
{ status: 404 },
)
}
if (request.method === "POST" && request.path.endsWith("/interrupt")) {
return new Response(null, { status: 500 })
return interrupt.fail ? new Response(null, { status: 500 }) : Response.json({ interrupted: false })
}
return undefined
},
@@ -239,6 +250,9 @@ describe("acp service lifecycle", () => {
})
expect(currentValue(updated, "effort")).toBe("high")
const failed = await fixture.service.closeSession({ sessionId: created.sessionId }).catch((error: unknown) => error)
expect(failed).toMatchObject({ name: "ClientError", reason: "UnexpectedStatus" })
interrupt.fail = false
expect(await fixture.service.closeSession({ sessionId: created.sessionId })).toEqual({})
const missing = await fixture.service
.setSessionConfigOption({
@@ -252,6 +266,7 @@ describe("acp service lifecycle", () => {
expect(
fixture.requests.filter((request) => request.path.endsWith("/interrupt")).map((request) => request.path),
).toEqual([
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/ses_lifecycle/interrupt",
"/api/session/missing/interrupt",
+30 -12
View File
@@ -46,6 +46,11 @@ type Waiter = {
export type AcpProcess = {
readonly request: <T>(method: string, params?: unknown) => Promise<JsonRpcResponse<T>>
readonly send: <T>(
method: string,
params?: unknown,
) => { readonly id: number; readonly response: Promise<JsonRpcResponse<T>> }
readonly notify: (method: string, params: unknown) => Promise<void>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
@@ -64,7 +69,9 @@ description: Verifier compatibility skill.
# Verifier Skill
`
export async function createAcpFixture(options: { readonly skill?: string } = {}) {
export async function createAcpFixture(
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
@@ -84,8 +91,9 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
if (request.method !== "POST" || new URL(request.url).pathname !== "/v1/chat/completions") {
return new Response("Not found", { status: 404 })
}
requests.push(await request.json().catch(() => undefined))
return new Response(completion("accepted"), {
const body: unknown = await request.json().catch(() => undefined)
requests.push(body)
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
headers: { "content-type": "text/event-stream" },
})
},
@@ -294,18 +302,28 @@ function spawnAcp(input: { readonly env: Record<string, string | undefined> }):
})
}
return {
async request<T>(method: string, params?: unknown) {
if (inputClosed) throw new Error("ACP stdin is closed")
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
await child.stdin.write(encoder.encode(`${JSON.stringify(request)}\n`))
await child.stdin.flush()
const write = async (message: JsonRpcRequest | JsonRpcNotification<unknown>) => {
if (inputClosed) throw new Error("ACP stdin is closed")
await child.stdin.write(encoder.encode(`${JSON.stringify(message)}\n`))
await child.stdin.flush()
}
const send = <T>(method: string, params?: unknown) => {
const id = nextID++
const request: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
const response = write(request).then(async () => {
const response = await take((message) => isResponse(message) && message.id === id, 20_000, `${method} response`)
if (!isResponse<T>(response)) throw new Error(`Invalid ACP response: ${JSON.stringify(response)}`)
return response
},
})
return { id, response }
}
return {
request: <T>(method: string, params?: unknown) => send<T>(method, params).response,
send,
notify: (method: string, params: unknown) => write({ jsonrpc: "2.0", method, params }),
async waitForNotification<T>(method: string, predicate: (params: T) => boolean, timeoutMs = 20_000) {
const notification = await take(
(message) => isNotification<T>(message) && message.method === method && predicate(message.params),
+105
View File
@@ -0,0 +1,105 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import type { FormFields } from "@opencode/client"
import { answerForm } from "../src/commands/handlers/auth/form"
test("resolves hidden defaults without an interactive terminal", async () => {
const fields = [
{ type: "string", key: "server", hidden: true, default: "https://example.com/console" },
{ type: "boolean", key: "enabled", hidden: true, default: false },
{ type: "integer", key: "count", hidden: true, default: 0 },
{ type: "string", key: "optional", hidden: true },
] satisfies FormFields
expect(await Effect.runPromise(answerForm(fields))).toEqual({
server: "https://example.com/console",
enabled: false,
count: 0,
})
expect(await Effect.runPromise(answerForm(fields, ["server=https://staging.example.com/console"]))).toEqual({
server: "https://staging.example.com/console",
enabled: false,
count: 0,
})
})
test("parses typed answers and evaluates conditions in form order", async () => {
const fields = [
{ type: "string", key: "deployment", options: [{ value: "enterprise", label: "Enterprise" }] },
{ type: "string", key: "host", when: [{ key: "deployment", op: "eq", value: "enterprise" }] },
{ type: "boolean", key: "enabled" },
{ type: "integer", key: "count", minimum: 0 },
{ type: "number", key: "rate" },
{ type: "multiselect", key: "scopes", options: [{ value: "read", label: "Read" }] },
] satisfies FormFields
expect(
await Effect.runPromise(
answerForm(fields, [
"host=example.com",
"deployment=enterprise",
"enabled=false",
"count=0",
"rate=1.5",
'scopes=["read"]',
]),
),
).toEqual({ deployment: "enterprise", host: "example.com", enabled: false, count: 0, rate: 1.5, scopes: ["read"] })
})
test("preserves empty strings and equals signs in supplied text", async () => {
expect(
await Effect.runPromise(
answerForm(
[
{ type: "string", key: "server" },
{ type: "string", key: "optional" },
],
["server=https://example.com/console?tenant=a=b", "optional="],
),
),
).toEqual({ server: "https://example.com/console?tenant=a=b", optional: "" })
})
const invalidAnswers: { fields: FormFields | undefined; supplied: string[]; error: string }[] = [
{ fields: undefined, supplied: ["missing-separator"], error: "Expected --answer key=value" },
{ fields: undefined, supplied: ["=missing-key"], error: "Expected --answer key=value" },
{ fields: undefined, supplied: ["unknown=value"], error: "Unknown form field: unknown" },
{
fields: [{ type: "string", key: "known" }],
supplied: ["unknown=value"],
error: "Unknown form field: unknown",
},
{
fields: [
{ type: "boolean", key: "enabled", hidden: true, default: false },
{ type: "string", key: "host", when: [{ key: "enabled", op: "eq", value: true }] },
],
supplied: ["host=example.com"],
error: "Form field is not active: host",
},
{ fields: [{ type: "integer", key: "count" }], supplied: ["count=1.5"], error: "Expected an integer" },
{ fields: [{ type: "boolean", key: "enabled" }], supplied: ["enabled=yes"], error: "Expected a JSON value" },
{
fields: [{ type: "string", key: "server", format: "uri", hidden: true }],
supplied: ["server=not a URL"],
error: "Expected a URL",
},
{
fields: [{ type: "string", key: "required", hidden: true, required: true }],
supplied: [],
error: "Answer required",
},
{
fields: [{ type: "multiselect", key: "scopes", options: [{ value: "read", label: "Read" }] }],
supplied: ['scopes=["write"]'],
error: "Select only available options",
},
{
fields: [{ type: "external", key: "approve", url: "https://example.com" }],
supplied: ["approve=true"],
error: "requires interactive confirmation",
},
]
test.each(invalidAnswers)("rejects invalid form answers: $error", async (input) => {
await expect(Effect.runPromise(answerForm(input.fields, input.supplied))).rejects.toThrow(input.error)
})
+49 -13
View File
@@ -1,6 +1,11 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { OPENCODE_VERSION } from "../src/version"
import type { FormFields } from "@opencode/client"
const consoleForm = [
{ type: "string", key: "server", hidden: true, format: "uri", default: "https://opencode.ai/console" },
] satisfies FormFields
describe("auth command", () => {
test("registers authentication commands", async () => {
@@ -28,6 +33,7 @@ describe("auth command", () => {
expect(login.stdout).toContain("opencode auth login [flags] [<target>]")
expect(login.stdout).toContain("Integration ID, name, or well-known provider URL")
expect(login.stdout).toContain("--method")
expect(login.stdout).toContain("--answer")
expect(logout.exitCode).toBe(0)
expect(logout.stdout).toContain("opencode auth logout [flags] [<target>] [<credential>]")
})
@@ -135,23 +141,52 @@ describe("auth command", () => {
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/company/connect/command/con_test" })
})
test("completes automatic OAuth authentication", async () => {
test.each([
{ id: "openai", method: "browser", form: undefined, args: [], answer: undefined },
{
id: "opencode",
method: "device",
form: consoleForm,
args: [],
answer: { server: "https://opencode.ai/console" },
},
{
id: "opencode",
method: "device",
form: consoleForm,
args: ["--answer", "server=https://staging.example.com/console"],
answer: { server: "https://staging.example.com/console" },
},
{
id: "company",
method: "browser",
form: [
{ type: "string", key: "tenant", required: true },
{ type: "boolean", key: "enabled", required: true },
] satisfies FormFields,
args: ["--answer", "tenant=team=one", "--answer", "enabled=false"],
answer: { tenant: "team=one", enabled: false },
},
])("completes $id/$method OAuth with supplied answers $args", async (input) => {
const requests: Array<{ method: string; path: string }> = []
using server = authServer((request, url) => {
const bodies: unknown[] = []
const endpoint = `/api/integration/${input.id}/connect/oauth`
using server = authServer(async (request, url) => {
requests.push({ method: request.method, path: url.pathname })
if (url.pathname === "/api/integration") {
return Response.json(
located([
{
id: "openai",
name: "OpenAI",
methods: [{ id: "browser", type: "oauth", label: "Browser" }],
id: input.id,
name: input.id,
methods: [{ id: input.method, type: "oauth", label: "Browser", form: input.form }],
connections: [],
},
]),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth" && request.method === "POST") {
if (url.pathname === endpoint && request.method === "POST") {
bodies.push(await request.json())
return Response.json(
located({
attemptID: "con_oauth",
@@ -162,22 +197,23 @@ describe("auth command", () => {
}),
)
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "GET") {
if (url.pathname === `${endpoint}/con_oauth` && request.method === "GET") {
return Response.json(located({ status: "complete", time: { created: 1, expires: 2 } }))
}
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
if (url.pathname === `${endpoint}/con_oauth` && request.method === "DELETE") {
return new Response(null, { status: 204 })
}
return new Response("Not found", { status: 404 })
})
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
const result = await cli(["auth", "login", input.id, "--server", server.url.toString(), ...input.args])
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(result.stdout).toContain("https://example.com/authorize")
expect(result.stdout).toContain("Connected to OpenAI")
expect(requests).toContainEqual({ method: "POST", path: "/api/integration/openai/connect/oauth" })
expect(requests).toContainEqual({ method: "GET", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(requests).toContainEqual({ method: "DELETE", path: "/api/integration/openai/connect/oauth/con_oauth" })
expect(result.stdout).toContain(`Connected to ${input.id}`)
expect(bodies).toEqual([{ methodID: input.method, ...(input.answer ? { answer: input.answer } : {}) }])
expect(requests).toContainEqual({ method: "POST", path: endpoint })
expect(requests).toContainEqual({ method: "GET", path: `${endpoint}/con_oauth` })
expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` })
})
test("settles the OAuth spinner when status polling fails", async () => {
@@ -63,6 +63,7 @@ function stepStarted(): V2Event {
type: "session.step.started",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: {
started: 1,
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
agent: "build",
-24
View File
@@ -1,24 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"repository": {
+1
View File
@@ -817,6 +817,7 @@ export type SessionLogOutput =
readonly agent: Agent.ID
readonly model: Model.Ref
readonly snapshot?: (string & Brand.Brand<"Snapshot.ID">) | undefined
readonly started: number
}
}
| {
+59 -2
View File
@@ -712,7 +712,14 @@ export type SessionStepStarted = {
type: "session.step.started"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { sessionID: string; assistantMessageID: string; agent: string; model: ModelRef; snapshot?: string }
data: {
sessionID: string
assistantMessageID: string
agent: string
model: ModelRef
snapshot?: string
started: number
}
}
export type SessionStepStreamed = {
@@ -1360,6 +1367,7 @@ export type FormNumberField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "number"
minimum?: number | "Infinity" | "-Infinity" | "NaN"
@@ -1372,6 +1380,7 @@ export type FormIntegerField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "integer"
minimum?: number | "Infinity" | "-Infinity" | "NaN"
@@ -1384,6 +1393,7 @@ export type FormBooleanField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "boolean"
default?: boolean
@@ -1394,6 +1404,7 @@ export type FormStringField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
@@ -1411,6 +1422,7 @@ export type FormMultiselectField = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen>
type: "multiselect"
options: Array<FormOption>
@@ -1568,6 +1580,7 @@ export type FormStringField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "string"
format?: "email" | "uri" | "date" | "date-time"
@@ -1585,6 +1598,7 @@ export type FormNumberField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "number"
minimum?: number
@@ -1597,6 +1611,7 @@ export type FormIntegerField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "integer"
minimum?: number
@@ -1609,6 +1624,7 @@ export type FormBooleanField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "boolean"
default?: boolean
@@ -1619,6 +1635,7 @@ export type FormMultiselectField1 = {
title?: string
description?: string
required?: boolean
hidden?: boolean
when?: Array<FormWhen1>
type: "multiselect"
options: Array<FormOption>
@@ -2118,7 +2135,7 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
policies?: Array<{ action: "provider.use" | "permission"; resource: string; effect: "allow" | "deny" }>
}
}
}
@@ -4451,6 +4468,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4475,6 +4493,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4490,6 +4509,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4505,6 +4525,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4518,6 +4539,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4548,6 +4570,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4572,6 +4595,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4587,6 +4611,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4602,6 +4627,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4615,6 +4641,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4652,6 +4679,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4676,6 +4704,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4691,6 +4720,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4706,6 +4736,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4719,6 +4750,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4749,6 +4781,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4773,6 +4806,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4788,6 +4822,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4803,6 +4838,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4816,6 +4852,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4853,6 +4890,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4877,6 +4915,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4892,6 +4931,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4907,6 +4947,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4920,6 +4961,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4950,6 +4992,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4974,6 +5017,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -4989,6 +5033,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5004,6 +5049,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5017,6 +5063,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5054,6 +5101,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5078,6 +5126,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5093,6 +5142,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5108,6 +5158,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5121,6 +5172,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5151,6 +5203,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5175,6 +5228,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5190,6 +5244,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5205,6 +5260,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
@@ -5218,6 +5274,7 @@ export type SessionFormCreateInput = {
readonly title?: string
readonly description?: string
readonly required?: boolean
readonly hidden?: boolean
readonly when?: ReadonlyArray<{
readonly key: string
readonly op: "eq" | "neq"
+14 -1
View File
@@ -847,6 +847,7 @@ export function createData(config: CreateDataInput) {
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
@@ -865,7 +866,7 @@ export function createData(config: CreateDataInput) {
metadata: event.metadata,
content: [],
snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined,
time: { created: event.created },
time: { created: event.data.started },
})
})
return
@@ -1042,6 +1043,18 @@ export function createData(config: CreateDataInput) {
: "interrupted",
time: { created: event.created },
})
if (
store.session.message[event.data.sessionID]?.some(
(item) =>
item.type === "assistant" &&
item.content.some(
(part) => part.type === "tool" && (part.state.status === "streaming" || part.state.status === "running"),
),
)
) {
sync.invalidate(`session.message:${event.data.sessionID}`)
refresh(() => result.session.message.sync(event.data.sessionID))
}
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+74
View File
@@ -52,6 +52,80 @@ test("uses the configured initial window and retains normal cursor page sizes",
}
})
test("reconciles a stale running tool when execution settles", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
let completed = false
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
requests++
return Response.json({
data: [
{
id: "msg_assistant",
type: "assistant",
agent: "build",
model: { providerID: "provider", id: "model" },
time: { created: 1, ...(completed ? { completed: 2 } : {}) },
content: [
{
type: "tool",
id: "call_execute",
name: "execute",
time: { created: 1, ran: 1, ...(completed ? { completed: 2 } : {}) },
state: completed
? { status: "completed", input: {}, metadata: {}, content: [] }
: { status: "running", input: {}, metadata: {} },
},
],
},
],
cursor: {},
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
completed = true
const interrupted: OpenCodeEvent = {
id: "evt_interrupted",
created: 3,
type: "session.execution.interrupted",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh", reason: "user" },
}
listeners.forEach((listener) => listener({ name: interrupted.type, details: interrupted }))
await wait(
() =>
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.type === "tool" &&
setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]?.state.status === "completed",
)
expect(requests).toBe(2)
expect(setup.data.session.message.get("ses_refresh", "msg_assistant")?.content[0]).toMatchObject({
state: { status: "completed" },
})
} finally {
setup.dispose()
}
})
test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.8",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+148
View File
@@ -0,0 +1,148 @@
{
"openapi": "3.1.0",
"info": {
"title": "CodeMode Transport Coverage",
"version": "1.0.0"
},
"paths": {
"/records/{recordID}": {
"get": {
"operationId": "records.get",
"parameters": [
{
"name": "recordID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Record",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Record"
}
}
}
}
}
}
},
"/events": {
"get": {
"operationId": "events.subscribe",
"responses": {
"200": {
"description": "Events",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
}
}
}
},
"/files/{path}": {
"get": {
"operationId": "files.read",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "File",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"put": {
"operationId": "files.write",
"parameters": [
{
"name": "path",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"responses": {
"204": {
"description": "Written"
}
}
}
},
"/terminals/{terminalID}/connect": {
"get": {
"operationId": "terminals.connect",
"x-websocket": true,
"parameters": [
{
"name": "terminalID",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"101": {
"description": "Connected"
}
}
}
}
},
"components": {
"schemas": {
"Record": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": ["id", "value"],
"additionalProperties": false
}
}
}
}
+44 -107
View File
@@ -14,8 +14,8 @@ type Recorded = {
readonly body: unknown
}
const opencodeSpec = async (): Promise<Document> => {
return Bun.file(new URL("../../protocol/openapi.json", import.meta.url)).json() as Promise<Document>
const transportSpec = async (): Promise<Document> => {
return Bun.file(new URL("./fixtures/openapi-transports.json", import.meta.url)).json() as Promise<Document>
}
const happyPathSpec = async (): Promise<Document> => {
@@ -219,48 +219,42 @@ describe("OpenAPI.fromSpec", () => {
expect(client.requests[3]!.headers.authorization).toBe("Bearer bearer-secret")
})
test("converts representative opencode operations into the expected tool shape", async () => {
const spec = await opencodeSpec()
test("generates supported operations and reports unsupported transports", async () => {
const spec = await transportSpec()
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(result.skipped).toHaveLength(5)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/pty/{ptyID}/connect",
reason: "WebSocket operations are not supported",
})
expect(result.skipped.filter((item) => item.reason === "SSE operations are not supported")).toHaveLength(2)
expect(result.skipped).toContainEqual({
method: "GET",
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "server.info")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
expect(result.skipped).toEqual([
{
method: "GET",
path: "/events",
reason: "SSE operations are not supported",
},
{
method: "GET",
path: "/files/{path}",
reason: "binary responses are not supported",
},
{
method: "PUT",
path: "/files/{path}",
reason: "request body has no JSON content (declared: application/octet-stream)",
},
{
method: "GET",
path: "/terminals/{terminalID}/connect",
reason: "WebSocket operations are not supported",
},
])
const sessionGet = toolAt(result.tools, "session.get")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "session.switchAgent")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "experimental.session.instructions.entry.put")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("experimental.session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "experimental_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
expect(toolAt(result.tools, "session.log")).toBeUndefined()
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
const get = toolAt(result.tools, "records.get")
expect(Tool.isTool(get)).toBe(true)
if (!Tool.isTool(get)) throw new Error("records.get was not generated")
expect(inputTypeScript(get)).toBe("{ recordID: string }")
expect(outputTypeScript(get)).toBe("{ id: string; value: string }")
expect(toolAt(result.tools, "events.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "files.read")).toBeUndefined()
expect(toolAt(result.tools, "files.write")).toBeUndefined()
expect(toolAt(result.tools, "terminals.connect")).toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
@@ -971,30 +965,16 @@ describe("OpenAPI.fromSpec", () => {
expect(result).toMatchObject({ password: "returned-by-server", profile: { secret: "returned-secret" } })
})
test("documents that the opencode fixture is unauthenticated", async () => {
const spec = await opencodeSpec()
const components = isRecord(spec.components) ? spec.components : {}
const result = OpenAPI.fromSpec({ spec, baseUrl })
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const info = toolAt(result.tools, "server.info")
const infoInput = Tool.isTool(info) && isRecord(info.input) ? info.input : undefined
expect(infoInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(infoInput) ? infoInput : {}
expect(Object.keys(isRecord(input.properties) ? input.properties : {})).toStrictEqual([])
})
test("exposes real opencode operations through CodeMode discovery", async () => {
test("exposes generated operations through CodeMode discovery", async () => {
const { layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
tools: { api: OpenAPI.fromSpec({ spec: await happyPathSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
return search({ query: "server info", namespace: "opencode", limit: 1 })
return search({ query: "get a user", namespace: "api", limit: 1 })
`,
)
.pipe(Effect.provide(layer)),
@@ -1005,55 +985,12 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.server.info",
description: "Return the server identity, connection URLs, paths, and readiness status.",
path: "tools.api.users.get",
description: "Get a user",
},
],
})
expect(JSON.stringify(result.value)).toContain("version: string")
})
test("invokes real opencode path parameters and JSON request bodies", async () => {
const { requests, layer } = recordingClient((request) => {
if (request.method === "GET") return json({ id: "ses_123" })
return json({ id: "ses_456" })
})
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime
.execute(
`
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.session.create({ id: "ses_456" })
return { existing, created }
`,
)
.pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: true })
expect(requests).toHaveLength(2)
expect(requests[0]).toMatchObject({ method: "GET", body: undefined })
expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_123")
expect(requests[1]).toMatchObject({
method: "POST",
url: "http://localhost:4096/api/session",
body: { id: "ses_456" },
})
})
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
await Effect.runPromise(location.execute({ location: { directory: "/tmp" } }).pipe(Effect.provide(client.layer)))
const url = new URL(client.requests[0]!.url)
expect(url.searchParams.get("location[directory]")).toBe("/tmp")
expect(JSON.stringify(result.value)).toContain("userId: string")
})
test("serializes supported simple and form parameter shapes", async () => {
@@ -1461,15 +1398,15 @@ describe("OpenAPI.fromSpec", () => {
test("fails missing required parameters before auth and network", async () => {
const { requests, layer } = recordingClient(() => json({}))
const runtime = CodeMode.make({
tools: { opencode: OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools },
tools: { api: OpenAPI.fromSpec({ spec: await transportSpec(), baseUrl }).tools },
})
const result = await Effect.runPromise(
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.api.records.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
expect(JSON.stringify(result)).toContain("Missing required path parameter 'sessionID'")
expect(JSON.stringify(result)).toContain("Missing required path parameter 'recordID'")
expect(requests).toHaveLength(0)
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.8",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.8",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.8",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.8",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.5",
"version": "2.0.8",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+22 -4
View File
@@ -12,16 +12,34 @@ export const Plugin = define({
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.provider.reload())
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
const policies = () =>
loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
yield* ctx.provider.transform((providers) => {
// User-global policy takes priority over policy authored by a repository.
for (const record of providers.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
const policy = policies().findLast(
(policy) => policy.action === "provider.use" && Wildcard.match(record.provider.id, policy.resource),
)
if (policy?.effect === "deny") providers.remove(record.provider.id)
}
})
yield* ctx.permission.hook("evaluate", (event) =>
Effect.sync(() => {
const current = policies()
const denied = event.resources.some((resource) => {
const policy = current.findLast(
(policy) =>
policy.action === "permission" && Wildcard.match(`${event.action}:${resource}`, policy.resource),
)
return policy?.effect === "deny"
})
if (!denied) return
event.effect = "deny"
event.message = "Blocked by configuration policy"
}),
)
}),
})
+29
View File
@@ -0,0 +1,29 @@
export * as NativeCompactionPlugin from "./compaction.js"
import { LLMClient, Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { SessionCompaction } from "../session/compaction.js"
import type { PluginInternal } from "./internal.js"
export const Plugin = define({
id: "opencode.compaction.native",
effect: Effect.fn("NativeCompactionPlugin")(function* () {
const llm = yield* LLMClient.Service
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => {
editor.native((input) => {
const request = input.request
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
return Effect.gen(function* () {
const retained = yield* input.retained
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
})
if (LLMClient.canCompact(request))
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
return undefined
})
})
}),
} satisfies PluginInternal.InternalPlugin)
+6
View File
@@ -1,5 +1,6 @@
export * as PluginInternal from "./internal.js"
import { LLMClient } from "@opencode/ai"
import type { Plugin } from "@opencode/plugin/effect/plugin"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { httpClient } from "@opencode/util/effect/app-node-platform"
@@ -12,6 +13,7 @@ import { Provider } from "../provider.js"
import { Command } from "../command.js"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { llmClient } from "../effect/app-node-platform.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
@@ -84,6 +86,7 @@ import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -120,6 +123,7 @@ const services = [
Integration.Service,
Job.Service,
KV.Service,
LLMClient.Service,
Location.Service,
ModelsDev.Service,
Mcp.Service,
@@ -171,6 +175,7 @@ export const requirements = LayerNode.group([
Integration.node,
Job.node,
KV.node,
llmClient,
Location.node,
ModelsDev.node,
Mcp.node,
@@ -212,6 +217,7 @@ const pre = [
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
NativeCompactionPlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
@@ -45,6 +45,15 @@ function oauth(http: HttpClient.HttpClient) {
id: methodID,
type: "oauth",
label: "OpenCode Console account",
form: [
{
key: "server",
type: "string",
format: "uri",
hidden: true,
default: defaultServer,
},
],
},
authorize: (answer) =>
Effect.gen(function* () {

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