mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-05 15:06:16 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494af523d1 |
No files matched your search
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -179,7 +179,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -205,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +254,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +298,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +322,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +342,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -410,7 +410,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -553,7 +553,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -572,7 +572,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -585,7 +585,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -599,7 +599,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -614,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -683,7 +683,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -722,7 +722,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -743,7 +743,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -800,7 +800,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -820,7 +820,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -854,7 +854,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -873,7 +873,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -919,7 +919,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -933,7 +933,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1002,7 +1002,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1040,7 +1040,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1081,7 +1081,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -54,7 +54,6 @@ const route = Route.make({
|
||||
),
|
||||
},
|
||||
stream: AnthropicMessages.protocol.stream,
|
||||
supportsEffortUpdates: AnthropicMessages.protocol.supportsEffortUpdates,
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
|
||||
@@ -293,28 +293,20 @@ describe("Anthropic Messages effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers markers on the Vertex Anthropic route for models that support them", () =>
|
||||
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
|
||||
Effect.gen(function* () {
|
||||
const vertex = GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" })
|
||||
const opus5 = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertex.model("claude-opus-5"),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
const opus48 = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: vertex.model("claude-opus-4-8"),
|
||||
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
|
||||
"claude-opus-5",
|
||||
),
|
||||
messages: conversation,
|
||||
providerOptions: { effort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(systemMessages(opus5.body)).toEqual([{ role: "system", content: [], output_config: { effort: "low" } }])
|
||||
expect(opus5.body.output_config).toEqual({ effort: "high" })
|
||||
expect(systemMessages(opus48.body)).toHaveLength(0)
|
||||
expect(opus48.body.output_config).toEqual({ effort: "low" })
|
||||
expect(systemMessages(prepared.body)).toHaveLength(0)
|
||||
expect(prepared.body.output_config).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,149 +5,11 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("keeps separate one-shot /btw answers and restores them after reload", async ({ page }, testInfo) => {
|
||||
const prompts: unknown[] = []
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwQuestions",
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: (input) => {
|
||||
generations.push(input)
|
||||
|
||||
return {
|
||||
text: input.prompt.includes("second question")
|
||||
? "Second answer"
|
||||
: `First answer\n\n${"A detailed explanation of the first question.\n\n".repeat(100)}End of first answer`,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await editor.fill("/btw first question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("button", { name: "Send", exact: true })).toHaveCount(0)
|
||||
await panel.getByText("End of first answer", { exact: true }).scrollIntoViewIfNeeded()
|
||||
await expect(panel.getByText("End of first answer", { exact: true })).toBeInViewport()
|
||||
await editor.fill("/btw second question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
const first = page.getByRole("tab", { name: "first question", exact: true })
|
||||
const second = page.getByRole("tab", { name: "second question", exact: true })
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await page.screenshot({ path: testInfo.outputPath("separate-tabs.png") })
|
||||
expect(generations).toHaveLength(2)
|
||||
expect(generations[1]?.prompt).not.toContain("first question")
|
||||
expect(generations[1]?.prompt).not.toContain("First answer")
|
||||
|
||||
await page.reload()
|
||||
await expect(first).toBeVisible()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await first.click()
|
||||
await expect(panel.getByText("First answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toHaveCount(0)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath("restored-answer.png") })
|
||||
|
||||
// Closing a tab forgets its question; the other tab and its answer survive a reload.
|
||||
await first.click({ button: "middle" })
|
||||
await expect(first).toHaveCount(0)
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await expect(second).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Second answer", { exact: true })).toBeVisible()
|
||||
await expect(first).toHaveCount(0)
|
||||
expect(generations).toHaveLength(2)
|
||||
expect(prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("isolates concurrent side questions and makes an interrupted reload retryable", async ({ page }) => {
|
||||
const held = Promise.withResolvers<void>()
|
||||
const attempts: string[] = []
|
||||
const prompts: unknown[] = []
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwConcurrent",
|
||||
onPrompt: (input) => prompts.push(input),
|
||||
generate: async (input) => {
|
||||
attempts.push(input.prompt)
|
||||
|
||||
if (input.prompt.includes("slow question") && attempts.length === 1) {
|
||||
await held.promise
|
||||
|
||||
return { text: "Abandoned answer" }
|
||||
}
|
||||
|
||||
return { text: input.prompt.includes("fast question") ? "Fast answer" : "Retried answer" }
|
||||
},
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await editor.fill("/btw slow question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await editor.fill("/btw fast question")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "slow question", exact: true }).click()
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toHaveCount(0)
|
||||
|
||||
await page.reload()
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("status")).toHaveCount(0)
|
||||
expect(attempts).toHaveLength(2)
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
|
||||
expect(attempts).toHaveLength(3)
|
||||
expect(attempts[2]).toContain("slow question")
|
||||
expect(attempts[2]).not.toContain("fast question")
|
||||
held.resolve()
|
||||
await page.getByRole("tab", { name: "fast question", exact: true }).click()
|
||||
await expect(panel.getByText("Fast answer", { exact: true })).toBeVisible()
|
||||
await page.getByRole("tab", { name: "slow question", exact: true }).click()
|
||||
await expect(panel.getByText("Retried answer", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Abandoned answer", { exact: true })).toHaveCount(0)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
expect(prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps many long-titled side tabs usable at a narrow desktop width", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 900, height: 700 })
|
||||
|
||||
const { editor } = await openSession(page, {
|
||||
name: "BtwOverflow",
|
||||
generate: () => ({ text: "Saved overflow answer" }),
|
||||
})
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
const questions = Array.from({ length: 12 }, (_, index) => `Side ${index + 1}: ${"long-title-".repeat(30)}`)
|
||||
|
||||
for (const question of questions) {
|
||||
await editor.fill(`/btw ${question}`)
|
||||
await editor.press("Enter")
|
||||
await expect(page.getByRole("tab", { name: question, exact: true })).toHaveAttribute("data-selected", "")
|
||||
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(page.getByRole("tab", { name: /^Side \d+:/ })).toHaveCount(12)
|
||||
await expect(panel.getByText("Saved overflow answer", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("narrow-many-tabs.png") })
|
||||
})
|
||||
|
||||
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
|
||||
const generations: { sessionID: string; prompt: string }[] = []
|
||||
const prompts: unknown[] = []
|
||||
const generated = Promise.withResolvers<void>()
|
||||
const leftBehind = Promise.withResolvers<void>()
|
||||
const abandoned = Promise.withResolvers<void>()
|
||||
const main = { id: "ses_btw_sidebar", title: "Side question session" }
|
||||
const other = { id: "ses_btw_sidebar_other", title: "Other side question session" }
|
||||
const ownerWarnings: string[] = []
|
||||
@@ -166,7 +28,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
if (input.sessionID === other.id) return { text: "This answer belongs to the **other session**." }
|
||||
|
||||
if (input.prompt.includes("left behind")) {
|
||||
await leftBehind.promise
|
||||
await abandoned.promise
|
||||
|
||||
return { text: "This answer arrived after the user left." }
|
||||
}
|
||||
@@ -185,6 +47,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await suggestion.click()
|
||||
await expect(editor).toHaveText("/btw ")
|
||||
await editor.press("Enter")
|
||||
|
||||
const panel = page.locator('[data-slot="session-btw-panel"]')
|
||||
await expect(panel).toBeHidden()
|
||||
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
|
||||
@@ -196,10 +59,7 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(panel.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveAttribute("data-selected", "")
|
||||
generated.resolve()
|
||||
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
@@ -215,11 +75,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await editor.fill("/btw what belongs here?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
|
||||
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
|
||||
|
||||
// Leaving a session abandons its in-flight question, so it reads as failed on return.
|
||||
await editor.fill("/btw is this question left behind?")
|
||||
await editor.press("Enter")
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
@@ -227,35 +89,13 @@ test("answers /btw in the side panel without admitting a prompt", async ({ page
|
||||
await expectSessionTitle(page, other.title)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
// Leaving the session does not abandon its question: it is still working, and its answer lands once it arrives.
|
||||
await expect(panel.getByRole("status")).toContainText("Working")
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
leftBehind.resolve()
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toHaveCount(0)
|
||||
expect(generations.filter((item) => item.prompt.includes("left behind"))).toHaveLength(1)
|
||||
await expect(panel.getByText("Couldn’t answer that question", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Retry", exact: true })).toBeVisible()
|
||||
abandoned.resolve()
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "is this question left behind?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
await expect(panel.getByText("This answer arrived after the user left.", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByRole("status")).toHaveCount(0)
|
||||
await page.getByRole("tab", { name: "how does the retry loop work?", exact: true }).click()
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home", exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(main.id)}"]`).click()
|
||||
await expectSessionTitle(page, main.title)
|
||||
await expect(page.getByRole("tab", { name: "how does the retry loop work?", exact: true })).toHaveAttribute(
|
||||
"data-selected",
|
||||
"",
|
||||
)
|
||||
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
|
||||
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
|
||||
await expect(panel).toHaveCount(0)
|
||||
expect(ownerWarnings).toEqual([])
|
||||
})
|
||||
@@ -30,11 +30,9 @@ for (const position of ["top", "bottom"] as const) {
|
||||
const navigation = page.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
const more = navigation.getByRole("button", { name: "More options", exact: true })
|
||||
const picker = tabs.getByRole("tab", { selected: true })
|
||||
|
||||
const message = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
await expect(picker).toHaveText("Session")
|
||||
await expect(message).toBeVisible()
|
||||
@@ -42,17 +40,9 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(tabs.getByRole("tab")).toHaveText(["Session", "Changes", "Files", "Terminal"])
|
||||
await expect(tabs).toHaveCSS("padding-left", "0px")
|
||||
await expect(tabs).toHaveCSS("padding-right", "0px")
|
||||
|
||||
if (position === "top") {
|
||||
const titlebar = page.locator('[data-slot="titlebar-v2"]')
|
||||
await expect(titlebar).toHaveCSS("padding-top", "16px")
|
||||
await expect(titlebar).toHaveCSS("height", "44px")
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await navigation.boundingBox()
|
||||
|
||||
return !!bounds && bounds.x >= 8 && bounds.x <= 9 && bounds.width >= 372 && bounds.width <= 374
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -61,7 +51,6 @@ for (const position of ["top", "bottom"] as const) {
|
||||
const bar = await tabs.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
const panel = await page.locator('[data-slot="session-chat-panel"]').boundingBox()
|
||||
|
||||
return !!bar && !!input && !!panel && Math.abs(bar.y - panel.y) <= 1 && bar.y + bar.height <= input.y
|
||||
})
|
||||
.toBe(true)
|
||||
@@ -160,19 +149,16 @@ for (const position of ["top", "bottom"] as const) {
|
||||
|
||||
// The view resets to Session whenever the routed session changes, including through Home.
|
||||
const trigger = page.locator('[data-slot="mobile-tabs-trigger"]')
|
||||
|
||||
const openTabs = async () => {
|
||||
await trigger.click()
|
||||
await expect(drawer).not.toHaveAttribute("data-transitioning")
|
||||
}
|
||||
|
||||
const openTab = async (title: string) => {
|
||||
await openTabs()
|
||||
await drawer.locator('[data-slot="tab-link"]').filter({ hasText: title }).click()
|
||||
await expect(drawer).toBeHidden()
|
||||
await expect(trigger).toContainText(title)
|
||||
}
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Usage", exact: true }).click()
|
||||
await expect(page.getByText("Total Cost", { exact: true })).toBeVisible()
|
||||
@@ -352,7 +338,6 @@ test.describe("touch", () => {
|
||||
.poll(async () => {
|
||||
const navigation = await settings.getByRole("button", { name: "Models", exact: true }).boundingBox()
|
||||
const content = await panel.boundingBox()
|
||||
|
||||
return !!navigation && !!content && navigation.y + navigation.height <= content.y
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
@@ -66,72 +66,6 @@ for (const endpoint of ["/api/location", "/api/agent"]) {
|
||||
}
|
||||
}
|
||||
|
||||
test("replaces the composer when the location is not found and recovers by moving to another worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = "/projects/deleted-worktree"
|
||||
const destination = "/projects/existing-worktree"
|
||||
const sessionID = "ses_location_not_found"
|
||||
const session = { id: sessionID, projectID: fixture.project.id, directory, title: "Missing location" }
|
||||
const moves: unknown[] = []
|
||||
const transport = await installSseTransport(page, { server: fixture.serverKey })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
provider: fixture.provider,
|
||||
sessions: [session],
|
||||
fileList: () => [],
|
||||
worktrees: [{ directory: fixture.directory }, { directory: destination, strategy: "git" }],
|
||||
pageMessages: () => ({
|
||||
items: [{ id: "msg_saved", type: "user", text: "Keep this session history", time: { created: 1 } }],
|
||||
}),
|
||||
})
|
||||
let requests = 0
|
||||
await page.route("**/api/location?**", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("location[directory]") !== directory) return route.fallback()
|
||||
requests++
|
||||
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
})
|
||||
})
|
||||
await page.route(`**/api/session/${sessionID}/move`, (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
moves.push(route.request().postDataJSON())
|
||||
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
})
|
||||
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionID}`)
|
||||
const status = page.getByRole("status").filter({ hasText: "Session location unavailable" })
|
||||
await expect(status).toContainText(directory)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toHaveCount(0)
|
||||
// A typed not-found answer is final; retrying cannot succeed until the folder changes.
|
||||
expect(requests).toBe(1)
|
||||
|
||||
await transport.waitForConnection()
|
||||
await page.getByRole("button", { name: "Choose worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await expect.poll(() => moves).toEqual([{ directory: destination }])
|
||||
|
||||
session.directory = destination
|
||||
await transport.send({
|
||||
id: "evt_location_not_found_moved",
|
||||
type: "session.moved",
|
||||
created: 2,
|
||||
durable: { aggregateID: sessionID, seq: 1, version: 1 },
|
||||
data: { sessionID, location: { directory: destination }, projectID: fixture.project.id },
|
||||
})
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(page.getByText("Keep this session history", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("follows a live session move while the agent catalog is still loading", async ({ page }) => {
|
||||
const recovery = recoveryRequests(page)
|
||||
const directory = "/projects/old-tree"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { expectPath, SERVER, sessionHref } from "../utils/app"
|
||||
import { currentSession } from "../utils/mock-server"
|
||||
import { assistantMessage, session, sessionID, setupTimeline, textPart, userMessage } from "../utils/timeline"
|
||||
@@ -38,32 +38,6 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)])
|
||||
})
|
||||
|
||||
test("keeps the parent title anchored when opening a subagent", async ({ page }) => {
|
||||
await setup(page)
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))
|
||||
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
const breadcrumb = page.locator('[data-slot="session-title-parent"]')
|
||||
|
||||
await expect(breadcrumb).toHaveText(parentTitle)
|
||||
await expect.poll(() => titleInlineStart(breadcrumb)).toBeCloseTo(start, 0)
|
||||
await breadcrumb.click()
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await expect
|
||||
.poll(() => titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle })))
|
||||
.toBeCloseTo(start, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const runningChildID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
@@ -342,19 +316,6 @@ async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function titleInlineStart(title: Locator) {
|
||||
return title.evaluate((element) => {
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(element)
|
||||
|
||||
const bounds = range.getBoundingClientRect()
|
||||
|
||||
return document.documentElement.dir === "rtl" ? bounds.right : bounds.left
|
||||
})
|
||||
}
|
||||
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@opencode/client/promise"
|
||||
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
@@ -650,43 +650,6 @@ test.describe("Working", () => {
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps Working while a new read joins a standalone read group", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
["one", "two", "three"].map((name) =>
|
||||
toolPart(`prt_read_${name}`, "read", "completed", { path: `src/${name}.ts` }),
|
||||
),
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
settings: { timelineDetail: timelinePresets[0].value },
|
||||
})
|
||||
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="read-tool-group"]')
|
||||
const shimmer = group.locator('[data-component="text-shimmer"]')
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_read_one,prt_read_two,prt_read_three")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "streaming", { path: "src/four.ts" })))
|
||||
await expect(group).toHaveAttribute(
|
||||
"data-timeline-part-ids",
|
||||
"prt_read_one,prt_read_two,prt_read_three,prt_read_four",
|
||||
)
|
||||
await expect(shimmer).toHaveAttribute("data-active", "true")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "running", { path: "src/four.ts" })))
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("one.ts, two.ts, three.ts, four.ts")
|
||||
await expect(working).toBeVisible()
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_read_four", "read", "completed", { path: "src/four.ts" })))
|
||||
await expect(shimmer).toHaveAttribute("data-active", "false")
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [false, true]) {
|
||||
test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
@@ -1012,153 +975,6 @@ test.describe("background shortcut", () => {
|
||||
await expect(backgroundCard).toContainText("Background task (background)")
|
||||
})
|
||||
|
||||
test("hides the running switcher when viewing the only running subagent", async ({ page }) => {
|
||||
const childID = "ses_only_running_child"
|
||||
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Only running task", agent: "explore" }),
|
||||
],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "1 working", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitem", { name: /Only running task/ }).click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${childID}$`))
|
||||
await expect(header.getByRole("heading")).toHaveText("Only running task")
|
||||
await expect(trigger).toHaveCount(0)
|
||||
|
||||
const working = header.getByRole("status", { name: "Working", exact: true })
|
||||
|
||||
await expect(working.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
|
||||
const gap = await working.evaluate((element) => {
|
||||
const title = document.createRange()
|
||||
|
||||
title.selectNodeContents(element.nextElementSibling!)
|
||||
|
||||
return title.getBoundingClientRect().left - element.getBoundingClientRect().right
|
||||
})
|
||||
|
||||
expect(gap).toBe(8)
|
||||
|
||||
const before = await header.getByRole("heading").boundingBox()
|
||||
const exit = await pauseExitAnimations(working)
|
||||
|
||||
await timeline.transport.send(status("idle", 1, childID))
|
||||
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 100)))
|
||||
await expect(working).toBeAttached()
|
||||
|
||||
const midway = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(midway!.x).toBeLessThan(before!.x)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
|
||||
await expect(working).toHaveCount(0)
|
||||
|
||||
const after = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(after!.x).toBeLessThan(midway!.x)
|
||||
expect(after!.x).toBeCloseTo(before!.x - 24, 0)
|
||||
await exit.dispose()
|
||||
await header.locator('[data-slot="session-title-parent"]').click()
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}$`))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the running menu closed when work restarts after all subagents finish", async ({ page }) => {
|
||||
const childID = "ses_restarted_child"
|
||||
const otherID = "ses_counted_child"
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Restarted task", agent: "explore" }),
|
||||
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
|
||||
],
|
||||
sessionStatus: {},
|
||||
})
|
||||
|
||||
const trigger = page.locator("[data-session-title]").getByRole("button", { name: /^[12] working$/ })
|
||||
const menu = page.getByRole("menu", { name: "1 working", exact: true })
|
||||
|
||||
await timeline.transport.send(status("busy", 1, childID))
|
||||
await expect(trigger).toHaveAccessibleName("1 working")
|
||||
await timeline.transport.send(status("busy", 1, otherID))
|
||||
await expect(trigger).toHaveAccessibleName("2 working")
|
||||
await expect(trigger.locator("..")).toHaveCSS("animation-name", "none")
|
||||
await timeline.transport.send(status("idle", 1, otherID))
|
||||
await expect(trigger).toHaveAccessibleName("1 working")
|
||||
// Inspect the update itself, not the settled state after a replayed fade finishes.
|
||||
expect(await trigger.evaluate((element) => getComputedStyle(element.parentElement!).animationName)).toBe("none")
|
||||
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
|
||||
await timeline.transport.send(status("idle", 1, childID))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
await expect(menu).toHaveCount(0)
|
||||
await timeline.transport.send(status("busy", 1, childID))
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(menu).toHaveCount(0)
|
||||
await trigger.click()
|
||||
await expect(menu.getByRole("menuitem", { name: /Restarted task/ })).toBeVisible()
|
||||
})
|
||||
|
||||
test("slides the subagent title left when its running switcher disappears", async ({ page }) => {
|
||||
const childID = "ses_collapsing_viewed"
|
||||
const otherID = "ses_collapsing_sibling"
|
||||
const timeline = await setupTimeline(page, {
|
||||
sessionMessages: [user, completed],
|
||||
sessions: [
|
||||
session(),
|
||||
session({ id: childID, parentID: sessionID, title: "Viewed task", agent: "explore" }),
|
||||
session({ id: otherID, parentID: sessionID, title: "Other task", agent: "build" }),
|
||||
],
|
||||
sessionStatus: { [childID]: { type: "busy" }, [otherID]: { type: "busy" } },
|
||||
})
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const trigger = header.getByRole("button", { name: "2 working", exact: true })
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitem", { name: /Viewed task/ }).click()
|
||||
await expect(header.getByRole("heading")).toHaveText("Viewed task")
|
||||
await expect(trigger).toBeEnabled()
|
||||
|
||||
const before = await header.getByRole("heading").boundingBox()
|
||||
const width = await trigger.evaluate((element) => element.parentElement!.getBoundingClientRect().width)
|
||||
const exit = await pauseExitAnimations(trigger.locator(".."))
|
||||
|
||||
await timeline.transport.send(status("idle", 1, otherID))
|
||||
await expect.poll(() => exit.evaluate((animations) => animations.length)).toBeGreaterThan(0)
|
||||
expect(
|
||||
await exit.evaluate((animations) =>
|
||||
animations.some(
|
||||
(animation) =>
|
||||
animation.effect instanceof KeyframeEffect && animation.effect.getKeyframes().some((frame) => "width" in frame),
|
||||
),
|
||||
),
|
||||
).toBe(true)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => (animation.currentTime = 75)))
|
||||
|
||||
const midway = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(midway!.x).toBeLessThan(before!.x)
|
||||
await exit.evaluate((animations) => animations.forEach((animation) => animation.finish()))
|
||||
await expect(trigger).toHaveCount(0)
|
||||
|
||||
const after = await header.getByRole("heading").boundingBox()
|
||||
|
||||
expect(after!.x).toBeLessThan(midway!.x)
|
||||
expect(after!.x).toBeCloseTo(before!.x - width - 2, 0)
|
||||
await exit.dispose()
|
||||
})
|
||||
|
||||
test("moves between siblings and to the parent's shell from inside a subagent", async ({ page }) => {
|
||||
const siblings = [
|
||||
{ id: "ses_sibling_one", agent: "explore", description: "Draft TUI proposal" },
|
||||
@@ -1246,15 +1062,6 @@ test.describe("background shortcut", () => {
|
||||
await trigger.click()
|
||||
await list.getByRole("menuitem", { name: /Draft TUI proposal/ }).click()
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_one$/)
|
||||
await expect(list).toHaveCount(0)
|
||||
await expect(header.getByRole("heading")).toHaveText("Draft TUI proposal")
|
||||
await expect(trigger.locator('[data-component="text-shimmer"]')).toHaveCSS("font-variant-numeric", "tabular-nums")
|
||||
|
||||
const breadcrumbOrder = await header
|
||||
.locator('[data-slot="session-title-parent"], button[aria-label="3 running"], [data-slot="session-title-child"]')
|
||||
.evaluateAll((elements) => elements.map((element) => element.getAttribute("data-slot") ?? "running"))
|
||||
|
||||
expect(breadcrumbOrder).toEqual(["session-title-parent", "running", "session-title-child"])
|
||||
|
||||
await trigger.click()
|
||||
await expect(list.getByRole("menuitem")).toHaveText([
|
||||
@@ -1264,26 +1071,8 @@ test.describe("background shortcut", () => {
|
||||
])
|
||||
await expect(list.getByRole("menuitem", { name: /Draft TUI proposal/ })).toHaveAttribute("aria-current", "page")
|
||||
await expect(list.getByRole("menuitem", { name: /Fix context controls/ })).not.toHaveAttribute("aria-current")
|
||||
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session/ses_sibling_two/message",
|
||||
async (route) => {
|
||||
requested.resolve()
|
||||
await release.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
await list.getByRole("menuitem", { name: /Fix context controls/ }).click()
|
||||
await requested.promise
|
||||
await Promise.all([
|
||||
expect(page).toHaveURL(/\/session\/ses_sibling_two$/),
|
||||
expect(header.getByRole("heading")).toHaveText("Fix context controls"),
|
||||
expect(trigger).toBeEnabled(),
|
||||
]).finally(() => release.resolve())
|
||||
await expect(list).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/session\/ses_sibling_two$/)
|
||||
|
||||
// The shell call lives in the parent: the row opens it there, expanded.
|
||||
await trigger.click()
|
||||
@@ -1705,22 +1494,6 @@ test.describe("shell completion", () => {
|
||||
})
|
||||
})
|
||||
|
||||
function pauseExitAnimations(locator: Locator) {
|
||||
return locator.evaluateHandle((element) => {
|
||||
const animations: Animation[] = []
|
||||
const pause = (event: AnimationEvent) => {
|
||||
if (event.target !== element || event.animationName !== "exit") return
|
||||
|
||||
animations.push(...element.getAnimations())
|
||||
animations.forEach((animation) => animation.pause())
|
||||
element.removeEventListener("animationstart", pause)
|
||||
}
|
||||
|
||||
element.addEventListener("animationstart", pause)
|
||||
return animations
|
||||
})
|
||||
}
|
||||
|
||||
function runningSubagent(): SessionMessageAssistant {
|
||||
return {
|
||||
...completed,
|
||||
|
||||
@@ -524,11 +524,11 @@ export function messageUpdated(info: SessionMessageAssistant) {
|
||||
})
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1, id = sessionID) {
|
||||
if (type === "busy") return makeEvent("session.execution.started", { sessionID: id })
|
||||
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID: id })
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
if (type === "busy") return makeEvent("session.execution.started", { sessionID })
|
||||
if (type === "idle") return makeEvent("session.execution.succeeded", { sessionID })
|
||||
return makeEvent("session.retry.scheduled", {
|
||||
sessionID: id,
|
||||
sessionID,
|
||||
assistantMessageID: assistantID,
|
||||
attempt,
|
||||
at: 1700000010000,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -145,7 +145,7 @@ function session(input: {
|
||||
}
|
||||
|
||||
describe("Composer submission", () => {
|
||||
test("applies the selection and runs a client argument command without admitting it to the session", async () => {
|
||||
test("runs a client argument command without admitting it to the session", async () => {
|
||||
const state = createMemoryComposerState().capture()
|
||||
|
||||
const image = {
|
||||
@@ -169,8 +169,7 @@ describe("Composer submission", () => {
|
||||
}
|
||||
}).submit(new Event("submit"))
|
||||
|
||||
// Client commands such as /btw generate with the session's model, so the composer's selection commits first.
|
||||
expect(calls).toEqual(["switch-agent", "switch-model", "btw"])
|
||||
expect(calls).toEqual(["btw"])
|
||||
expect(history).toEqual([])
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }, image])
|
||||
expect(state.context.items()).toHaveLength(1)
|
||||
|
||||
@@ -66,16 +66,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
submitting.add(input.adapter.state)
|
||||
|
||||
try {
|
||||
// Client commands such as /btw answer with the session's model, so apply the composer's selection first,
|
||||
// following the same steer rule as server commands.
|
||||
const selection = currentSelection(input)
|
||||
|
||||
if (input.adapter.kind === "active-session" && selection && (input.delivery?.(false) ?? "steer") === "steer")
|
||||
await applySelection(
|
||||
input.adapter.session(),
|
||||
selection,
|
||||
input.adapter.controls().model.selection.trackSessionCommit,
|
||||
)
|
||||
clearClientCommand(input, prompt)
|
||||
await clientCommand()
|
||||
} catch (error) {
|
||||
@@ -282,22 +272,25 @@ function readSubmission(
|
||||
|
||||
if (!text.trim() && !prompt.some(isAttachment) && comments === 0) return
|
||||
|
||||
const selection = currentSelection(input)
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
|
||||
if (!selection) {
|
||||
if (!model || !agent) {
|
||||
input.notify.missingSelection()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const variant = controls.model.selection.variant.current()
|
||||
const retry = input.adapter.state.retry.current()
|
||||
|
||||
const retryID =
|
||||
retry &&
|
||||
retry.agent === selection.agent &&
|
||||
retry.providerID === selection.model.providerID &&
|
||||
retry.modelID === selection.model.modelID &&
|
||||
(retry.variant ?? "default") === (selection.variant ?? "default")
|
||||
retry.agent === agent &&
|
||||
retry.providerID === model.provider.id &&
|
||||
retry.modelID === model.id &&
|
||||
(retry.variant ?? "default") === (variant ?? "default")
|
||||
? retry.id
|
||||
: undefined
|
||||
|
||||
@@ -308,25 +301,15 @@ function readSubmission(
|
||||
context,
|
||||
text,
|
||||
images,
|
||||
selection,
|
||||
selection: {
|
||||
agent,
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant,
|
||||
},
|
||||
delivery: input.delivery?.(alternate) ?? "steer",
|
||||
}
|
||||
}
|
||||
|
||||
function currentSelection(input: ComposerSubmitInput): ComposerSelection | undefined {
|
||||
const controls = input.adapter.controls()
|
||||
const model = controls.model.selection.current()
|
||||
const agent = controls.agents.current
|
||||
|
||||
if (!model || !agent) return
|
||||
|
||||
return {
|
||||
agent,
|
||||
model: { modelID: model.id, providerID: model.provider.id },
|
||||
variant: controls.model.selection.variant.current(),
|
||||
}
|
||||
}
|
||||
|
||||
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
|
||||
submission.clear()
|
||||
submission.target().mode.set("normal")
|
||||
|
||||
@@ -2,13 +2,6 @@ import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native"
|
||||
|
||||
export const dict = {
|
||||
...DESKTOP_NATIVE_ENGLISH,
|
||||
"session.location.unavailable": "Session location unavailable",
|
||||
"session.location.description": "Choose another directory to continue this session.",
|
||||
"session.location.choose": "Choose directory",
|
||||
"session.location.worktree": "Choose worktree",
|
||||
"session.location.worktreesFailed": "Failed to load worktrees",
|
||||
"session.location.moving": "Moving session…",
|
||||
"session.location.moveFailed": "Failed to move session",
|
||||
"server.action.authenticate": "Authenticate",
|
||||
"server.status.connecting": "Connecting over SSH…",
|
||||
"server.status.authentication": "Authentication required",
|
||||
@@ -516,8 +509,8 @@ export const dict = {
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.running.working.one": "{{count}} working",
|
||||
"session.running.working.other": "{{count}} working",
|
||||
"session.running.working.one": "{{count}} working…",
|
||||
"session.running.working.other": "{{count}} working…",
|
||||
"session.running.running.one": "{{count}} running",
|
||||
"session.running.running.other": "{{count}} running",
|
||||
"session.running.stop.subagent": "Interrupt subagent",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DockShell, DockTray } from "@opencode/ui/dock-surface"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { createEffect, createResource, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { useDirectoryPicker } from "@/workspaces/selection/picker"
|
||||
|
||||
export function SessionLocationMissing(props: { sessionID: string; projectID: string; directory: string }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
const dialog = useDialog()
|
||||
const pickDirectory = useDirectoryPicker()
|
||||
const [state, setState] = createStore({ restoreFocus: false, worktreesOpen: false })
|
||||
const project = () => data.project.get(props.projectID)
|
||||
|
||||
const [worktrees] = createResource(
|
||||
() => (state.worktreesOpen ? props.projectID : undefined),
|
||||
async (projectID) => {
|
||||
await server.ctx.sync.worktrees.refresh(projectID)
|
||||
const items = await server.ctx.sync.worktrees.list(projectID)
|
||||
|
||||
if (!items) showToast({ variant: "error", title: language.t("session.location.worktreesFailed") })
|
||||
|
||||
return items ?? []
|
||||
},
|
||||
// Seed latest so even the first fetch does not enter Suspense.
|
||||
{ initialValue: [] },
|
||||
)
|
||||
|
||||
const otherWorktrees = () =>
|
||||
worktrees.latest.filter((item) => item.strategy && !sameDirectory(item.directory, props.directory))
|
||||
|
||||
let button: HTMLButtonElement | undefined
|
||||
|
||||
const move = useMutation(() => ({
|
||||
mutationFn: async (input: { directory?: string }) => {
|
||||
// The missing worktree cannot resolve its own Location, so create from the project's saved checkout.
|
||||
const current = project()
|
||||
|
||||
const destination =
|
||||
input.directory ??
|
||||
(current &&
|
||||
(await createWorktree({
|
||||
api: server.ctx.sdk.api,
|
||||
data,
|
||||
directory: current.canonical,
|
||||
project: { id: current.id, canonical: current.canonical, directory: current.canonical },
|
||||
})))
|
||||
|
||||
if (!destination) return
|
||||
await server.ctx.sdk.api.session.move({ sessionID: props.sessionID, directory: destination })
|
||||
},
|
||||
onError: (error) => {
|
||||
setState("restoreFocus", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("session.location.moveFailed"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
createEffect(() => {
|
||||
if (!state.restoreFocus || move.isPending || dialog.active) return
|
||||
setState("restoreFocus", false)
|
||||
button?.focus()
|
||||
})
|
||||
|
||||
function choose() {
|
||||
if (move.isPending) return
|
||||
pickDirectory({
|
||||
server: server.conn,
|
||||
title: language.t("session.location.choose"),
|
||||
onSelect: (result) => {
|
||||
const selected = Array.isArray(result) ? result[0] : result
|
||||
|
||||
if (selected) move.mutate({ directory: selected })
|
||||
|
||||
if (!selected) setState("restoreFocus", true)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="session-location-missing">
|
||||
<DockShell class="flex flex-col gap-2 p-3">
|
||||
<div role="status" class="flex items-start gap-2 text-13-regular leading-[var(--line-height-base)]">
|
||||
<Icon name="warning" class="shrink-0 text-icon-warning-base" />
|
||||
<div class="min-w-0 flex flex-col gap-1">
|
||||
<div class="font-medium text-text-strong">{language.t("session.location.unavailable")}</div>
|
||||
<div class="break-all font-mono text-12-regular text-text-weak">{props.directory}</div>
|
||||
<div class="text-text-base">{language.t("session.location.description")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</DockShell>
|
||||
<DockTray attach="top" class="flex flex-wrap justify-end gap-2 p-2 pt-[22px]">
|
||||
<Show when={project()?.vcs === "git"}>
|
||||
<Menu placement="top-end" onOpenChange={(open) => setState("worktreesOpen", open)}>
|
||||
<Menu.Trigger as={Button} variant="neutral" disabled={move.isPending}>
|
||||
{language.t("session.location.worktree")}
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="max-h-80 max-w-[calc(100vw-32px)] overflow-y-auto">
|
||||
<Menu.Item onSelect={() => move.mutate({})} disabled={move.isPending}>
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={worktrees.loading && otherWorktrees().length === 0}>
|
||||
<Menu.Item disabled>{language.t("common.loading")}</Menu.Item>
|
||||
</Show>
|
||||
<For each={otherWorktrees()}>
|
||||
{(worktree) => (
|
||||
<Menu.Item
|
||||
title={worktree.directory}
|
||||
onSelect={() => move.mutate({ directory: worktree.directory })}
|
||||
disabled={move.isPending}
|
||||
>
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="truncate">{getFilename(worktree.directory)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Button ref={button} variant="contrast" onClick={choose} disabled={move.isPending}>
|
||||
{language.t(move.isPending ? "session.location.moving" : "session.location.choose")}
|
||||
</Button>
|
||||
</DockTray>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { SessionUserActions } from "@opencode/session-ui/message"
|
||||
import { getFilename, sameDirectory } from "@opencode/util/path"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode/ui/scroll-view"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, onMount, Show, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, on, onMount, type Accessor } from "solid-js"
|
||||
import { Composer } from "@/composer/composer"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { createComposerControls } from "@/composer/selection"
|
||||
@@ -32,7 +32,6 @@ import { SessionQueuePanel } from "./queue-panel"
|
||||
import { resolveSessionComposerSelection } from "./selection"
|
||||
import { createSessionRequestModel } from "../requests/model"
|
||||
import type { Region } from "@/runtime/extension/panels"
|
||||
import { SessionLocationMissing } from "./location-missing"
|
||||
|
||||
export function createActiveSessionRegion(input: {
|
||||
session: SessionModel
|
||||
@@ -286,51 +285,24 @@ export function createActiveSessionRegion(input: {
|
||||
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
|
||||
|
||||
export function ActiveSessionComposerRegion(props: {
|
||||
session: SessionModel
|
||||
model: SessionComposerController
|
||||
suggestionBoundary: () => HTMLElement | undefined
|
||||
}) {
|
||||
const location = useWorkspaceLocation()
|
||||
|
||||
// Only the server's LocationNotFoundError for the session's own Location replaces the composer.
|
||||
const missing = createMemo(() => {
|
||||
const ref = location().missing
|
||||
const info = props.session.data.info()
|
||||
|
||||
if (!ref || !info || !sameDirectory(info.location.directory, ref.directory)) return
|
||||
|
||||
return { directory: info.location.directory, sessionID: info.id, projectID: info.projectID }
|
||||
})
|
||||
|
||||
return (
|
||||
<SessionComposerRegion
|
||||
controller={props.model.region}
|
||||
composer={
|
||||
<Show
|
||||
when={missing()}
|
||||
keyed
|
||||
fallback={
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(current) => (
|
||||
<SessionLocationMissing
|
||||
sessionID={current.sessionID}
|
||||
projectID={current.projectID}
|
||||
directory={current.directory}
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -4,9 +4,8 @@ import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { TextShimmer } from "@opencode/ui/text-shimmer"
|
||||
import { createEffect, createMemo, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -31,19 +30,13 @@ export function SessionRunningMenu(props: {
|
||||
owner?: string
|
||||
blocking: readonly { type: "shell" | "subagent"; partID: string; id?: string; label?: string }[]
|
||||
tasks: readonly BackgroundTask[]
|
||||
onReveal?: (target: string) => void
|
||||
separator?: "before" | "after"
|
||||
onReveal: (target: string) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const openRoute = useOpenSessionRoute()
|
||||
const server = useServer()
|
||||
const sdk = useServerSDK()
|
||||
const [menu, setMenu] = createStore({ open: false })
|
||||
const [appearance, setAppearance] = createStore<{
|
||||
group?: HTMLDivElement
|
||||
collapse?: Animation
|
||||
entered: boolean
|
||||
}>({ entered: false })
|
||||
const sessionAgent = (id: string | undefined) => (id ? server.ctx.data.session.get(id)?.agent : undefined)
|
||||
|
||||
// Foreground shells stay out: the timeline already shows them at the bottom.
|
||||
@@ -69,58 +62,16 @@ export function SessionRunningMenu(props: {
|
||||
...props.tasks.flatMap((task) => (task.type === "shell" ? [{ ...task, key: task.id, target: task.id }] : [])),
|
||||
])
|
||||
|
||||
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
|
||||
|
||||
const label = createMemo(() => {
|
||||
const count = items().length
|
||||
|
||||
if (!count || (count === 1 && viewing(items()[0]))) return undefined
|
||||
|
||||
if (items().some((item) => item.type === "shell")) return language.plural("session.running.running", count)
|
||||
|
||||
return language.plural("session.running.working", count)
|
||||
})
|
||||
|
||||
const presence = createAnimatedPresence(
|
||||
label,
|
||||
() => appearance.group ?? null,
|
||||
() => props.sessionID,
|
||||
)
|
||||
// Label updates change presence state without changing visibility.
|
||||
const visible = createMemo(presence.show)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
visible,
|
||||
(show) => {
|
||||
if (show) {
|
||||
appearance.collapse?.cancel()
|
||||
setAppearance({ collapse: undefined, entered: false })
|
||||
return
|
||||
}
|
||||
|
||||
setMenu("open", false)
|
||||
|
||||
const group = appearance.group
|
||||
|
||||
if (!group || props.separator !== "after" || matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
||||
|
||||
setAppearance(
|
||||
"collapse",
|
||||
group.animate(
|
||||
[
|
||||
{ width: `${group.getBoundingClientRect().width}px`, marginInlineEnd: "0px" },
|
||||
// Cancel the header's 2px gap so removing the collapsed slot doesn't nudge the title.
|
||||
{ width: "0px", marginInlineEnd: "-2px" },
|
||||
],
|
||||
{ duration: 150, easing: "cubic-bezier(0, 0, 0.2, 1)", fill: "forwards" },
|
||||
),
|
||||
)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
onCleanup(() => appearance.collapse?.cancel())
|
||||
// Inside a subagent, its own row is listed among its siblings.
|
||||
const viewing = (item: RunningItem) => !!item.sessionID && item.sessionID === props.sessionID
|
||||
|
||||
const open = (item: RunningItem) => {
|
||||
const current = props.sessionID
|
||||
@@ -132,9 +83,7 @@ export function SessionRunningMenu(props: {
|
||||
// Shell calls and starting subagent calls live in the owner's timeline, which reveals them once open.
|
||||
if (props.owner && props.owner !== current) return openRoute(current, props.owner, item.target)
|
||||
|
||||
if (props.onReveal) return props.onReveal(item.target)
|
||||
|
||||
openRoute(current, current, item.target)
|
||||
props.onReveal(item.target)
|
||||
}
|
||||
|
||||
// A subagent can only be interrupted once its child session exists.
|
||||
@@ -157,133 +106,99 @@ export function SessionRunningMenu(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div
|
||||
ref={(element) => setAppearance("group", element)}
|
||||
inert={!presence.show()}
|
||||
class="flex shrink-0 items-center gap-0.5 duration-150 ease-out motion-reduce:animate-none"
|
||||
classList={{
|
||||
"animate-in fade-in": presence.animate() && presence.show() && !appearance.entered,
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
"overflow-hidden": props.separator === "after" && !presence.show(),
|
||||
}}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget && presence.show()) setAppearance("entered", true)
|
||||
}}
|
||||
>
|
||||
<Show when={props.separator !== "after"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="shrink-0 ps-2 pe-1 text-[11px] font-medium leading-text-compact text-v2-text-text-faint"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Menu
|
||||
gutter={6}
|
||||
placement="bottom-start"
|
||||
open={menu.open && presence.show()}
|
||||
onOpenChange={(open) => setMenu("open", open)}
|
||||
<Show when={items().length > 0}>
|
||||
<Menu gutter={6} placement="bottom-start" open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={label()}
|
||||
// 8px from the ··· menu, or from the padded title inside a subagent, which has no menu.
|
||||
class={`${props.owner === props.sessionID ? "ms-1.5" : "ms-0.5"} flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover`}
|
||||
>
|
||||
<Menu.Trigger
|
||||
as="button"
|
||||
type="button"
|
||||
aria-label={presence.value()}
|
||||
class="flex h-7 shrink-0 items-center rounded-[6px] px-2 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] tabular-nums whitespace-nowrap text-v2-text-text-base outline-none hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-v2-border-border-focus data-[expanded]:bg-v2-overlay-simple-overlay-hover"
|
||||
>
|
||||
<TextShimmer text={presence.value() ?? ""} active={presence.show()} />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-60" aria-label={presence.value()}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Menu.Item
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
open(item)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
<TextShimmer text={label()} active />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="w-60" aria-label={label()}>
|
||||
<For each={items()}>
|
||||
{(item) => (
|
||||
<Menu.Item
|
||||
class="group/running-item"
|
||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": viewing(item) }}
|
||||
aria-current={viewing(item) ? "page" : undefined}
|
||||
onSelect={() => {
|
||||
// Close before navigating: the router's transition would hold the close until this
|
||||
// timeline detaches, and the menu would flash at the viewport origin.
|
||||
setMenu("open", false)
|
||||
open(item)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key !== "Delete" && event.key !== "Backspace") || !stoppable(item)) return
|
||||
|
||||
event.preventDefault()
|
||||
stop(item)
|
||||
event.preventDefault()
|
||||
stop(item)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "subagent"}
|
||||
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
|
||||
<SessionProgressIndicatorV2
|
||||
class="shrink-0"
|
||||
style={{
|
||||
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.type === "shell"
|
||||
? language.t("ui.tool.shell")
|
||||
: item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
|
||||
<span
|
||||
dir="auto"
|
||||
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{
|
||||
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
|
||||
stoppable(item),
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={item.type === "subagent"}
|
||||
fallback={<Icon name="console" class="shrink-0 text-v2-icon-icon-muted" />}
|
||||
>
|
||||
{/* Built-in agents have theme tokens; any other agent keeps the neutral color. */}
|
||||
<SessionProgressIndicatorV2
|
||||
class="shrink-0"
|
||||
style={{
|
||||
color: item.agent ? `var(--v2-agent-${item.agent.toLowerCase()}-solid, ${neutral})` : neutral,
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={stoppable(item)}>
|
||||
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
|
||||
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
|
||||
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
tabIndex={-1}
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t(
|
||||
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
stop(item)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<span class="shrink-0 font-[530]">
|
||||
{item.type === "shell"
|
||||
? language.t("ui.tool.shell")
|
||||
: item.agent
|
||||
? `${item.agent[0].toUpperCase()}${item.agent.slice(1)}`
|
||||
: language.t("ui.tool.agent.default")}
|
||||
</span>
|
||||
{/* Menu rows end 6px in for trailing controls; text alone ends 12px in, like the leading edge. */}
|
||||
<span
|
||||
dir="auto"
|
||||
class="me-1.5 min-w-0 flex-1 truncate text-v2-text-text-muted"
|
||||
classList={{
|
||||
"group-hover/running-item:me-0 group-data-[highlighted]/running-item:me-0 [@media(hover:none)]:me-0":
|
||||
stoppable(item),
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<Show when={stoppable(item)}>
|
||||
{/* The button's own display rule outranks utilities, so this wrapper shows and hides it. */}
|
||||
<span class="hidden shrink-0 group-hover/running-item:flex group-data-[highlighted]/running-item:flex [@media(hover:none)]:flex">
|
||||
{/* The row selects on press, so the stop button keeps its pointer events to itself. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
tabIndex={-1}
|
||||
icon={<Icon name="outline-xmark" />}
|
||||
aria-label={language.t(
|
||||
item.type === "shell" ? "session.running.stop.shell" : "session.running.stop.subagent",
|
||||
)}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onPointerUp={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
stop(item)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
<Show when={props.separator === "after"}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="shrink-0 ps-2 pe-1 text-[11px] font-medium leading-text-compact text-v2-text-text-faint"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
|
||||
export function SessionWorkingIndicator(props: { sessionID?: string }) {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const [elements, setElements] = createStore<{ indicator?: HTMLSpanElement }>({})
|
||||
const presence = createAnimatedPresence(
|
||||
() => {
|
||||
const id = props.sessionID
|
||||
|
||||
return id && server.ctx.data.session.get(id)?.parentID && server.ctx.data.session.status(id) === "running"
|
||||
? true
|
||||
: undefined
|
||||
},
|
||||
() => elements.indicator ?? null,
|
||||
() => props.sessionID,
|
||||
)
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<span
|
||||
ref={(element) => setElements("indicator", element)}
|
||||
role="status"
|
||||
aria-label={language.t("session.timeline.working")}
|
||||
data-visible={presence.show()}
|
||||
// Cancel the header's 2px gap at the end of the collapse so unmounting doesn't nudge the title.
|
||||
class="ms-1 me-0.5 flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden text-v2-icon-icon-muted duration-200 ease-out data-[visible=false]:ms-0 data-[visible=false]:-me-0.5 data-[visible=false]:w-0 data-[visible=false]:opacity-0 motion-reduce:animate-none motion-reduce:transition-none"
|
||||
classList={{
|
||||
"transition-[width,margin,opacity]": presence.animate(),
|
||||
// Presence waits for animationend; transitions alone would unmount the icon immediately.
|
||||
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
|
||||
}}
|
||||
>
|
||||
<SessionProgressIndicatorV2 class="shrink-0" />
|
||||
</span>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ function ResolvedTargetSessionRoute() {
|
||||
>
|
||||
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
|
||||
{(value) => (
|
||||
<LocationProvider directory={value()}>
|
||||
<LocationProvider directory={value}>
|
||||
<SessionUIProvider directory={value()} server={server.key}>
|
||||
<TargetSessionPage />
|
||||
</SessionUIProvider>
|
||||
|
||||
@@ -34,7 +34,6 @@ import { TimelineSearchBar } from "./timeline/search-bar"
|
||||
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
|
||||
import { SessionIdentityHeader } from "./session-identity-header"
|
||||
import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { SessionRunningMenu } from "./header/session-running-menu"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
|
||||
@@ -334,15 +333,7 @@ function SessionScreenContent(props: {
|
||||
</Match>
|
||||
<Match when={session.identity.params.id}>
|
||||
<Show when={isDesktop() && !messagesReady()}>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()}>
|
||||
<SessionRunningMenu
|
||||
sessionID={session.identity.params.id}
|
||||
owner={composer.requests.background.running.sessionID()}
|
||||
blocking={composer.requests.background.running.blocking()}
|
||||
tasks={composer.requests.background.running.tasks()}
|
||||
separator={session.data.parentID() ? "after" : "before"}
|
||||
/>
|
||||
</SessionIdentityHeader>
|
||||
<SessionIdentityHeader sessionID={session.identity.params.id ?? ""} session={session.data.info()} />
|
||||
</Show>
|
||||
<Show when={messagesReady() && session.identity.params.id}>{timelineView()}</Show>
|
||||
</Match>
|
||||
@@ -350,9 +341,7 @@ function SessionScreenContent(props: {
|
||||
</div>
|
||||
|
||||
<Show when={composer.active()} keyed>
|
||||
{(model) => (
|
||||
<ActiveSessionComposerRegion session={session} model={model} suggestionBoundary={timeline.scroller} />
|
||||
)}
|
||||
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -26,7 +26,6 @@ import { isProjectDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { sessionHref } from "@/shell/routes/session"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { sessionTitle } from "./title"
|
||||
import { SessionWorkingIndicator } from "./header/session-working-indicator"
|
||||
import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
@@ -254,16 +253,16 @@ export function SessionAncestorTrail(props: {
|
||||
data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"}
|
||||
data-session-id={ancestor.id}
|
||||
title={ancestor.title}
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
classList={{ "ps-1": index() === 0, "ps-2": index() > 0 }}
|
||||
dir="auto"
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={() => open(props.sessionID, ancestor.id)}
|
||||
>
|
||||
<bdi dir="auto">{ancestor.title}</bdi>
|
||||
{ancestor.title}
|
||||
</button>
|
||||
<Show when={index() < ancestors().length - 1}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -276,7 +275,7 @@ export function SessionAncestorTrail(props: {
|
||||
<Show when={props.trailing}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 ps-2 pe-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
@@ -306,7 +305,7 @@ export function useOpenSessionRoute() {
|
||||
}
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; session?: SessionInfo }>) {
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
@@ -395,8 +394,6 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID()}>{props.children}</Show>
|
||||
<SessionWorkingIndicator sessionID={props.sessionID} />
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
@@ -409,7 +406,6 @@ export function SessionIdentityHeader(props: ParentProps<{ sessionID: string; se
|
||||
</h1>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={!parentID()}>{props.children}</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import { SessionRunningMenu } from "@/session/header/session-running-menu"
|
||||
import { SessionWorkingIndicator } from "@/session/header/session-working-indicator"
|
||||
|
||||
type BlockingTask = { type: "shell" | "subagent"; partID: string; id?: string; label?: string }
|
||||
|
||||
@@ -375,9 +374,8 @@ function MessageTimelineView(
|
||||
return message?.type === "compaction" && message.status === "running"
|
||||
}
|
||||
|
||||
// Used and read groups keep the fallback, so each new read does not swap Working out for its short call.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context" || row.group.type === "read")
|
||||
return false
|
||||
// Used groups keep the fallback regardless of disclosure state.
|
||||
if (!Predicate.isTagged(row, "AssistantPart") || row.group.type === "context") return false
|
||||
|
||||
return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => {
|
||||
const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID)
|
||||
@@ -392,17 +390,6 @@ function MessageTimelineView(
|
||||
})
|
||||
})
|
||||
|
||||
const runningMenu = (separator: "before" | "after") => (
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
separator={separator}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<VirtualizedTimeline
|
||||
workspaceSession={workspaceSession}
|
||||
@@ -468,8 +455,6 @@ function MessageTimelineView(
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentID()}>{runningMenu("after")}</Show>
|
||||
<SessionWorkingIndicator sessionID={sessionID()} />
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
@@ -520,7 +505,6 @@ function MessageTimelineView(
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={!parentID()}>{runningMenu("before")}</Show>
|
||||
<Show when={!parentID() && sessionID()} keyed>
|
||||
{(id) => (
|
||||
<Menu
|
||||
@@ -574,6 +558,13 @@ function MessageTimelineView(
|
||||
</Menu>
|
||||
)}
|
||||
</Show>
|
||||
<SessionRunningMenu
|
||||
sessionID={sessionID()}
|
||||
owner={props.background.running.sessionID()}
|
||||
blocking={props.background.running.blocking()}
|
||||
tasks={props.background.running.tasks()}
|
||||
onReveal={virtualized.revealPart}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()} keyed>
|
||||
|
||||
@@ -59,7 +59,6 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
const location = useLocation()
|
||||
const mobile = createMediaQuery("(max-width: 767px)")
|
||||
const bottom = createMemo(() => mobile() && settings.general.mobileTitlebarPosition() === "bottom")
|
||||
const mobileTop = createMemo(() => platform.platform === "web" && mobile() && !bottom())
|
||||
|
||||
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
|
||||
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
|
||||
@@ -151,17 +150,9 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
platform.platform === "web"
|
||||
? bottom()
|
||||
? "calc(28px + max(8px, var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))"
|
||||
: mobileTop()
|
||||
? "calc(28px + 16px + env(safe-area-inset-top, 0px))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: "calc(28px + max(8px, env(safe-area-inset-top, 0px)))"
|
||||
: undefined,
|
||||
// iOS blurs page content just below the status bar in Home Screen web apps, so keep the phone titlebar row
|
||||
// 16px clear of the safe area, as ChatGPT's phone header does.
|
||||
"padding-top": bottom()
|
||||
? "0px"
|
||||
: mobileTop()
|
||||
? "calc(16px + env(safe-area-inset-top, 0px))"
|
||||
: "env(safe-area-inset-top, 0px)",
|
||||
"padding-top": bottom() ? "0px" : "env(safe-area-inset-top, 0px)",
|
||||
"padding-bottom": bottom() ? "var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))" : "0px",
|
||||
"min-height": minHeight(),
|
||||
// Keep native macOS traffic lights clear even when the desktop window is narrow.
|
||||
@@ -521,7 +512,7 @@ export function Titlebar(props: { verticalTabs?: { mount?: HTMLElement } }) {
|
||||
<div
|
||||
class="h-full flex-1 overflow-hidden flex flex-row items-center gap-1.5 px-2 md:pe-3"
|
||||
classList={{
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows() && !mobileTop(),
|
||||
"pt-[max(0px,calc(8px-env(safe-area-inset-top,0px)))]": !bottom() && !windows(),
|
||||
"pb-[max(0px,calc(8px-var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))))]": bottom(),
|
||||
"pl-4": macTrafficLights(),
|
||||
// Center the 20px app icon over the sidebar's 16px icon column.
|
||||
|
||||
@@ -1,40 +1,33 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { isLocationNotFoundError, type LocationGetOutput, type LocationRef } from "@opencode/client/promise"
|
||||
import type { LocationGetOutput, LocationRef } from "@opencode/client/promise"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
|
||||
export type { LocationContext } from "@/runtime/server/client"
|
||||
|
||||
export type WorkspaceLocation = LocationContext & {
|
||||
readonly ref: LocationRef
|
||||
readonly current: LocationGetOutput | undefined
|
||||
/** Set only when the server reports that this exact Location's folder does not exist. */
|
||||
readonly missing: LocationRef | undefined
|
||||
}
|
||||
|
||||
const context = createSimpleContext({
|
||||
name: "Location",
|
||||
init: (props: { directory: string; workspaceID?: string }) => {
|
||||
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = useData()
|
||||
|
||||
const ref = createMemo(
|
||||
() => ({
|
||||
directory: props.directory,
|
||||
workspaceID: props.workspaceID,
|
||||
directory: typeof props.directory === "function" ? props.directory() : props.directory,
|
||||
workspaceID: typeof props.workspaceID === "function" ? props.workspaceID() : props.workspaceID,
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
equals: (previous, next) => previous.directory === next.directory && previous.workspaceID === next.workspaceID,
|
||||
},
|
||||
)
|
||||
|
||||
const current = createMemo(() => data.location.info(ref()))
|
||||
const [state, setState] = createStore<{ missing?: LocationRef }>({})
|
||||
|
||||
createEffect(() => {
|
||||
const location = ref()
|
||||
@@ -42,37 +35,27 @@ const context = createSimpleContext({
|
||||
onCleanup(() => {
|
||||
stale = true
|
||||
})
|
||||
setState("missing", undefined)
|
||||
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
// Generic sync failures do not prove the directory is missing; only the server's typed
|
||||
// LocationNotFoundError does, and retrying it cannot succeed until the folder changes.
|
||||
// A failed sync does not prove the directory is missing. Keep recovery local to reads.
|
||||
void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), {
|
||||
retryIf: (error) => !stale && !isLocationNotFoundError(error),
|
||||
}).catch((error) => {
|
||||
if (stale || !isLocationNotFoundError(error)) return
|
||||
setState("missing", location)
|
||||
})
|
||||
retryIf: () => !stale,
|
||||
}).catch(() => undefined)
|
||||
})
|
||||
createEffect(() => {
|
||||
const id = current()?.project.id
|
||||
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id))
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
|
||||
return createMemo<WorkspaceLocation>(() => ({
|
||||
...location(),
|
||||
ref: ref(),
|
||||
current: current(),
|
||||
missing: state.missing,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
export const useWorkspaceLocation: () => Accessor<WorkspaceLocation> = context.use
|
||||
|
||||
export const LocationProvider = context.provider
|
||||
@@ -49,12 +49,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const policy = pickerMode(props.mode ?? "directory", props.start)
|
||||
|
||||
const action = {
|
||||
file: language.t("dialog.directory.action.selectFile"),
|
||||
directory: language.t("dialog.directory.action.selectFolder"),
|
||||
}
|
||||
|
||||
const [root, setRoot] = createSignal("")
|
||||
const [input, setInput] = createSignal("")
|
||||
const [selected, setSelected] = createSignal("")
|
||||
@@ -76,42 +74,31 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
() => sdk.api.location.get().catch(() => undefined),
|
||||
{ initialValue: undefined },
|
||||
)
|
||||
|
||||
const home = createMemo(() => sync.data.path.home || "")
|
||||
|
||||
const location = createMemo(() => {
|
||||
const current = props.location ?? fallbackPath.latest
|
||||
|
||||
const current = props.location ?? fallbackPath()
|
||||
return current ? { directory: current.directory } : undefined
|
||||
})
|
||||
|
||||
const start = createMemo(
|
||||
() =>
|
||||
props.start ||
|
||||
sync.data.path.home ||
|
||||
props.location?.directory ||
|
||||
sync.data.path.directory ||
|
||||
fallbackPath.latest?.directory,
|
||||
fallbackPath()?.directory,
|
||||
)
|
||||
|
||||
const search = createDirectorySearch({ sdk, home, location, base: () => root() || start() })
|
||||
|
||||
const [suggestions] = createResource(input, async (value) => {
|
||||
const cleaned = cleanPickerInput(value)
|
||||
const typed = cleaned.replace(/\/+$/, "")
|
||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||
|
||||
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||
|
||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||
const base = location()?.directory
|
||||
|
||||
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||
const query = pickerRelativePath(base, pickerAbsoluteInput(cleaned, home(), root() || base))
|
||||
|
||||
if (query === undefined) return { query: value, items: directories.slice(0, 5) }
|
||||
|
||||
const files = await sdk.api.file
|
||||
.find({
|
||||
location: location(),
|
||||
@@ -121,64 +108,48 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
})
|
||||
.then((result) => result.data)
|
||||
.catch(() => [])
|
||||
|
||||
const results = [
|
||||
...directories,
|
||||
...files.map((entry) => ({ absolute: pickerAbsolutePath(entry.path, base), type: "file" as const })),
|
||||
]
|
||||
|
||||
return {
|
||||
query: value,
|
||||
items: Array.from(new Map(results.map((result) => [result.absolute, result])).values()).slice(0, 8),
|
||||
}
|
||||
})
|
||||
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions.latest, input()))
|
||||
const currentSuggestions = createMemo(() => currentPickerSuggestions(suggestions(), input()))
|
||||
|
||||
async function load(path: string, generation: number, eager = false) {
|
||||
const key = path.replace(/\/+$/, "")
|
||||
setError(false)
|
||||
const absolute = absoluteTreePath(root(), key)
|
||||
const existing = listings.get(key)
|
||||
|
||||
if (existing && !eager) loads.promote(`${generation}:${key}`)
|
||||
|
||||
const request =
|
||||
existing ??
|
||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||
const current = location()
|
||||
|
||||
if (!current) return Promise.resolve(undefined)
|
||||
|
||||
return listPickerDirectory(sdk, current, absolute).catch(() => undefined)
|
||||
})
|
||||
|
||||
listings.set(key, request)
|
||||
const nodes = await request
|
||||
|
||||
if (!activeTreeNavigation(generation, navigation)) return false
|
||||
|
||||
if (!nodes) {
|
||||
listings.delete(key)
|
||||
|
||||
if (!key) setError(true)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
tree?.batch(policy.entries(key, nodes).map((item) => ({ type: "add", path: item })))
|
||||
|
||||
if (!eager && advanceTreePreload(advanced, key)) {
|
||||
for (const directory of preloadTreeDirectories(key, nodes)) void load(directory, generation, true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function navigate(path: string) {
|
||||
const value = policy.navigation(pickerAbsoluteInput(cleanPickerInput(path), home(), root() || start() || home()))
|
||||
|
||||
if (!value) return
|
||||
const token = ++navigation
|
||||
setLoading(true)
|
||||
@@ -192,7 +163,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
advanced.clear()
|
||||
tree?.resetPaths([])
|
||||
const valid = await load("", token)
|
||||
|
||||
if (!activeTreeNavigation(token, navigation)) return
|
||||
setRootValid(valid)
|
||||
setLoading(false)
|
||||
@@ -201,11 +171,9 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function complete() {
|
||||
const items = currentSuggestions()
|
||||
const match = items[activeSuggestion()] ?? items[0]
|
||||
|
||||
if (!match) return
|
||||
const value = displayPickerPath(match.absolute, input(), home())
|
||||
setInput(match.type === "directory" && !value.endsWith("/") ? value + "/" : value)
|
||||
|
||||
if (match.type === "file") {
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), match.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -216,10 +184,8 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
function chooseSuggestion(suggestion: { absolute: string; type: "file" | "directory" }) {
|
||||
if (suggestion.type === "directory") {
|
||||
void navigate(suggestion.absolute)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setInput(displayPickerPath(suggestion.absolute, input(), home()))
|
||||
setSelected(policy.selection(root(), pickerFileSearchQuery(root(), suggestion.absolute, home())) ?? "")
|
||||
setSuggestionsOpen(false)
|
||||
@@ -233,31 +199,23 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function activeSuggestionValue() {
|
||||
const items = currentSuggestions()
|
||||
|
||||
return items[activeSuggestion()] ?? items[0]
|
||||
}
|
||||
|
||||
const keyActions = new Map([
|
||||
["ArrowDown", () => moveSuggestion(1)],
|
||||
["ArrowUp", () => moveSuggestion(-1)],
|
||||
[
|
||||
"Enter",
|
||||
() => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
],
|
||||
["Tab", complete],
|
||||
])
|
||||
const keyActions: Partial<Record<string, () => void>> = {
|
||||
ArrowDown: () => moveSuggestion(1),
|
||||
ArrowUp: () => moveSuggestion(-1),
|
||||
Enter: () => {
|
||||
const suggestion = activeSuggestionValue()
|
||||
if (suggestion) chooseSuggestion(suggestion)
|
||||
if (!suggestion) void navigate(input())
|
||||
},
|
||||
Tab: complete,
|
||||
}
|
||||
|
||||
function handleInputKey(event: KeyboardEvent) {
|
||||
const action = keyActions.get(event.key)
|
||||
|
||||
const action = keyActions[event.key]
|
||||
if (!action) return
|
||||
|
||||
if (event.key === "Tab" && event.shiftKey) return
|
||||
event.preventDefault()
|
||||
action()
|
||||
@@ -265,7 +223,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
function resolve() {
|
||||
const path = policy.result(root(), selected(), rootValid())
|
||||
|
||||
if (!path) return
|
||||
props.onSelect(props.multiple ? [path] : path)
|
||||
dialog.close()
|
||||
@@ -273,11 +230,10 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
onMount(() => {
|
||||
const closeSuggestions = (event: PointerEvent) => {
|
||||
if (event.target instanceof Node && pathArea?.contains(event.target)) return
|
||||
if (pathArea?.contains(event.target as Node)) return
|
||||
setSuggestionsOpen(false)
|
||||
setActiveSuggestion(-1)
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", closeSuggestions)
|
||||
onCleanup(() => document.removeEventListener("pointerdown", closeSuggestions))
|
||||
tree = new FileTree({
|
||||
@@ -310,7 +266,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
setSelected(path ? (policy.selection(root(), path) ?? "") : "")
|
||||
},
|
||||
})
|
||||
|
||||
if (!container) return
|
||||
tree.render({ containerWrapper: container })
|
||||
tree.getFileTreeContainer()?.classList.add("directory-picker-tree")
|
||||
@@ -318,7 +273,6 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
|
||||
createEffect(() => {
|
||||
const path = start()
|
||||
|
||||
if (!path || !location() || root()) return
|
||||
void navigate(path)
|
||||
})
|
||||
@@ -393,16 +347,13 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) {
|
||||
const scroller = tree
|
||||
?.getFileTreeContainer()
|
||||
?.shadowRoot?.querySelector<HTMLElement>("[data-file-tree-virtualized-scroll]")
|
||||
|
||||
if (!scroller) return
|
||||
|
||||
const next = nextTreeScrollTop(
|
||||
scroller.scrollTop,
|
||||
event.deltaY,
|
||||
scroller.scrollHeight,
|
||||
scroller.clientHeight,
|
||||
)
|
||||
|
||||
if (next === scroller.scrollTop) return
|
||||
event.preventDefault()
|
||||
scroller.scrollTop = next
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { lazy, Suspense } from "solid-js"
|
||||
import { lazy } from "solid-js"
|
||||
import type { LocationRef } from "@opencode/client/promise"
|
||||
import { directoryPickerKind } from "./policy"
|
||||
|
||||
@@ -24,29 +24,17 @@ export function useDirectoryPicker() {
|
||||
return (input: DirectoryPickerInput) => {
|
||||
if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") {
|
||||
void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let selected = false
|
||||
|
||||
const onSelect = (result: string | string[] | null) => {
|
||||
selected = result !== null
|
||||
input.onSelect(result)
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (!selected) input.onSelect(null)
|
||||
}
|
||||
|
||||
// Dialogs render under the caller's owner, so loading the lazy chunk would otherwise suspend the caller's boundary.
|
||||
dialog.show(
|
||||
() => (
|
||||
<Suspense>
|
||||
<DirectoryPickerDialog {...input} onSelect={onSelect} />
|
||||
</Suspense>
|
||||
),
|
||||
cancel,
|
||||
)
|
||||
dialog.show(() => <DirectoryPickerDialog {...input} onSelect={onSelect} />, cancel)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -20,30 +20,20 @@ import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
|
||||
type HandlerContext<Params> = AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }
|
||||
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
const run = Effect.runPromiseWith(yield* Effect.context<Scope.Scope>())
|
||||
const catalog = yield* ACPCatalog.make(client)
|
||||
// Requests can dispatch before the service below is built.
|
||||
const ready = yield* Deferred.make<{
|
||||
readonly service: ACPService.Interface
|
||||
readonly connection: ACPConnection.Interface
|
||||
}>()
|
||||
const ready = yield* Deferred.make<ACPService.Interface>()
|
||||
const handle =
|
||||
<Params, A>(
|
||||
call: (service: ACPService.Interface, ctx: AgentHandlerContext<Params>) => Effect.Effect<A, ACPError.Failure>,
|
||||
) =>
|
||||
(name: string) => {
|
||||
const handler = Effect.fn(name)(
|
||||
function* (ctx: HandlerContext<Params>) {
|
||||
const connected = yield* Deferred.await(ready)
|
||||
if (ctx.requestId === undefined) return yield* call(connected.service, ctx)
|
||||
return yield* call(connected.service, ctx).pipe(
|
||||
Effect.provideService(ACPConnection.Responded, connected.connection.responded(ctx.requestId)),
|
||||
)
|
||||
},
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) =>
|
||||
@@ -53,7 +43,12 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
|
||||
)
|
||||
return (ctx: HandlerContext<Params>) => run(handler(ctx))
|
||||
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
|
||||
if (ctx.requestId === undefined) return run(handler(ctx))
|
||||
return run(
|
||||
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
|
||||
)
|
||||
}
|
||||
}
|
||||
const app = agent({ name: "opencode" })
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
@@ -122,10 +117,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make(ACPCapabilities.parse(undefined))
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, {
|
||||
service: ACPService.make({ client, connection, catalog, sessions, capabilities, turn }),
|
||||
connection,
|
||||
})
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
})
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@ import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
import { Cause, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ReadonlyArray<Model.Info>
|
||||
readonly defaultModel: Model.Ref
|
||||
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
|
||||
@@ -18,14 +19,24 @@ export type Catalog = {
|
||||
readonly commands: ReadonlyArray<Command.Info>
|
||||
}
|
||||
|
||||
export function findModel(models: ReadonlyArray<Model.Info>, ref: Model.Ref) {
|
||||
return models.find((model) => model.providerID === ref.providerID && model.id === ref.id)
|
||||
class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason === "models" ? "No models are available" : "No primary agents are available"
|
||||
}
|
||||
}
|
||||
|
||||
class LoadError extends Schema.TaggedError<LoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type Error = NotReadyError | LoadError
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, ACPError.CatalogError>
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, ACPError.CatalogError>
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, ACPError.CatalogError>
|
||||
readonly get: (cwd: string) => Effect.Effect<Catalog, Error>
|
||||
readonly reload: (cwd: string) => Effect.Effect<void, Error>
|
||||
readonly changes: (cwd: string) => Stream.Stream<Catalog, Error>
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
@@ -40,7 +51,7 @@ const reloadOn = new Set<OpenCodeEvent["type"]>(["model.updated", "agent.updated
|
||||
|
||||
export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const scope = yield* Effect.scope
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, ACPError.CatalogError>>()
|
||||
const entries = new Map<string, Deferred.Deferred<Entry, Error>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Requests queued behind a running load share the next one.
|
||||
@@ -100,7 +111,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
const key = FSUtil.resolve(cwd)
|
||||
const cached = entries.get(key)
|
||||
if (cached) return Deferred.await(cached)
|
||||
const loading = Deferred.makeUnsafe<Entry, ACPError.CatalogError>()
|
||||
const loading = Deferred.makeUnsafe<Entry, Error>()
|
||||
entries.set(key, loading)
|
||||
return create(cwd).pipe(
|
||||
Effect.onExit((exit) => {
|
||||
@@ -144,17 +155,20 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
client.command.list({ location }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new ACPError.CatalogLoadError({ cause })))
|
||||
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// The parallel default read can name a model missing from this list.
|
||||
const defaultModel = preferred ? findModel(models, preferred) : models[0]
|
||||
if (!defaultModel) return yield* new ACPError.CatalogNotReadyError({ reason: "models" })
|
||||
const defaultModel = preferred
|
||||
? models.find((model) => model.providerID === preferred.providerID && model.id === preferred.id)
|
||||
: models[0]
|
||||
if (!defaultModel) return yield* new NotReadyError({ reason: "models" })
|
||||
const agents = agentResult.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
// Core lists its resolved default agent first, the same one a new session runs.
|
||||
const defaultAgent = agents[0]
|
||||
if (!defaultAgent) return yield* new ACPError.CatalogNotReadyError({ reason: "agents" })
|
||||
if (!defaultAgent) return yield* new NotReadyError({ reason: "agents" })
|
||||
return {
|
||||
providers: providers(models),
|
||||
models,
|
||||
defaultModel: {
|
||||
providerID: defaultModel.providerID,
|
||||
@@ -167,4 +181,16 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: ReadonlyArray<Model.Info>) {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
id: providerID,
|
||||
name: providerID,
|
||||
models: models
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => ({ id: model.id, name: model.name, variants: model.variants.map((variant) => variant.id) })),
|
||||
}))
|
||||
}
|
||||
|
||||
export * as ACPCatalog from "./catalog"
|
||||
@@ -1,70 +1,51 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Effect, Order } from "effect"
|
||||
import { builtinCommands, findModel, type Catalog } from "./catalog"
|
||||
import { ACPError } from "./error"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
const DEFAULT_VARIANT_VALUE = "default"
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
type ConfigOptionModel = {
|
||||
id: string
|
||||
name: string
|
||||
variants?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
export type ConfigOptionProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: ReadonlyArray<ConfigOptionModel>
|
||||
}
|
||||
|
||||
type ConfigOptionMode = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ModelSelection = {
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
|
||||
export type Selection = {
|
||||
readonly model?: Model.Ref
|
||||
readonly modeID?: Agent.ID
|
||||
}
|
||||
|
||||
export type Change = { readonly model: Model.Ref } | { readonly modeID: Agent.ID }
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection): SessionConfigOption[] {
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
const model = currentModel(catalog, selection)
|
||||
const variants = findModel(catalog.models, model)?.variants.map((variant) => variant.id) ?? []
|
||||
return [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${model.providerID}/${model.id}`,
|
||||
options: catalog.models
|
||||
.toSorted((a, b) => Order.String(a.providerID, b.providerID) || a.name.localeCompare(b.name))
|
||||
.map((item) => ({ value: `${item.providerID}/${item.id}`, name: `${item.providerID}/${item.name}` })),
|
||||
},
|
||||
...(variants.length > 0
|
||||
? [
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(model.variant, variants),
|
||||
options: [...new Set([...variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
|
||||
value: variant,
|
||||
name: variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
})),
|
||||
} satisfies SessionConfigOption,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: selection.modeID ?? catalog.defaultModeID,
|
||||
options: catalog.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
},
|
||||
]
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
export function availableCommands(catalog: Catalog) {
|
||||
@@ -74,72 +55,111 @@ export function availableCommands(catalog: Catalog) {
|
||||
]
|
||||
}
|
||||
|
||||
export const resolveChange = Effect.fnUntraced(function* (
|
||||
catalog: Catalog,
|
||||
selection: Selection,
|
||||
configId: string,
|
||||
value: string,
|
||||
) {
|
||||
const current = currentModel(catalog, selection)
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return { model: yield* requireModel(catalog, value, current) }
|
||||
case "effort": {
|
||||
const model = findModel(catalog.models, current)
|
||||
if (!model || (value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === value)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort: value })
|
||||
return { model: { ...current, variant: Model.VariantID.make(value) } }
|
||||
}
|
||||
case "mode": {
|
||||
const mode = catalog.modes.find((item) => item.id === value)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: value })
|
||||
return { modeID: mode.id }
|
||||
}
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
export function parseModelSelection(value: string, models: ReadonlyArray<Model.Info>): Model.Ref {
|
||||
const providerID = models
|
||||
.map((model) => model.providerID)
|
||||
.toSorted()
|
||||
.find((id) => value.startsWith(`${id}/`))
|
||||
if (!providerID) {
|
||||
const separator = value.indexOf("/")
|
||||
if (separator === -1) return { providerID: Provider.ID.make(value), id: Model.ID.make("") }
|
||||
return { providerID: Provider.ID.make(value.slice(0, separator)), id: Model.ID.make(value.slice(separator + 1)) }
|
||||
}
|
||||
const id = Model.ID.make(value.slice(providerID.length + 1))
|
||||
if (findModel(models, { providerID, id })) return { providerID, id }
|
||||
const separator = id.lastIndexOf("/")
|
||||
const baseID = Model.ID.make(separator === -1 ? id : id.slice(0, separator))
|
||||
const variant = separator === -1 ? undefined : id.slice(separator + 1)
|
||||
const model = findModel(models, { providerID, id: baseID })
|
||||
if (model && variant && model.variants.some((item) => item.id === variant))
|
||||
return { providerID, id: baseID, variant: Model.VariantID.make(variant) }
|
||||
return { providerID, id }
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
currentVariant?: string
|
||||
modes?: readonly ConfigOptionMode[]
|
||||
currentModeId?: string
|
||||
}): SessionConfigOption[] {
|
||||
const variants =
|
||||
input.providers
|
||||
.find((provider) => provider.id === input.currentModel.providerID)
|
||||
?.models.find((model) => model.id === input.currentModel.modelID)?.variants ?? []
|
||||
const effort =
|
||||
variants.length > 0 ? buildEffortSelectOption({ variants, currentVariant: input.currentVariant }) : undefined
|
||||
return [
|
||||
buildModelSelectOption({ providers: input.providers, currentModel: input.currentModel }),
|
||||
...(effort ? [effort] : []),
|
||||
...(input.modes && input.currentModeId
|
||||
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, value: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(value, catalog.models)
|
||||
const model = findModel(catalog.models, selected)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.providerID, modelId: value })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selectedVariant?.id ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
export function buildModelSelectOption(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: `${input.currentModel.providerID}/${input.currentModel.modelID}`,
|
||||
options: input.providers.flatMap((provider) =>
|
||||
provider.models
|
||||
.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
.map((model) => ({ value: `${provider.id}/${model.id}`, name: `${provider.name}/${model.name}` })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEffortSelectOption(input: {
|
||||
variants: readonly string[]
|
||||
currentVariant?: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: selectVariant(input.currentVariant, input.variants),
|
||||
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
|
||||
value: variant,
|
||||
name: formatVariantName(variant),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildModeSelectOption(input: {
|
||||
modes: readonly ConfigOptionMode[]
|
||||
currentModeId: string
|
||||
}): SessionConfigOption {
|
||||
return {
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.currentModeId,
|
||||
options: input.modes.map((mode) => ({
|
||||
value: mode.id,
|
||||
name: mode.name,
|
||||
...(mode.description ? { description: mode.description } : {}),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
|
||||
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
|
||||
if (!provider) {
|
||||
const separator = modelId.indexOf("/")
|
||||
if (separator === -1) return { model: { providerID: modelId, modelID: "" } }
|
||||
return { model: { providerID: modelId.slice(0, separator), modelID: modelId.slice(separator + 1) } }
|
||||
}
|
||||
const modelID = modelId.slice(provider.id.length + 1)
|
||||
if (provider.models.some((model) => model.id === modelID)) return { model: { providerID: provider.id, modelID } }
|
||||
const separator = modelID.lastIndexOf("/")
|
||||
const baseModelID = separator === -1 ? modelID : modelID.slice(0, separator)
|
||||
const variant = separator === -1 ? undefined : modelID.slice(separator + 1)
|
||||
const model = provider.models.find((item) => item.id === baseModelID)
|
||||
if (model && variant && model.variants?.includes(variant)) {
|
||||
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
|
||||
}
|
||||
return { model: { providerID: provider.id, modelID } }
|
||||
}
|
||||
|
||||
export function formatVariantName(variant: string) {
|
||||
return variant
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
function selectVariant(variant: string | undefined, variants: readonly string[]) {
|
||||
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
|
||||
if (variants.includes(variant)) return variant
|
||||
if (variant && variants.includes(variant)) return variant
|
||||
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
|
||||
return variants[0] ?? DEFAULT_VARIANT_VALUE
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
@@ -30,8 +30,28 @@ type Input = {
|
||||
readonly clientSessionID: string
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Outcome = Form.Answer | "cancel" | "settled"
|
||||
|
||||
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("cancel" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("cancel" as const)),
|
||||
Effect.flatMap((outcome) => respond(input, outcome)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
export const UnshownQuestionMessage =
|
||||
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
|
||||
|
||||
@@ -83,7 +103,7 @@ function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answ
|
||||
)
|
||||
}
|
||||
|
||||
export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
|
||||
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
|
||||
const response = yield* input.connection.createElicitation({
|
||||
@@ -96,7 +116,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return answer(input.form, response) ?? "cancel"
|
||||
})
|
||||
|
||||
export function respond(input: Input, outcome: Form.Answer | "cancel") {
|
||||
function respond(input: Input, outcome: Outcome) {
|
||||
if (outcome === "settled") return Effect.void
|
||||
if (outcome === "cancel") return cancel(input.client, input.form)
|
||||
return input.client.session.form
|
||||
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -55,20 +56,6 @@ export class ServerUnavailableError extends Schema.TaggedError<ServerUnavailable
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class CatalogNotReadyError extends Schema.TaggedError<CatalogNotReadyError>()("ACPCatalogNotReadyError", {
|
||||
reason: Schema.Literals(["models", "agents"]),
|
||||
}) {
|
||||
override get message() {
|
||||
return this.reason === "models" ? "No models are available" : "No primary agents are available"
|
||||
}
|
||||
}
|
||||
|
||||
export class CatalogLoadError extends Schema.TaggedError<CatalogLoadError>()("ACPCatalogLoadError", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
export type CatalogError = CatalogNotReadyError | CatalogLoadError
|
||||
|
||||
export type Error =
|
||||
| SessionNotFoundError
|
||||
| SessionDirectoryMismatchError
|
||||
@@ -83,7 +70,7 @@ export type Error =
|
||||
| ServiceFailureError
|
||||
| ServerUnavailableError
|
||||
|
||||
export type Failure = Error | RequestError | CatalogError
|
||||
export type Failure = Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
|
||||
@@ -5,20 +5,11 @@ import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { applyPatch } from "diff"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import {
|
||||
absolutePath,
|
||||
canonicalName,
|
||||
filePath,
|
||||
patchHunks,
|
||||
pendingToolCall,
|
||||
stringValue,
|
||||
toLocations,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
|
||||
@@ -33,6 +24,7 @@ type Input = {
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -43,7 +35,24 @@ const options: PermissionOption[] = [
|
||||
|
||||
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("reject" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("reject" as const)),
|
||||
Effect.flatMap((decision) => respond(input, decision)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
@@ -71,7 +80,8 @@ export const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
export function respond(input: Input, decision: Permission.Reply) {
|
||||
function respond(input: Input, decision: Permission.Reply | "settled") {
|
||||
if (decision === "settled") return Effect.void
|
||||
return input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }).pipe(
|
||||
Effect.catchTag("PermissionNotFoundError", () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
@@ -85,7 +95,8 @@ const permissionPreviews = Effect.fnUntraced(function* (
|
||||
metadata: ToolInput | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
if (canonicalName(toolName) === "patch") return yield* patchPreviews(input, cwd)
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
|
||||
const previews = yield* Effect.forEach(
|
||||
files,
|
||||
@@ -126,7 +137,7 @@ function diff(path: string, oldText: string | null, newText: string) {
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
|
||||
if (previews.length > 1) return `${previews.length} files`
|
||||
switch (canonicalName(toolName)) {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "external_directory":
|
||||
return stringValue(input.description) ?? stringValue(input.command) ?? stringValue(input.parentDir)
|
||||
case "webfetch":
|
||||
@@ -140,6 +151,7 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
case "edit":
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return filePath(input) ?? previews[0]?.path
|
||||
default:
|
||||
return undefined
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Effect } from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPTranslate } from "./translate"
|
||||
|
||||
export type Prepared = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: Command.Info
|
||||
}
|
||||
|
||||
export const prepare = Effect.fnUntraced(function* (catalog: Catalog, content: readonly ContentBlock[]) {
|
||||
if (content.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(content), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const slash = detectSlashCommand(text)
|
||||
const id = SessionMessage.ID.create()
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id }
|
||||
: { type: "input", id },
|
||||
text,
|
||||
files: visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : [])),
|
||||
synthetic: parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : [])),
|
||||
slash,
|
||||
command: slash ? catalog.commands.find((item) => item.name === slash.name) : undefined,
|
||||
} satisfies Prepared
|
||||
})
|
||||
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
Effect.as(part),
|
||||
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
|
||||
)
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPPrompt from "./prompt"
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { DateTime, Effect, Ref, Schema } from "effect"
|
||||
@@ -35,9 +36,9 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { ACPCapabilities, type Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { configOptions, resolveChange } from "./config-option"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { ACPError } from "./error"
|
||||
@@ -77,21 +78,42 @@ export function make(input: {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
const withReload = <A>(attached: Attached, attempt: Effect.Effect<A, ACPError.Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(attempt))
|
||||
return attempt.pipe(
|
||||
const withReload = <A>(attached: Attached, select: Effect.Effect<A, ACPError.Failure>) => {
|
||||
const retry = () => input.catalog.reload(attached.cwd).pipe(Effect.andThen(select))
|
||||
return select.pipe(
|
||||
Effect.catchTags({ ACPInvalidModelError: retry, ACPInvalidModeError: retry, ACPInvalidEffortError: retry }),
|
||||
)
|
||||
}
|
||||
|
||||
const select = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
const change = yield* resolveChange(
|
||||
yield* input.catalog.get(attached.cwd),
|
||||
yield* Ref.get(attached.selection),
|
||||
configId,
|
||||
value,
|
||||
)
|
||||
yield* input.sessions.select(attached, change)
|
||||
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
|
||||
case "effort":
|
||||
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
|
||||
case "mode":
|
||||
return yield* selectMode(attached, value)
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
// Update selection before switching so the echoed event is a no-op.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const mode = catalog.modes.find((item) => item.id === modeID)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
|
||||
yield* input.client.session
|
||||
.switchAgent({ sessionID: attached.id, agent: mode.id })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
|
||||
@@ -223,12 +245,12 @@ export function make(input: {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const value = params.value
|
||||
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
yield* withReload(attached, select(attached, params.configId, value))
|
||||
yield* withReload(attached, selectOption(attached, params.configId, value))
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
setSessionMode: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
yield* withReload(attached, select(attached, "mode", params.modeId))
|
||||
yield* withReload(attached, selectMode(attached, params.modeId))
|
||||
return {}
|
||||
}),
|
||||
prompt: input.turn.prompt,
|
||||
@@ -248,4 +270,29 @@ const supportedMcpServers = Effect.fnUntraced(function* (servers: readonly McpSe
|
||||
return supported
|
||||
})
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selectedVariant?.id ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort })
|
||||
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
|
||||
})
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -6,7 +6,7 @@ import type { Session } from "@opencode/schema/session"
|
||||
import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { availableCommands, configOptions, type Change, type Selection } from "./config-option"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
@@ -28,13 +28,12 @@ export interface Interface {
|
||||
readonly release: (attached: Attached) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
|
||||
readonly select: (attached: Attached, change: Change) => Effect.Effect<void, ACPError.Error>
|
||||
}
|
||||
|
||||
type Entry = {
|
||||
readonly attached: Attached
|
||||
readonly scope: Scope.Closeable
|
||||
readonly selected: Queue.Queue<Change>
|
||||
readonly selected: Queue.Queue<Selection>
|
||||
}
|
||||
|
||||
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
|
||||
@@ -131,7 +130,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
},
|
||||
scope: Scope.forkUnsafe(scope),
|
||||
selected: yield* Queue.unbounded<Change>(),
|
||||
selected: yield* Queue.unbounded<Selection>(),
|
||||
}
|
||||
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
|
||||
const replaced = sessions.get(session.id)
|
||||
@@ -180,15 +179,6 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
// Update selection before switching so the echoed event is a no-op.
|
||||
select: Effect.fnUntraced(function* (attached, change) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, ...change }))
|
||||
yield* (
|
||||
"model" in change
|
||||
? input.client.session.switchModel({ sessionID: attached.id, model: change.model })
|
||||
: input.client.session.switchAgent({ sessionID: attached.id, agent: change.modeID })
|
||||
).pipe(Effect.catch(ACPClient.classify))
|
||||
}),
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
|
||||
@@ -8,12 +8,14 @@ import { Result } from "effect"
|
||||
export type ToolInput = Record<string, unknown>
|
||||
|
||||
function toToolKind(toolName: string): ToolKind {
|
||||
switch (canonicalName(toolName)) {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
return "execute"
|
||||
case "webfetch":
|
||||
return "fetch"
|
||||
case "edit":
|
||||
case "apply_patch":
|
||||
case "patch":
|
||||
case "write":
|
||||
return "edit"
|
||||
@@ -25,6 +27,7 @@ function toToolKind(toolName: string): ToolKind {
|
||||
return "search"
|
||||
case "read":
|
||||
return "read"
|
||||
case "task":
|
||||
case "subagent":
|
||||
return "think"
|
||||
default:
|
||||
@@ -33,7 +36,8 @@ function toToolKind(toolName: string): ToolKind {
|
||||
}
|
||||
|
||||
export function toLocations(toolName: string, input: ToolInput, cwd: string): ToolCallLocation[] {
|
||||
switch (canonicalName(toolName)) {
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "bash":
|
||||
case "shell":
|
||||
return locationFrom(cwd, stringValue(input.workdir) ?? stringValue(input.cwd) ?? cwd)
|
||||
case "read":
|
||||
@@ -41,6 +45,7 @@ export function toLocations(toolName: string, input: ToolInput, cwd: string): To
|
||||
case "write":
|
||||
return locationFrom(cwd, filePath(input))
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return locationFrom(
|
||||
cwd,
|
||||
...patchHunks(input).flatMap((hunk) => [hunk.path, hunk.type === "update" ? hunk.movePath : undefined]),
|
||||
@@ -100,7 +105,7 @@ export function completedToolUpdate(input: {
|
||||
}): ToolCallUpdate {
|
||||
const normalized = toolContent(input.content)
|
||||
const firstText = input.content.find((part) => part.type === "text")
|
||||
const read = canonicalName(input.toolName) === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const read = input.toolName.toLocaleLowerCase() === "read" && firstText ? readDisplayText(firstText.text) : undefined
|
||||
const images = normalized.filter((part) => part.type === "content" && part.content.type === "image")
|
||||
const primary =
|
||||
read === undefined
|
||||
@@ -168,7 +173,8 @@ function rawInput(toolName: string, input: ToolInput, cwd: string): ToolInput {
|
||||
}
|
||||
|
||||
function isShell(toolName: string) {
|
||||
return canonicalName(toolName) === "shell"
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
return tool === "bash" || tool === "shell"
|
||||
}
|
||||
|
||||
function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
@@ -178,17 +184,6 @@ function locationFrom(cwd: string, ...values: unknown[]): ToolCallLocation[] {
|
||||
)
|
||||
}
|
||||
|
||||
const V1Aliases = new Map([
|
||||
["bash", "shell"],
|
||||
["task", "subagent"],
|
||||
["apply_patch", "patch"],
|
||||
])
|
||||
|
||||
export function canonicalName(toolName: string) {
|
||||
const name = toolName.toLocaleLowerCase()
|
||||
return V1Aliases.get(name) ?? name
|
||||
}
|
||||
|
||||
// Sessions migrated from V1 keep their original `filePath` tool inputs.
|
||||
export function filePath(input: ToolInput) {
|
||||
return stringValue(input.path) ?? stringValue(input.filePath)
|
||||
|
||||
+143
-150
@@ -15,12 +15,14 @@ export type TurnStart = { readonly type: "input" | "compaction"; readonly id: Se
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type TurnContext = {
|
||||
export type Context = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
readonly compaction: boolean
|
||||
/** Background mode follows open children after the turn ends and never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
|
||||
type Tool = {
|
||||
@@ -53,7 +55,6 @@ export type TurnState = {
|
||||
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
type CreatedEvent = Extract<OpenCodeEvent, { type: "session.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
@@ -64,10 +65,15 @@ export type Output =
|
||||
readonly tool?: Tool
|
||||
readonly child?: ACPChild.Session
|
||||
}
|
||||
| { readonly _tag: "FormAsk"; readonly form: FormEvent["data"]["form"]; readonly child?: ACPChild.Session }
|
||||
| {
|
||||
readonly _tag: "FormAsk"
|
||||
readonly form: FormEvent["data"]["form"]
|
||||
readonly child?: ACPChild.Session
|
||||
readonly toolCallSent: boolean
|
||||
}
|
||||
| { readonly _tag: "AskSettled"; readonly id: string }
|
||||
|
||||
export type Folded = {
|
||||
type Step = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
@@ -83,11 +89,27 @@ export const initial: TurnState = {
|
||||
asks: new Set(),
|
||||
}
|
||||
|
||||
export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
|
||||
if (event.type === "session.created") return childCreated(state, event, ctx)
|
||||
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
|
||||
const sessionID = sessionIDFromEvent(event)
|
||||
const child = sessionID ? state.children.get(sessionID) : undefined
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
|
||||
if (ctx.mode === "background" && !child) return { state, outputs: [] }
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
|
||||
const tool = event.data.source?.id
|
||||
@@ -101,7 +123,14 @@ export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext):
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state: { ...state, asks: new Set(state.asks).add(event.data.form.id) },
|
||||
outputs: [{ _tag: "FormAsk", form: event.data.form, child }],
|
||||
outputs: [
|
||||
{
|
||||
_tag: "FormAsk",
|
||||
form: event.data.form,
|
||||
child,
|
||||
toolCallSent: ctx.mode === "turn" && (!child || !ctx.childUpdates),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
const settledID =
|
||||
@@ -115,148 +144,22 @@ export function fold(state: TurnState, event: OpenCodeEvent, ctx: TurnContext):
|
||||
asks.delete(settledID)
|
||||
return { state: { ...state, asks }, outputs: [{ _tag: "AskSettled", id: settledID }] }
|
||||
}
|
||||
if (!sessionID || (sessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (event.type === "session.inbox.delivered" && event.data.inboxID === ctx.start.id)
|
||||
return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
return child ? childEvent(state, event, ctx, child) : rootEvent(state, event, ctx)
|
||||
}
|
||||
|
||||
export function fromTrackedChild(state: TurnState, event: OpenCodeEvent) {
|
||||
const sessionID = event.type === "session.created" ? event.data.parentID : sessionIDFromEvent(event)
|
||||
return sessionID !== undefined && state.children.has(sessionID)
|
||||
}
|
||||
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
// Child compactions are left to the background consumer.
|
||||
export function abandon(state: TurnState, ctx: TurnContext): Folded {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
function childCreated(state: TurnState, event: CreatedEvent, ctx: TurnContext): Folded {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && parentID !== ctx.sessionID) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
|
||||
function rootEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext): Folded {
|
||||
switch (event.type) {
|
||||
case "session.step.started":
|
||||
return sessionEvent({ ...state, stepError: undefined }, event, ctx, undefined)
|
||||
case "session.step.ended":
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
return { state, outputs: [], terminal: "succeeded" }
|
||||
case "session.execution.interrupted":
|
||||
return { state, outputs: [], terminal: "interrupted" }
|
||||
case "session.execution.failed":
|
||||
return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
default:
|
||||
return sessionEvent(state, event, ctx, undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function childEvent(state: TurnState, event: OpenCodeEvent, ctx: TurnContext, child: ACPChild.Session): Folded {
|
||||
switch (event.type) {
|
||||
case "session.execution.started":
|
||||
return { state, outputs: childStatus(ctx, child, { type: "status", status: "running" }) }
|
||||
case "session.execution.succeeded":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" })
|
||||
case "session.execution.interrupted":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" })
|
||||
case "session.execution.failed":
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error })
|
||||
default:
|
||||
return sessionEvent(state, event, ctx, child)
|
||||
}
|
||||
}
|
||||
|
||||
function sessionEvent(
|
||||
state: TurnState,
|
||||
event: OpenCodeEvent,
|
||||
ctx: TurnContext,
|
||||
child: ACPChild.Session | undefined,
|
||||
): Folded {
|
||||
const sessionID = child?.id ?? ctx.sessionID
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
switch (event.type) {
|
||||
case "session.step.started":
|
||||
if (!state.retries.has(sessionID)) return { state, outputs: [] }
|
||||
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
|
||||
case "session.step.started": {
|
||||
const next = child ? state : { ...state, stepError: undefined }
|
||||
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
|
||||
return {
|
||||
state: { ...state, retries: without(state.retries, sessionID) },
|
||||
state: { ...next, retries: without(state.retries, eventSessionID) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
|
||||
}
|
||||
}
|
||||
case "session.retry.scheduled": {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
@@ -264,7 +167,7 @@ function sessionEvent(
|
||||
error: event.data.error,
|
||||
}
|
||||
return {
|
||||
state: { ...state, retries: new Map(state.retries).set(sessionID, retry) },
|
||||
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
|
||||
}
|
||||
}
|
||||
@@ -280,7 +183,7 @@ function sessionEvent(
|
||||
}
|
||||
case "session.compaction.delta": {
|
||||
const update = ACPCompaction.chunk(
|
||||
state.compactions.get(sessionID),
|
||||
state.compactions.get(eventSessionID),
|
||||
event.data.text,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
@@ -396,31 +299,121 @@ function sessionEvent(
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.step.ended":
|
||||
if (child) return { state, outputs: [] }
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
if (child) return { state, outputs: [] }
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
if (!child) return { state, outputs: [], terminal: "succeeded" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
|
||||
case "session.execution.interrupted":
|
||||
if (!child) return { state, outputs: [], terminal: "interrupted" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
|
||||
case "session.execution.failed":
|
||||
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
|
||||
default:
|
||||
return { state, outputs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Interruption clears the projected retry, so a retry pending at interrupt is reported here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
// Child compactions are left to the background consumer.
|
||||
export function abandon(state: TurnState, ctx: Context): Step {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function reasoningMessageID(messageID: string, ordinal: number) {
|
||||
return `${messageID}:reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
function newTool(sessionID: string, id: string, name = "tool"): Tool {
|
||||
return { sessionID, id, name, input: {}, metadata: {} }
|
||||
}
|
||||
|
||||
function route(ctx: TurnContext, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return [{ _tag: "SessionUpdate", update }]
|
||||
function route(ctx: Context, child: ACPChild.Session | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
const projected = ACPChild.project(update, child)
|
||||
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
|
||||
return [{ _tag: "SessionUpdate", update: projected }]
|
||||
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
|
||||
}
|
||||
|
||||
function childStatus(ctx: TurnContext, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
function childStatus(ctx: Context, child: ACPChild.Session, event: ACPChild.Event): Output[] {
|
||||
if (!ctx.childUpdates) return []
|
||||
return [{ _tag: "ChildUpdate", update: ACPChild.update(ctx.sessionID, child, event) }]
|
||||
}
|
||||
|
||||
function childEnded(state: TurnState, ctx: TurnContext, child: ACPChild.Session, status: ACPChild.Event): Folded {
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ACPChild.Session,
|
||||
status: ACPChild.Event,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
openChildren.delete(child.id)
|
||||
return { state: { ...state, openChildren }, outputs: childStatus(ctx, child, status) }
|
||||
return {
|
||||
state: { ...state, openChildren },
|
||||
outputs: childStatus(ctx, child, status),
|
||||
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
|
||||
|
||||
+189
-149
@@ -1,5 +1,7 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
Cause,
|
||||
@@ -16,16 +18,18 @@ import {
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import type { Capabilities } from "./capabilities"
|
||||
import type { ACPCatalog } from "./catalog"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { ACPChild } from "./child"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPrompt } from "./prompt"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
@@ -41,19 +45,24 @@ export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/c
|
||||
defaultValue: () => "5 seconds",
|
||||
})
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: Command.Info
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
|
||||
type Subscription = {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
|
||||
readonly askQueue: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
readonly settled: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
type Turn = {
|
||||
readonly ctx: ACPTranslate.TurnContext
|
||||
readonly state: Ref.Ref<ACPTranslate.TurnState>
|
||||
readonly subscription: Subscription
|
||||
/** Asks run serially off the event stream. */
|
||||
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
readonly background: boolean
|
||||
readonly settled: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
@@ -67,7 +76,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, ACPError.Failure>()
|
||||
|
||||
const open = Effect.fnUntraced(function* (ctx: ACPTranslate.TurnContext, state: Ref.Ref<ACPTranslate.TurnState>) {
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
// Parented to the service scope; the session scope may already be closed.
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
@@ -75,18 +84,19 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
events: yield* input.client.event
|
||||
.subscribe()
|
||||
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
|
||||
askQueue: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
settled: new Map(),
|
||||
}
|
||||
yield* Queue.take(subscription.askQueue).pipe(
|
||||
yield* Queue.take(subscription.asks).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP ask reply failed", cause),
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkIn(subscriptionScope),
|
||||
)
|
||||
return { ctx, state, subscription, cancelled: yield* Deferred.make<void>(), background: false } satisfies Turn
|
||||
return subscription
|
||||
})
|
||||
|
||||
const take = (subscription: Subscription) =>
|
||||
@@ -98,44 +108,35 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
|
||||
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(subscription.askQueue, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const queueAsk = <A>(
|
||||
turn: Turn,
|
||||
id: string,
|
||||
ask: Effect.Effect<A, unknown>,
|
||||
fallback: A,
|
||||
respond: (outcome: A) => Effect.Effect<void, ACPError.Error | RequestError>,
|
||||
const reply = (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
ask: PermissionAsk,
|
||||
settled: Deferred.Deferred<void>,
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
turn.subscription.settled.set(id, settled)
|
||||
yield* Queue.offer(
|
||||
turn.subscription.askQueue,
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
Deferred.await(turn.cancelled).pipe(
|
||||
Effect.as(fallback),
|
||||
Effect.raceFirst(Deferred.await(settled).pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed(fallback)),
|
||||
Effect.flatMap((outcome) => (outcome === "settled" ? Effect.void : respond(outcome))),
|
||||
),
|
||||
).pipe(Effect.withSpan("cli.acp.turn.ask")),
|
||||
)
|
||||
})
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: ask.event,
|
||||
sessionID: ask.event.data.sessionID,
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
child: ask.child,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
|
||||
const interpret = (turn: Turn, output: ACPTranslate.Output) => {
|
||||
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
|
||||
switch (output._tag) {
|
||||
case "SessionUpdate":
|
||||
if (turn.background) return Effect.void
|
||||
return input.connection.sessionUpdate({ sessionId: turn.ctx.sessionID, update: output.update })
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPChild.UpdateMethod, output.update)
|
||||
@@ -146,79 +147,65 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
: Effect.logWarning("ACP child session update failed", cause),
|
||||
),
|
||||
)
|
||||
case "PermissionAsk": {
|
||||
const permission = {
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: output.event,
|
||||
sessionID: output.event.data.sessionID,
|
||||
clientSessionID: turn.ctx.sessionID,
|
||||
cwd: turn.ctx.cwd,
|
||||
tool: output.tool,
|
||||
child: output.child,
|
||||
}
|
||||
return queueAsk(turn, output.event.data.id, ACPPermission.ask(permission), "reject", (decision) =>
|
||||
ACPPermission.respond(permission, decision),
|
||||
)
|
||||
}
|
||||
case "PermissionAsk":
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.event.data.id, settled)
|
||||
yield* Queue.offer(subscription.asks, reply(subscription, ctx, output, settled))
|
||||
})
|
||||
case "FormAsk":
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
const elicitation = {
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
form: output.form,
|
||||
requestedSchema,
|
||||
clientSessionID: turn.ctx.sessionID,
|
||||
child: output.child,
|
||||
toolCallSent: !turn.background && (!output.child || !turn.ctx.childUpdates),
|
||||
}
|
||||
yield* queueAsk(turn, output.form.id, ACPElicitation.ask(elicitation), "cancel", (outcome) =>
|
||||
ACPElicitation.respond(elicitation, outcome),
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.settled.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
subscription.asks,
|
||||
ACPElicitation.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
form: output.form,
|
||||
requestedSchema,
|
||||
clientSessionID: ctx.sessionID,
|
||||
child: output.child,
|
||||
toolCallSent: output.toolCallSent,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
),
|
||||
)
|
||||
})
|
||||
case "AskSettled":
|
||||
return Effect.suspend(() => {
|
||||
const settled = turn.subscription.settled.get(output.id)
|
||||
turn.subscription.settled.delete(output.id)
|
||||
const settled = subscription.settled.get(output.id)
|
||||
subscription.settled.delete(output.id)
|
||||
return settled ? Deferred.succeed(settled, undefined) : Effect.void
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const advance = Effect.fnUntraced(function* (turn: Turn) {
|
||||
const event = yield* take(turn.subscription)
|
||||
const folded = yield* Ref.modify(turn.state, (current) => {
|
||||
const next =
|
||||
turn.background && !ACPTranslate.fromTrackedChild(current, event)
|
||||
? { state: current, outputs: [] }
|
||||
: ACPTranslate.fold(current, event, turn.ctx)
|
||||
return [next, next.state]
|
||||
})
|
||||
yield* Effect.forEach(folded.outputs, (output) => interpret(turn, output), { discard: true })
|
||||
return folded
|
||||
})
|
||||
|
||||
const consume = Effect.fnUntraced(function* (turn: Turn) {
|
||||
const consume = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) {
|
||||
while (true) {
|
||||
const folded = yield* advance(turn)
|
||||
if (folded.terminal) {
|
||||
yield* asksSettled(turn.subscription)
|
||||
return folded.terminal
|
||||
const event = yield* take(subscription)
|
||||
const next = yield* Ref.modify(state, (current) => {
|
||||
const step = ACPTranslate.step(current, event, ctx)
|
||||
return [step, step.state]
|
||||
})
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* asksSettled(subscription)
|
||||
return next.terminal
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const followChildren = Effect.fnUntraced(function* (turn: Turn) {
|
||||
while (true) {
|
||||
const folded = yield* advance(turn)
|
||||
if (folded.state.openChildren.size === 0) return yield* asksSettled(turn.subscription)
|
||||
}
|
||||
})
|
||||
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* input.client.session
|
||||
@@ -254,64 +241,80 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
})
|
||||
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
turn: Turn,
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(turn.cancelled, undefined)
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* input.client.session
|
||||
.interrupt({ sessionID: turn.ctx.sessionID })
|
||||
.interrupt({ sessionID: ctx.sessionID })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP server interrupt failed", cause),
|
||||
),
|
||||
)
|
||||
if (!(yield* Ref.get(turn.state)).started) return
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.exists(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)), Exit.isSuccess)) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(turn.state), turn.ctx)
|
||||
yield* Ref.set(turn.state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(turn, output), { discard: true }).pipe(Effect.ignore)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
const execute = (attached: Attached, prompt: ACPPrompt.Prepared, turn: Turn) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(turn.subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(turn).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(turn, events)))
|
||||
}).pipe(Effect.scoped)
|
||||
const execute = (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
subscribe(),
|
||||
(subscription) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
|
||||
}).pipe(Effect.scoped),
|
||||
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
|
||||
)
|
||||
|
||||
const handoff = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
turn: Turn,
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
const close = Scope.close(turn.subscription.scope, Exit.void)
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
const state = yield* Ref.get(turn.state)
|
||||
if (state.openChildren.size === 0) return yield* close
|
||||
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
|
||||
// Children outlive a cancelled turn, so their asks still reach the client.
|
||||
const background = followChildren({
|
||||
...turn,
|
||||
state: yield* Ref.make(state),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
background: true,
|
||||
}).pipe(Effect.ignore, Effect.ensuring(close), Effect.withSpan("cli.acp.turn.background"))
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(close),
|
||||
Effect.withSpan("cli.acp.turn.background"),
|
||||
)
|
||||
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
current: ACPTranslate.TurnState,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, ACPError.Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
const failure = ACPTranslate.failure(current)
|
||||
if (failure) return yield* failure
|
||||
yield* sendUsageUpdate(attached, current)
|
||||
@@ -343,31 +346,32 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: ACPPrompt.Prepared) {
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const exit = yield* Effect.acquireUseRelease(
|
||||
open(
|
||||
{
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
},
|
||||
state,
|
||||
),
|
||||
(turn) => execute(attached, prompt, turn),
|
||||
(turn, exit) => handoff(attached, turn, exit),
|
||||
).pipe(Effect.interruptible, Effect.exit)
|
||||
return yield* settle(attached, yield* Ref.get(state), exit)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
return yield* settle(attached, state, exit)
|
||||
})
|
||||
|
||||
return {
|
||||
prompt: Effect.fnUntraced(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const prompt = yield* ACPPrompt.prepare(catalog, params.prompt)
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
|
||||
// Synchronous, so concurrent prompts for one session cannot both register.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
@@ -409,4 +413,40 @@ function aborted(signal: AbortSignal) {
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
return {
|
||||
start:
|
||||
slash && builtinCommands.get(slash.name)?.start === "compaction"
|
||||
? { type: "compaction", id: messageID }
|
||||
: { type: "input", id: messageID },
|
||||
text,
|
||||
files,
|
||||
synthetic,
|
||||
slash,
|
||||
command,
|
||||
}
|
||||
}
|
||||
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
Effect.as(part),
|
||||
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
|
||||
)
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string) {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPTurn from "./turn"
|
||||
@@ -1,13 +1,13 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Service, type DiscoverOptions } from "@opencode/client/effect/service"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { Effect, Option, Redacted, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
@@ -64,11 +64,11 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const config = options.mode === "service" ? yield* ServiceConfig.read() : {}
|
||||
const hostname = options.hostname ?? config.hostname ?? "127.0.0.1"
|
||||
const port = options.port ?? config.port ?? (options.mode === "service" ? ServiceConfig.defaultPort() : undefined)
|
||||
const findIncumbent =
|
||||
const incumbent =
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (findIncumbent !== undefined && (yield* findIncumbent) !== undefined) return
|
||||
if (incumbent !== undefined) return
|
||||
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
|
||||
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
|
||||
yield* RetainedImage.retain(global.cache, "service")
|
||||
@@ -88,7 +88,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const launch = start(
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
name: process.env.OPENCODE_CLIENT ?? OPENCODE_ARTIFACT,
|
||||
@@ -144,29 +144,22 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
)
|
||||
const server = yield* launch.pipe(
|
||||
).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (findIncumbent === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return Effect.gen(function* () {
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
const found = yield* findIncumbent.pipe(Effect.timeoutOption(deadline - Date.now()))
|
||||
if (Option.isSome(found) && found.value !== undefined) return
|
||||
yield* Effect.sleep("100 millis")
|
||||
if (Date.now() >= deadline) break
|
||||
// Failed binds close their scope; a successful bind may take longer than this window to boot.
|
||||
const server = yield* launch.pipe(Effect.catchIf(addressInUse, () => Effect.void))
|
||||
if (server !== undefined) return server
|
||||
}
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
)
|
||||
})
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
return recognizeIncumbent(serviceOptions, hostname, port).pipe(
|
||||
Effect.flatMap((found) =>
|
||||
found
|
||||
? Effect.void
|
||||
: Effect.fail(
|
||||
new Error(
|
||||
`Managed service port ${port} on ${hostname} is already in use by another process. ` +
|
||||
"Configure another port with `opencode service set port <port>` and start the service again.",
|
||||
{ cause: error },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
@@ -182,6 +175,15 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry(Schedule.spaced("100 millis")),
|
||||
Effect.timeoutOption("15 seconds"),
|
||||
)
|
||||
return Option.isSome(found)
|
||||
})
|
||||
|
||||
function serviceURL(hostname: string, port: number) {
|
||||
return `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${port}`
|
||||
}
|
||||
|
||||
@@ -58,10 +58,8 @@ function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
// qr-scanner decodes in a worker it creates from a blob: URL whenever the browser has no BarcodeDetector (Safari, desktop
|
||||
// Chrome on Windows and Linux). Without worker-src that worker falls under script-src 'self' and never starts.
|
||||
function csp(hash = "") {
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:; worker-src 'self' blob:`
|
||||
return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:`
|
||||
}
|
||||
|
||||
function cspForHtml(body: string) {
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import type { Catalog } from "../../src/acp/catalog"
|
||||
import { configOptions, parseModelSelection } from "../../src/acp/config-option"
|
||||
import { buildEffortSelectOption, parseModelSelection, type ConfigOptionProvider } from "../../src/acp/config-option"
|
||||
|
||||
const model = (providerID: string, id: string, name: string, variants: string[] = []) => ({
|
||||
...Model.Info.default(Provider.ID.make(providerID), Model.ID.make(id)),
|
||||
name,
|
||||
variants: variants.map((variant) => ({ id: Model.VariantID.make(variant) })),
|
||||
})
|
||||
|
||||
const catalog: Catalog = {
|
||||
models: [
|
||||
model("anthropic", "claude/sonnet-4", "Claude Sonnet 4", ["default", "high", "very-high"]),
|
||||
model("anthropic", "claude-haiku", "Claude Haiku"),
|
||||
model("openai", "gpt-5", "GPT-5", ["minimal", "low"]),
|
||||
model("test", "effort", "Effort", ["low", "default", "high"]),
|
||||
],
|
||||
defaultModel: { providerID: Provider.ID.make("openai"), id: Model.ID.make("gpt-5") },
|
||||
modes: [{ id: Agent.ID.make("build"), name: "Build" }],
|
||||
defaultModeID: Agent.ID.make("build"),
|
||||
commands: [],
|
||||
}
|
||||
|
||||
const effortOption = (providerID: string, id: string) =>
|
||||
configOptions(catalog, {
|
||||
model: {
|
||||
providerID: Provider.ID.make(providerID),
|
||||
id: Model.ID.make(id),
|
||||
variant: Model.VariantID.make("missing"),
|
||||
},
|
||||
}).find((option) => option.id === "effort")
|
||||
const providers: ConfigOptionProvider[] = [
|
||||
{
|
||||
id: "anthropic",
|
||||
name: "Anthropic",
|
||||
models: [
|
||||
{ id: "claude/sonnet-4", name: "Claude Sonnet 4", variants: ["default", "high", "very-high"] },
|
||||
{ id: "claude-haiku", name: "Claude Haiku" },
|
||||
],
|
||||
},
|
||||
{ id: "openai", name: "OpenAI", models: [{ id: "gpt-5", name: "GPT-5", variants: ["minimal", "low"] }] },
|
||||
]
|
||||
|
||||
describe("acp config options", () => {
|
||||
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
|
||||
expect(effortOption("test", "effort")).toEqual({
|
||||
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
@@ -48,29 +28,21 @@ describe("acp config options", () => {
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
})
|
||||
expect(effortOption("openai", "gpt-5")?.currentValue).toBe("minimal")
|
||||
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" }).currentValue).toBe(
|
||||
"minimal",
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["openai/gpt-5", { providerID: Provider.ID.openai, id: Model.ID.make("gpt-5") }],
|
||||
[
|
||||
"openai/gpt-5/low",
|
||||
{ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5"), variant: Model.VariantID.make("low") },
|
||||
],
|
||||
["anthropic/claude/sonnet-4", { providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4") }],
|
||||
["openai/gpt-5", { model: { providerID: "openai", modelID: "gpt-5" } }],
|
||||
["openai/gpt-5/low", { model: { providerID: "openai", modelID: "gpt-5" }, variant: "low" }],
|
||||
["anthropic/claude/sonnet-4", { model: { providerID: "anthropic", modelID: "claude/sonnet-4" } }],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/high",
|
||||
{
|
||||
providerID: Provider.ID.anthropic,
|
||||
id: Model.ID.make("claude/sonnet-4"),
|
||||
variant: Model.VariantID.make("high"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"anthropic/claude/sonnet-4/missing",
|
||||
{ providerID: Provider.ID.anthropic, id: Model.ID.make("claude/sonnet-4/missing") },
|
||||
{ model: { providerID: "anthropic", modelID: "claude/sonnet-4" }, variant: "high" },
|
||||
],
|
||||
["anthropic/claude/sonnet-4/missing", { model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" } }],
|
||||
])("parses the model selection %s, preferring exact slash-containing model ids", (value, expected) => {
|
||||
expect(parseModelSelection(value, catalog.models)).toEqual(expected)
|
||||
expect(parseModelSelection(value, providers)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
failed,
|
||||
interrupted,
|
||||
reasoningDelta,
|
||||
stepEnded,
|
||||
succeeded,
|
||||
@@ -25,12 +26,13 @@ import {
|
||||
} from "./wire-fixture"
|
||||
|
||||
const root = Session.ID.make("ses_root")
|
||||
const ctx: ACPTranslate.TurnContext = {
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: root,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: SessionMessage.ID.make("msg_input") },
|
||||
childUpdates: false,
|
||||
compaction: false,
|
||||
mode: "turn",
|
||||
}
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
|
||||
@@ -69,7 +71,7 @@ const compactionMessage = (input: Record<string, unknown>) => ({
|
||||
|
||||
type Row = {
|
||||
readonly name: string
|
||||
readonly ctx?: Partial<ACPTranslate.TurnContext>
|
||||
readonly ctx?: Partial<ACPTranslate.Context>
|
||||
readonly events?: OpenCodeEventEncoded[]
|
||||
readonly messages?: unknown[]
|
||||
readonly expected: object
|
||||
@@ -599,6 +601,23 @@ describe("acp turn translation", () => {
|
||||
test.each(rows)("$name", (row) => {
|
||||
expect(translate(row)).toMatchObject(row.expected)
|
||||
})
|
||||
|
||||
test("ends a background consumer when its last open child settles, without session updates", () => {
|
||||
const turn = run(live(childCreated("ses_a", root, "A"), childCreated("ses_b", root, "B"), succeeded(root)))
|
||||
const background = { ...ctx, mode: "background" as const }
|
||||
const first = run(
|
||||
[textDelta(root, "msg_root", "ignored"), toolStarted("ses_a", "call_1", "read"), succeeded("ses_a")],
|
||||
background,
|
||||
turn.state,
|
||||
)
|
||||
const last = run([childCreated("ses_later", root, "Later"), interrupted("ses_b")], background, first.state)
|
||||
|
||||
expect(turn.terminal).toBe("succeeded")
|
||||
expect(first.outputs).toEqual([])
|
||||
expect(first.terminal).toBeUndefined()
|
||||
expect(last.state.children.has("ses_later")).toBe(false)
|
||||
expect(last.terminal).toBe("interrupted")
|
||||
})
|
||||
})
|
||||
|
||||
function translate(row: Row) {
|
||||
@@ -627,21 +646,21 @@ function translate(row: Row) {
|
||||
}
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx) {
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
|
||||
return events.reduce<{
|
||||
state: ACPTranslate.TurnState
|
||||
outputs: ACPTranslate.Output[]
|
||||
terminal?: ACPTranslate.Terminal
|
||||
}>(
|
||||
(acc, event, index) => {
|
||||
const next = ACPTranslate.fold(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
...(next.terminal ? { terminal: next.terminal } : {}),
|
||||
}
|
||||
},
|
||||
{ state: ACPTranslate.initial, outputs: [] },
|
||||
{ state, outputs: [] },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
permissionAsked,
|
||||
startSession,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolCalled,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
@@ -59,8 +58,6 @@ describe("acp turn events over the wire", () => {
|
||||
{ childSessionId: "ses_background", type: "status", status: "created" },
|
||||
{ childSessionId: "ses_child", type: "status", status: "completed" },
|
||||
{ childSessionId: "ses_background", type: "status", status: "running" },
|
||||
{ childSessionId: "ses_background", type: "update", update: { sessionUpdate: "agent_message_chunk" } },
|
||||
{ childSessionId: "ses_background", type: "status", status: "completed" },
|
||||
],
|
||||
},
|
||||
])(
|
||||
@@ -69,43 +66,35 @@ describe("acp turn events over the wire", () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { childSessionUpdates: row.childSessionUpdates },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
acp.server.prompts.length > 1
|
||||
? turn(sessionID, id)
|
||||
: turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
toolStarted("ses_grandchild", "call_read", "read"),
|
||||
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
permissionAsked("ses_grandchild", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_read" },
|
||||
}),
|
||||
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
|
||||
succeeded("ses_grandchild"),
|
||||
childCreated("ses_background", sessionID, "Research"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
childCreated("ses_grandchild", "ses_child", "Deeper"),
|
||||
toolStarted("ses_grandchild", "call_read", "read"),
|
||||
toolCalled("ses_grandchild", "call_read", { path: "/workspace/src/index.ts" }),
|
||||
permissionAsked("ses_grandchild", "perm_child", {
|
||||
action: "read",
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_read" },
|
||||
}),
|
||||
toolSucceeded("ses_grandchild", "call_read", {}, "source"),
|
||||
succeeded("ses_grandchild"),
|
||||
childCreated("ses_background", sessionID, "Research"),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
permission: () => ({ outcome: { outcome: "selected", optionId: "once" } }),
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
const responded = turnUpdates(acp.updates).length
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
textDelta("ses_background", "msg_background", "late"),
|
||||
succeeded("ses_background"),
|
||||
permissionAsked("ses_background", "perm_after_end", { action: "read" }),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 2, "child permission replies")
|
||||
expect(turnUpdates(acp.updates).slice(responded)).toEqual([])
|
||||
expect((await acp.prompt(acp.sessionId, "again")).stopReason).toBe("end_turn")
|
||||
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update)).toMatchObject(row.parent)
|
||||
expect(acp.childUpdates).toMatchObject(row.child)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -403,77 +403,6 @@ test("unrelated managed port occupancy reports an actionable conflict", async ()
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("the original managed service contender binds when the occupied port is released", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-bind-retry-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
const requests: string[] = []
|
||||
using listener = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push(new URL(request.url).pathname)
|
||||
if (requests.length === 2) recognizing.resolve()
|
||||
return Response.json({ unrelated: true })
|
||||
},
|
||||
})
|
||||
const port = listener.port
|
||||
if (port === undefined) throw new Error("Server did not bind a port")
|
||||
const registration = path.join(root, "state", "opencode", "service-local.json")
|
||||
await fs.mkdir(path.join(root, "config"), { recursive: true })
|
||||
await fs.mkdir(path.dirname(registration), { recursive: true })
|
||||
await fs.writeFile(path.join(root, "config", "service-local.json"), JSON.stringify({ port }))
|
||||
await fs.writeFile(
|
||||
registration,
|
||||
JSON.stringify({
|
||||
id: "stale",
|
||||
version: OPENCODE_VERSION,
|
||||
url: "http://127.0.0.1:1",
|
||||
pid: 2_147_483_647,
|
||||
password: "stale",
|
||||
}),
|
||||
)
|
||||
const contender = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
|
||||
env: isolatedEnv(root),
|
||||
stderr: "pipe",
|
||||
stdout: "ignore",
|
||||
})
|
||||
const stderr = new Response(contender.stderr).text()
|
||||
try {
|
||||
// The first probe is preflight; the second only happens after start() fails to bind.
|
||||
expect(await Promise.race([recognizing.promise.then(() => true), Bun.sleep(20_000).then(() => false)])).toBe(true)
|
||||
expect(requests).toEqual(["/api/info", "/api/info"])
|
||||
await listener.stop(true)
|
||||
|
||||
const info = await Promise.race([
|
||||
waitForInfo(registration, (info) => info.pid === contender.pid),
|
||||
contender.exited.then(() => undefined),
|
||||
])
|
||||
expect(info?.pid, contender.exitCode === null ? undefined : await stderr).toBe(contender.pid)
|
||||
const endpoint = await Effect.runPromise(
|
||||
Service.discover({ file: registration }).pipe(
|
||||
Effect.filterOrFail((value) => value !== undefined),
|
||||
Effect.retry({ times: 400, schedule: Schedule.spaced("50 millis") }),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(new URL(endpoint.url).port).toBe(String(port))
|
||||
expect(
|
||||
await fetch(new URL("/api/info", endpoint.url), { headers: Service.headers(endpoint) }).then((response) =>
|
||||
response.json(),
|
||||
),
|
||||
).toMatchObject({ pid: contender.pid, version: OPENCODE_VERSION, urls: [endpoint.url] })
|
||||
expect(contender.exitCode).toBe(null)
|
||||
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
|
||||
expect(await waitForExit(contender)).toBe(true)
|
||||
expect(await Bun.file(registration).exists()).toBe(false)
|
||||
await expectPortAvailable(port)
|
||||
} finally {
|
||||
contender.kill("SIGTERM")
|
||||
await contender.exited
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 45_000)
|
||||
|
||||
test("unresponsive managed port occupancy reports a bounded conflict", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-unresponsive-conflict-"))
|
||||
const recognizing = Promise.withResolvers<void>()
|
||||
|
||||
@@ -171,7 +171,6 @@ describe("web UI", () => {
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("connect-src * data: blob:")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("worker-src 'self' blob:")
|
||||
|
||||
const dotted = yield* Effect.promise(() => fetch(`${origin}/workspace/example.js`))
|
||||
expect(dotted.status).toBe(200)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
@@ -9,10 +11,8 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, type LocalService, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
/** Contents of the local service registration file. */
|
||||
export type Info = import("../service.js").Info
|
||||
|
||||
@@ -40,9 +40,9 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
options: DiscoverOptions & { readonly url: string },
|
||||
) {
|
||||
const info = yield* read(options.file)
|
||||
if (info === undefined) return undefined
|
||||
const found = (yield* Effect.promise(() => probeResult({ ...info, url: options.url }))).service
|
||||
if (!found?.compatible) return undefined
|
||||
const found = info === undefined ? undefined : yield* probe({ ...info, url: options.url })
|
||||
if (found === undefined) return undefined
|
||||
if (!found.compatible) return undefined
|
||||
if (!matchesVersion(found.version, options)) return undefined
|
||||
return { endpoint: found.endpoint, state: found.state }
|
||||
})
|
||||
@@ -58,7 +58,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) =>
|
||||
Effect.sync(() => {
|
||||
if (announced) return
|
||||
@@ -90,13 +89,6 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
yield* Effect.logWarning("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
yield* Effect.tryPromise(() => PtyHandoff.clear(options.file ?? fallback()))
|
||||
yield* terminate(info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -125,27 +117,19 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
return Option.none<LocalService>()
|
||||
} else if (lastSpawn === 0 && info !== undefined) lastSpawn = Date.now()
|
||||
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
failure ??= finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
const failure = finished.map(contenderFailure).find((error): error is Error => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) return yield* Effect.fail(failure)
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
yield* announce("missing")
|
||||
contenders.add(yield* spawnContender)
|
||||
lastSpawn = Date.now()
|
||||
@@ -161,7 +145,7 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
@@ -177,6 +161,17 @@ export const stop = Effect.fn("service.stop")(function* (options: StopOptions =
|
||||
if (info !== undefined) yield* terminate(info, options, ensureTiming(options))
|
||||
})
|
||||
|
||||
function fallback() {
|
||||
const state = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state")
|
||||
return join(state, "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return { authorization: "Basic " + btoa(endpoint.auth.username + ":" + endpoint.auth.password) }
|
||||
}
|
||||
|
||||
/** Schema for the local service registration file. */
|
||||
export const Info = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
@@ -187,6 +182,13 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
const decodeInfo = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
version: Schema.String,
|
||||
pid: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
||||
}),
|
||||
)
|
||||
|
||||
// A missing or corrupt file means no valid info; callers treat both
|
||||
// the same (the registering server self-evicts, clients rediscover).
|
||||
const read = Effect.fnUntraced(function* (file?: string) {
|
||||
@@ -196,10 +198,77 @@ const read = Effect.fnUntraced(function* (file?: string) {
|
||||
return yield* decode(text.value).pipe(Effect.option, Effect.map(Option.getOrUndefined))
|
||||
})
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
const probe = Effect.fnUntraced(function* (info: Info) {
|
||||
return (yield* probeResult(info)).service
|
||||
})
|
||||
|
||||
const probeResult = Effect.fnUntraced(function* (info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const body = result.value.body
|
||||
const serverInfo = decodeInfo(body)
|
||||
if (Option.isSome(serverInfo)) {
|
||||
if (serverInfo.value.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.value.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.value.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
})
|
||||
|
||||
const registered = Effect.fnUntraced(function* (file?: string, timeout?: number) {
|
||||
const info = yield* read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
return { info, ...(yield* Effect.promise(() => probeResult(info, timeout))) }
|
||||
return { info, ...(yield* probeResult(info, timeout)) }
|
||||
})
|
||||
|
||||
// 50ms cadence bounded at ~5s, shared by stop escalation and each ensure
|
||||
@@ -218,6 +287,10 @@ const stopped = Effect.fnUntraced(function* (pid: number) {
|
||||
return yield* Effect.fail(new Error(`Server process ${pid} is still running`))
|
||||
})
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
const terminate = Effect.fnUntraced(function* (info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = yield* read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
@@ -9,10 +11,8 @@ import {
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
import { fallback, headers, probeResult, same } from "../service-probe.js"
|
||||
|
||||
export * from "../service.js"
|
||||
export { headers }
|
||||
|
||||
// Find, start, and stop the local opencode background service.
|
||||
//
|
||||
@@ -38,7 +38,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
let announced = false
|
||||
let lastSpawn = 0
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let failure: Error | undefined
|
||||
|
||||
const announce = (reason: "missing" | "version-mismatch", previousVersion?: string) => {
|
||||
if (announced) return
|
||||
@@ -57,7 +56,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) throw failure ?? new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw new Error("Timed out waiting for the background service to start")
|
||||
const registration = await registered(options.file, timing.requestTimeout)
|
||||
if (registration.timedOut && registration.info !== undefined) {
|
||||
timeouts = {
|
||||
@@ -69,13 +68,6 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
console.warn("Background service is unresponsive; recovery cannot preserve persistent terminals")
|
||||
await PtyHandoff.clear(options.file ?? fallback())
|
||||
await terminate(registration.info, options, timing)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === registration.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
timeouts = undefined
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
}
|
||||
@@ -103,27 +95,19 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
failure ??= finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
const failure = finished.map(contenderFailure).find((error) => error !== undefined)
|
||||
if (finished.some((item) => item.child.exitCode === 0)) {
|
||||
spawnDelay = Math.min(spawnDelay * 2, timing.maxSpawnDelay)
|
||||
}
|
||||
finished.forEach((item) => contenders.delete(item))
|
||||
if (failure !== undefined && contenders.size === 0) throw failure
|
||||
// Keep one candidate plus one lock probe for pre-lock stalls. After a failure, let the
|
||||
// survivors finish without recruiting replacements that could hide the error indefinitely.
|
||||
if (failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
// Keep one candidate plus one lock probe so a pre-lock stall cannot block recovery.
|
||||
if (contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay) {
|
||||
announce("missing")
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
@@ -148,6 +132,18 @@ export async function stop(options: StopOptions = {}) {
|
||||
if (info !== undefined) await terminate(info, options, ensureTiming(options))
|
||||
}
|
||||
|
||||
function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
async function read(file?: string) {
|
||||
const text = await readFile(file ?? fallback(), "utf8").catch(() => undefined)
|
||||
if (text === undefined) return undefined
|
||||
@@ -158,6 +154,73 @@ async function read(file?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth:
|
||||
info.password === undefined
|
||||
? undefined
|
||||
: { type: "basic" as const, username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.then(
|
||||
(value) => ({ value }),
|
||||
(cause: unknown) => ({ cause }),
|
||||
)
|
||||
if ("cause" in result) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.value.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready" as const,
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.value.body)
|
||||
if (serverInfo !== undefined) {
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version)
|
||||
return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
return { service: undefined, timedOut: false }
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
|
||||
async function registered(file?: string, timeout?: number) {
|
||||
const info = await read(file)
|
||||
if (info === undefined) return { info: undefined, service: undefined, timedOut: false }
|
||||
@@ -187,6 +250,10 @@ async function waitUntilStopped(pid: number, timing: EnsureTiming) {
|
||||
return false
|
||||
}
|
||||
|
||||
function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
async function terminate(info: Info, options: { readonly file?: string }, timing: EnsureTiming) {
|
||||
const current = await read(options.file)
|
||||
if (current === undefined || !same(current, info)) return
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Endpoint, Info } from "./service.js"
|
||||
import { defaultEnsureTiming } from "./service-timing.js"
|
||||
|
||||
// Shared by the Effect and Promise clients. Keep this module free of Effect so the
|
||||
// Promise client never loads the Effect runtime.
|
||||
|
||||
export type LocalService = {
|
||||
readonly info: Info
|
||||
readonly endpoint: Endpoint
|
||||
readonly version?: string
|
||||
readonly state: "ready" | "waiting" | "failed"
|
||||
readonly compatible: boolean
|
||||
}
|
||||
|
||||
/** The default registration file. */
|
||||
export function fallback() {
|
||||
return join(process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state"), "opencode", "service.json")
|
||||
}
|
||||
|
||||
/** Whether two registrations describe the same service instance. */
|
||||
export function same(left: Info, right: Info) {
|
||||
return left.id === right.id && left.version === right.version && left.url === right.url && left.pid === right.pid
|
||||
}
|
||||
|
||||
/** Ask a registered owner for its health and classify the answer. */
|
||||
export async function probeResult(info: Info, timeout = defaultEnsureTiming.requestTimeout) {
|
||||
const endpoint = {
|
||||
url: info.url,
|
||||
auth: info.password === undefined ? undefined : { type: "basic", username: "opencode", password: info.password },
|
||||
} satisfies Endpoint
|
||||
const signal = AbortSignal.timeout(timeout)
|
||||
const result = await fetch(new URL("/api/info", info.url), { headers: headers(endpoint), signal })
|
||||
.then(async (response) => ({
|
||||
response,
|
||||
body: response.status === 404 ? undefined : ((await response.json()) as unknown),
|
||||
}))
|
||||
.catch(() => undefined)
|
||||
if (result === undefined) return { service: undefined, timedOut: signal.aborted }
|
||||
const response = result.response
|
||||
// A missing health endpoint identifies protocol incompatibility, not an older
|
||||
// version. Only an unmet version requirement lets ensure replace this owner.
|
||||
if (response.status === 404)
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: info.version,
|
||||
state: "ready",
|
||||
compatible: false,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
const serverInfo = decodeInfo(result.body)
|
||||
if (serverInfo === undefined) return { service: undefined, timedOut: false }
|
||||
if (serverInfo.pid !== info.pid) return { service: undefined, timedOut: false }
|
||||
if (info.version !== undefined && serverInfo.version !== info.version) return { service: undefined, timedOut: false }
|
||||
return {
|
||||
service: {
|
||||
info,
|
||||
endpoint,
|
||||
version: serverInfo.version,
|
||||
state: response.ok ? "ready" : response.status === 500 ? "failed" : "waiting",
|
||||
compatible: true,
|
||||
} satisfies LocalService,
|
||||
timedOut: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Create HTTP authentication headers for a service endpoint. */
|
||||
export function headers(endpoint: Endpoint) {
|
||||
if (endpoint.auth === undefined) return undefined
|
||||
return {
|
||||
authorization: "Basic " + Buffer.from(endpoint.auth.username + ":" + endpoint.auth.password).toString("base64"),
|
||||
}
|
||||
}
|
||||
|
||||
function decodeInfo(input: unknown) {
|
||||
if (typeof input !== "object" || input === null) return
|
||||
if (!("version" in input) || typeof input.version !== "string") return
|
||||
if (!("pid" in input) || typeof input.pid !== "number" || !Number.isInteger(input.pid) || input.pid < 0) return
|
||||
return { version: input.version, pid: input.pid }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rename, rm } from "node:fs/promises"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { waitForExit } from "./service-timing"
|
||||
@@ -8,16 +8,6 @@ export async function serviceFixture() {
|
||||
const registration = join(directory, "service.json")
|
||||
const processes: Bun.Subprocess[] = []
|
||||
const pids = new Set<number>()
|
||||
const starts = async () =>
|
||||
(
|
||||
await Bun.file(registration + ".starts")
|
||||
.text()
|
||||
.catch(() => "")
|
||||
)
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map(Number)
|
||||
const command = (mode: string, ...args: string[]) => [
|
||||
process.execPath,
|
||||
join(import.meta.dir, "service.ts"),
|
||||
@@ -30,21 +20,6 @@ export async function serviceFixture() {
|
||||
directory,
|
||||
registration,
|
||||
command,
|
||||
starts,
|
||||
async waitForStarts(count: number) {
|
||||
for (let attempt = 0; attempt < 600; attempt++) {
|
||||
const result = await starts()
|
||||
if (result.length >= count) return result
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${count} contenders`)
|
||||
},
|
||||
async release(pid: number, action: "fail" | "hang" | "ready") {
|
||||
const file = registration + `.release-${pid}`
|
||||
await Bun.write(file + ".tmp", action)
|
||||
await rename(file + ".tmp", file)
|
||||
if (action === "fail") await waitForExit(pid)
|
||||
},
|
||||
spawn(mode: string, ...args: string[]) {
|
||||
const subprocess = Bun.spawn(command(mode, ...args), { stdout: "ignore", stderr: "inherit" })
|
||||
processes.push(subprocess)
|
||||
@@ -72,8 +47,6 @@ export async function serviceFixture() {
|
||||
throw new Error(`Timed out waiting for ${file}`)
|
||||
},
|
||||
async [Symbol.asyncDispose]() {
|
||||
// Include detached contenders that never registered, even when an assertion fails.
|
||||
for (const pid of await starts()) pids.add(pid)
|
||||
await Promise.all([
|
||||
...processes.map(async (subprocess) => {
|
||||
subprocess.kill("SIGTERM")
|
||||
|
||||
@@ -17,18 +17,6 @@ if (mode === "environment") {
|
||||
}
|
||||
if (mode === "signal") process.kill(process.pid, process.platform === "win32" ? "SIGTERM" : "SIGKILL")
|
||||
|
||||
let controlled = ""
|
||||
if (mode === "controlled") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const release = registration + `.release-${process.pid}`
|
||||
while (!(await Bun.file(release).exists())) await Bun.sleep(5)
|
||||
controlled = await Bun.file(release).text()
|
||||
if (controlled === "fail") {
|
||||
process.stderr.write("actionable startup failure: storage initialization denied\n")
|
||||
process.exit(23)
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "delayed" || mode === "delayed-failed" || mode === "coordinated" || mode === "coordinated-failed-loser") {
|
||||
await appendFile(registration + ".starts", process.pid + "\n")
|
||||
const owner = await writeFile(registration + ".owner", String(process.pid), { flag: "wx" })
|
||||
@@ -75,7 +63,7 @@ const server = Bun.serve({
|
||||
return new Response(null, { status: 404 })
|
||||
requests += 1
|
||||
if (mode === "starting") await writeFile(registration + ".status-request", "")
|
||||
if (mode === "hanging" || controlled === "hang") {
|
||||
if (mode === "hanging") {
|
||||
await appendFile(registration + ".requests", process.pid + "\n")
|
||||
return new Promise<Response>(() => {})
|
||||
}
|
||||
@@ -104,10 +92,8 @@ const server = Bun.serve({
|
||||
})
|
||||
|
||||
// Install handlers before publishing: a test may signal as soon as the registration appears.
|
||||
if (controlled !== "hang") {
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
}
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
|
||||
await writeFile(
|
||||
registration + ".tmp",
|
||||
|
||||
@@ -112,76 +112,6 @@ test("reports a failed registered service", async () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") })
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await Service.discover({ file: fixture.registration, version: "test" }))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail with native promises", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -290,76 +290,6 @@ test("reports a contender that fails to start", async () => {
|
||||
).rejects.toThrow("Server process exited with code 1")
|
||||
})
|
||||
|
||||
test("reports overlapping contender failures without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
// Let discovery observe the exit across two accelerated spawn windows before the survivor exits.
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("retains a contender failure until the deadline while its survivor stalls", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const started = Date.now()
|
||||
const pending = run(
|
||||
ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }),
|
||||
).catch((error: unknown) => error)
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
const error = await pending
|
||||
|
||||
expect(Date.now() - started).toBeGreaterThanOrEqual(3_000)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toContain("Server process exited with code 23")
|
||||
expect(error.message).toContain("storage initialization denied")
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
expect(() => process.kill(second, 0)).not.toThrow()
|
||||
})
|
||||
|
||||
test("accepts a surviving contender after a failure without recruiting replacements", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(first, "fail")
|
||||
await Bun.sleep(450)
|
||||
await fixture.release(second, "ready")
|
||||
const endpoint = await pending
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(second)
|
||||
expect(await fixture.starts()).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("recovers when an unresponsive contender is evicted after a prior failure", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const pending = run(ensure({ file: fixture.registration, version: "test", command: fixture.command("controlled") }))
|
||||
const [first, second] = await fixture.waitForStarts(2)
|
||||
await fixture.release(second, "hang")
|
||||
await fixture.waitForFile()
|
||||
await fixture.release(first, "fail")
|
||||
const [, , third] = await fixture.waitForStarts(3)
|
||||
await fixture.release(third, "ready")
|
||||
const endpoint = await pending
|
||||
fixture.track(third)
|
||||
|
||||
expect((await run(Service.discover({ file: fixture.registration, version: "test" })))?.url).toBe(endpoint.url)
|
||||
expect((await Bun.file(fixture.registration).json()).pid).toBe(third)
|
||||
})
|
||||
|
||||
test("reports a bounded contender stderr tail", async () => {
|
||||
await using fixture = await serviceFixture()
|
||||
const registration = fixture.registration
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,19 +7,13 @@ import { Permission } from "../permission.js"
|
||||
|
||||
const PROMPT_EXPLORE = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
- Use Glob for broad file pattern matching
|
||||
- Use Grep for searching file contents with regex
|
||||
- Use Read when you know the specific file path you need to read
|
||||
- Your role is EXCLUSIVELY to search and analyze
|
||||
- Parallelize independent tool calls for searches and reads whenever possible
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- For clear communication, avoid using emojis
|
||||
- Do not create any files, or run bash commands that modify the user's system state in any way
|
||||
- You MUST NOT create, modify, delete, move, or copy files, including temporary files and reports
|
||||
- Shell commands MUST be read-only. NEVER run commands that write files or change system state
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
@@ -115,6 +109,7 @@ export const Plugin = define({
|
||||
...Permission.merge(
|
||||
[
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "shell", resource: "*", effect: "allow" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
|
||||
@@ -203,6 +203,8 @@ describe("Agent", () => {
|
||||
expect(Permission.evaluate("external_directory", path.join(global.config, "*"), permissions).effect).toBe("allow")
|
||||
expect(Permission.evaluate("external_directory", path.join(global.tmp, "*"), permissions).effect).toBe("allow")
|
||||
const explore = yield* agent.get(Agent.ID.make("explore"))
|
||||
expect(Permission.evaluate("shell", "git log -5", explore?.permissions ?? []).effect).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "src/index.ts", explore?.permissions ?? []).effect).toBe("deny")
|
||||
expect(Permission.evaluate("read", ".env", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.local", explore?.permissions ?? []).effect).toBe("ask")
|
||||
expect(Permission.evaluate("read", ".env.example", explore?.permissions ?? []).effect).toBe("allow")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -4,26 +4,20 @@ import { Script } from "@opencode/script"
|
||||
import { UpdateArtifact } from "../../../script/update-artifact"
|
||||
|
||||
const dryRun = process.argv.includes("--dry-run")
|
||||
|
||||
if (!Script.release) {
|
||||
console.log("skipped desktop publication without a release")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const directory = process.env.OPENCODE_DESKTOP_DIST
|
||||
|
||||
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
|
||||
|
||||
const files = (
|
||||
await Array.fromAsync(
|
||||
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
|
||||
)
|
||||
).sort()
|
||||
|
||||
if (!files.length) throw new Error("No desktop release files found")
|
||||
|
||||
const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun })
|
||||
|
||||
const artifact = {
|
||||
channel: Script.channel,
|
||||
name: "desktop",
|
||||
@@ -31,9 +25,7 @@ const artifact = {
|
||||
version: Script.version,
|
||||
metadata: { files: uploaded, ...(await metadata(Script.version, uploaded)) },
|
||||
}
|
||||
|
||||
if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(artifact)}`)
|
||||
|
||||
if (!dryRun) await UpdateArtifact.publish(artifact)
|
||||
|
||||
type DesktopFile = {
|
||||
@@ -45,9 +37,7 @@ type DesktopFile = {
|
||||
|
||||
async function metadata(version: string, files: Record<string, { url: string }>) {
|
||||
const directory = process.env.LATEST_YML_DIR
|
||||
|
||||
if (!directory) throw new Error("LATEST_YML_DIR is required")
|
||||
|
||||
const entries = await Promise.all(
|
||||
[
|
||||
{
|
||||
@@ -74,75 +64,54 @@ async function metadata(version: string, files: Record<string, { url: string }>)
|
||||
await Promise.all(
|
||||
item.sources.map(async ([subdirectory, source]) => {
|
||||
const file = Bun.file(`${directory}/${subdirectory}/${source}`)
|
||||
|
||||
if (!(await file.exists())) return undefined
|
||||
|
||||
return parse(await file.text(), version, files)
|
||||
}),
|
||||
)
|
||||
).filter((manifest) => manifest !== undefined)
|
||||
|
||||
if (manifests.length !== item.sources.length) return undefined
|
||||
|
||||
return [
|
||||
item.name,
|
||||
{ files: manifests.flatMap((manifest) => manifest.files), releaseDate: manifests[0]!.releaseDate },
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
|
||||
if (entries.some((entry) => entry === undefined)) throw new Error("Desktop update metadata is incomplete")
|
||||
const manifests = Object.fromEntries(entries.filter((entry) => entry !== undefined))
|
||||
|
||||
return { manifests }
|
||||
}
|
||||
|
||||
function parse(content: string, version: string, uploaded: Record<string, { url: string }>) {
|
||||
const lines = content.split("\n")
|
||||
|
||||
const found = lines
|
||||
.find((line) => line.startsWith("version:"))
|
||||
?.slice("version:".length)
|
||||
.trim()
|
||||
|
||||
if (found !== version) throw new Error(`Desktop metadata version mismatch: expected ${version}, got ${found}`)
|
||||
|
||||
const releaseDate = lines
|
||||
.find((line) => line.startsWith("releaseDate:"))
|
||||
?.slice("releaseDate:".length)
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
|
||||
if (!releaseDate) throw new Error("Desktop metadata did not include a release date")
|
||||
const files: DesktopFile[] = []
|
||||
lines.forEach((line) => {
|
||||
const value = line.trim()
|
||||
|
||||
if (value.startsWith("- url:")) {
|
||||
const name = value.slice("- url:".length).trim()
|
||||
const filename = name.startsWith("http") ? decodeURIComponent(new URL(name).pathname.split("/").pop()!) : name
|
||||
const url = uploaded[filename]?.url
|
||||
|
||||
if (!url) throw new Error(`Desktop update file was not uploaded: ${filename}`)
|
||||
files.push({ url, sha512: "", size: 0 })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const current = files.at(-1)
|
||||
|
||||
// electron-builder repeats the first file's sha512 as a top-level legacy field after the list.
|
||||
if (!current || !line.startsWith(" ")) return
|
||||
|
||||
if (!current) return
|
||||
if (value.startsWith("sha512:")) current.sha512 = value.slice("sha512:".length).trim()
|
||||
|
||||
if (value.startsWith("size:")) current.size = Number(value.slice("size:".length).trim())
|
||||
|
||||
if (value.startsWith("blockMapSize:")) current.blockMapSize = Number(value.slice("blockMapSize:".length).trim())
|
||||
})
|
||||
|
||||
if (!files.length || files.some((file) => !file.sha512 || !file.size))
|
||||
throw new Error("Desktop metadata contained an incomplete file")
|
||||
|
||||
return { files, releaseDate }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { Schema } from "effect"
|
||||
import { Extension, Store } from "../sdk"
|
||||
import { Extension } from "../sdk"
|
||||
import en from "./i18n/en"
|
||||
|
||||
const Chats = Schema.Struct({
|
||||
chats: Schema.Array(
|
||||
Schema.Struct({ id: Schema.String, question: Schema.String, answer: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
export default Extension.define({
|
||||
id: "btw",
|
||||
stores: {
|
||||
// Each open /btw tab's chat (its question and answer), kept until its tab closes.
|
||||
chats: Store.session(Chats, { chats: [] }),
|
||||
},
|
||||
i18n: { en },
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { batch, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { showToast } from "@opencode/ui/toast"
|
||||
import type { MountedSession, SetupContext } from "../sdk"
|
||||
import { createKeyed, type MountedSession, type SetupContext } from "../sdk"
|
||||
import type Btw from "./index"
|
||||
|
||||
const instructions = [
|
||||
@@ -10,56 +10,38 @@ const instructions = [
|
||||
"Do not call any tools and do not take any actions.",
|
||||
].join(" ")
|
||||
|
||||
/** One-shot side questions per session, one tab each, stored until the tab closes. */
|
||||
const empty = {
|
||||
question: "",
|
||||
answer: "",
|
||||
error: false,
|
||||
pending: false,
|
||||
}
|
||||
|
||||
/** Side questions per session. Window-local: a reload drops them, and with them the tab. */
|
||||
export function createBtw(ctx: SetupContext<typeof Btw>) {
|
||||
const sessions = ctx.sessions
|
||||
const layout = ctx.layout
|
||||
const [states, setStates] = createStore<Record<string, typeof empty>>({})
|
||||
const requests = new Map<string, number>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const [requests, setRequests] = createStore<{ pending: string[] }>({ pending: [] })
|
||||
const saved = (session: MountedSession) => ctx.stores.chats(session)
|
||||
|
||||
const stop = (id: string) => {
|
||||
controllers.get(id)?.abort()
|
||||
controllers.delete(id)
|
||||
setRequests("pending", (list) => list.filter((item) => item !== id))
|
||||
const stop = (key: string) => {
|
||||
const controller = controllers.get(key)
|
||||
|
||||
if (!controller) return
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
|
||||
if (states[key]?.pending) setStates(key, { pending: false, error: true })
|
||||
}
|
||||
|
||||
// In-flight questions keep running when the user leaves their session and answer into its store; only a reload,
|
||||
// closing the question's tab or disposing the extension abandons one, which leaves it retryable.
|
||||
// Leaving a session abandons its in-flight question; the same session moving to another directory does not.
|
||||
createKeyed(
|
||||
() => sessions.current()?.key,
|
||||
(key) => onCleanup(() => stop(key)),
|
||||
)
|
||||
onCleanup(() => Array.from(controllers.keys()).forEach(stop))
|
||||
|
||||
const entry = (session: MountedSession, id: string) => saved(session).value?.chats.find((item) => item.id === id)
|
||||
const pending = (id: string) => requests.pending.includes(id)
|
||||
|
||||
// Takes the question rather than reading it back, because a new chat's write may still wait for the store to load.
|
||||
const generate = (session: MountedSession, id: string, question: string) => {
|
||||
if (pending(id)) return
|
||||
|
||||
const store = saved(session)
|
||||
const controller = new AbortController()
|
||||
controllers.set(id, controller)
|
||||
setRequests("pending", (list) => [...list, id])
|
||||
|
||||
return (
|
||||
session.server.client.session
|
||||
.generate(
|
||||
{ sessionID: session.id, prompt: [instructions, question].join("\n\n") },
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (ctx.signal.aborted || controller.signal.aborted) return
|
||||
store.update((draft) => {
|
||||
const chat = draft.chats.find((item) => item.id === id)
|
||||
|
||||
if (chat) chat.answer = result.text.trim()
|
||||
})
|
||||
})
|
||||
// A missing answer is the retry state; no transient error flag is stored.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (controllers.get(id) === controller) stop(id)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const ask = (value?: string) => {
|
||||
const question = value?.trim()
|
||||
|
||||
@@ -69,47 +51,54 @@ export function createBtw(ctx: SetupContext<typeof Btw>) {
|
||||
return
|
||||
}
|
||||
|
||||
const session = ctx.sessions.current()
|
||||
const session = sessions.current()
|
||||
|
||||
if (!session?.id) return
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// Desktop storage loads asynchronously. A write before load queues and lands in the same batch that marks the
|
||||
// store ready, and the panel keeps a tab hidden until then, so the chat and its tab need not wait. The session's
|
||||
// store lives while its tab stays open, so leaving the session first still records the chat.
|
||||
const key = session.key
|
||||
const request = (requests.get(key) ?? 0) + 1
|
||||
requests.set(key, request)
|
||||
controllers.get(key)?.abort()
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
// The tab lists only while its session has a question, and the host drops unlisted transient keys, so
|
||||
// record the question in the same batch that opens the tab.
|
||||
batch(() => {
|
||||
saved(session).update((draft) => {
|
||||
draft.chats.push({ id, question })
|
||||
})
|
||||
ctx.layout.open(`${ctx.id}:${id}`, session, { tab: "select" })
|
||||
setStates(key, { question, answer: "", error: false, pending: true })
|
||||
layout.open(`${ctx.id}:main`, session)
|
||||
})
|
||||
void generate(session, id, question)
|
||||
|
||||
return session.server.client.session
|
||||
.generate(
|
||||
{
|
||||
sessionID: session.id,
|
||||
prompt: [instructions, question].join("\n\n"),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
.then((result) => {
|
||||
if (controller.signal.aborted || requests.get(key) !== request) return
|
||||
setStates(key, { answer: result.text.trim(), pending: false })
|
||||
})
|
||||
.catch(() => {
|
||||
if (controller.signal.aborted || requests.get(key) !== request) return
|
||||
setStates(key, { error: true, pending: false })
|
||||
})
|
||||
.finally(() => {
|
||||
if (controllers.get(key) === controller) controllers.delete(key)
|
||||
})
|
||||
}
|
||||
|
||||
const state = (session: MountedSession) => states[session.key]
|
||||
|
||||
return {
|
||||
ask,
|
||||
saved,
|
||||
pending,
|
||||
question: (session: MountedSession, id: string) => entry(session, id)?.question,
|
||||
answer: (session: MountedSession, id: string) => entry(session, id)?.answer,
|
||||
error: (session: MountedSession, id: string) => {
|
||||
const item = entry(session, id)
|
||||
|
||||
return !!item && item.answer === undefined && !pending(id)
|
||||
},
|
||||
retry: (session: MountedSession, id: string) => {
|
||||
const question = entry(session, id)?.question
|
||||
|
||||
if (question) void generate(session, id, question)
|
||||
},
|
||||
/** Closing a tab forgets its question and answer. */
|
||||
remove: (session: MountedSession, id: string) => {
|
||||
stop(id)
|
||||
saved(session).update((draft) => {
|
||||
draft.chats = draft.chats.filter((item) => item.id !== id)
|
||||
})
|
||||
},
|
||||
has: (session: MountedSession) => !!state(session),
|
||||
answer: (session: MountedSession) => (state(session) ?? empty).answer,
|
||||
error: (session: MountedSession) => (state(session) ?? empty).error,
|
||||
pending: (session: MountedSession) => (state(session) ?? empty).pending,
|
||||
question: (session: MountedSession) => (state(session) ?? empty).question,
|
||||
retry: (session: MountedSession) => ask((state(session) ?? empty).question),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@ import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { useExtension, type MountedSession } from "../sdk"
|
||||
import type { BtwModel } from "./model"
|
||||
|
||||
export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession; id: string }) {
|
||||
export default function SessionBtwPanel(props: { btw: BtwModel; session: MountedSession }) {
|
||||
const ctx = useExtension()
|
||||
const system = ctx.system
|
||||
const answer = () => props.btw.answer(props.session, props.id)
|
||||
const answer = () => props.btw.answer(props.session)
|
||||
// A new token for each answer, so the copied mark clears when the answer changes.
|
||||
const shown = createMemo(on(answer, () => ({})))
|
||||
const [copiedAnswer, setCopiedAnswer] = createSignal<object>()
|
||||
@@ -32,7 +32,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
|
||||
return (
|
||||
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
|
||||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
|
||||
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session, props.id)}</div>
|
||||
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question(props.session)}</div>
|
||||
<Show when={answer()}>
|
||||
<Tooltip value={copied() ? ctx.t("common.copied") : ctx.t("copy")}>
|
||||
<IconButton
|
||||
@@ -48,7 +48,7 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<Switch>
|
||||
<Match when={props.btw.pending(props.id)}>
|
||||
<Match when={props.btw.pending(props.session)}>
|
||||
<div
|
||||
data-component="session-working"
|
||||
role="status"
|
||||
@@ -57,22 +57,20 @@ export default function SessionBtwPanel(props: { btw: BtwModel; session: Mounted
|
||||
<TextShimmer text={ctx.t("session.timeline.working")} active />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={props.btw.error(props.session, props.id)}>
|
||||
<Match when={props.btw.error(props.session)}>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
|
||||
<div class="text-13-regular text-text-weak">{ctx.t("error")}</div>
|
||||
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session, props.id)}>
|
||||
<Button size="small" variant="outline" onClick={() => props.btw.retry(props.session)}>
|
||||
{ctx.t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={answer()}>
|
||||
{(text) => (
|
||||
<ScrollView class="absolute inset-0">
|
||||
<div class="px-5 py-4 pb-8">
|
||||
<Markdown text={text()} class="text-14-regular" />
|
||||
</div>
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView class="absolute inset-0">
|
||||
<div class="px-5 py-4 pb-8">
|
||||
<Markdown text={answer()} class="text-14-regular" />
|
||||
</div>
|
||||
</ScrollView>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, lazy, onCleanup, Suspense } from "solid-js"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Command, createKeyed, onIdle, Panel, type PanelTab, type Setup } from "../sdk"
|
||||
import { Command, onIdle, Panel, type PanelTab, type Setup } from "../sdk"
|
||||
import type Btw from "./index"
|
||||
import { createBtw } from "./model"
|
||||
|
||||
@@ -13,19 +13,18 @@ const setup: Setup<typeof Btw> = (ctx) => {
|
||||
// Changes when a session mounts or unmounts, not on every switch between sessions.
|
||||
const mounted = createMemo(() => !!sessions.current())
|
||||
|
||||
// Tab objects per session, reused so strip updates and session switches do not rebuild a trigger.
|
||||
const tabs = new Map<string, Map<string, PanelTab>>()
|
||||
|
||||
// Drops the tab objects of sessions whose shell tab closed.
|
||||
createKeyed(
|
||||
() => [...sessions.list().map((session) => session.key), sessions.current()?.key].join("\u0000"),
|
||||
() => {
|
||||
const keep = new Set([...sessions.list().map((session) => session.key), sessions.current()?.key])
|
||||
tabs.forEach((_cache, key) => {
|
||||
if (!keep.has(key)) tabs.delete(key)
|
||||
})
|
||||
const tab: PanelTab = {
|
||||
id: "main",
|
||||
get title() {
|
||||
return ctx.t("tab.title")
|
||||
},
|
||||
)
|
||||
label: () => (
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="bubble-5" size="small" />
|
||||
<span>{ctx.t("tab.title")}</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
ctx.add(
|
||||
Command,
|
||||
@@ -49,52 +48,13 @@ const setup: Setup<typeof Btw> = (ctx) => {
|
||||
transient: true,
|
||||
// Layouts saved before extensions store the tab as "btw"; as a panel key it leaves like any unlisted transient tab.
|
||||
legacy: { btw: "main" },
|
||||
// One tab per stored question. Until the session's store loads, its tabs stay listed but hidden so restore keeps
|
||||
// them; afterwards a tab without a stored question leaves the strip.
|
||||
list: (input) => {
|
||||
if (input.open.length === 0) return []
|
||||
|
||||
const saved = btw.saved(input.session)
|
||||
const cache = tabs.get(input.session.key) ?? new Map<string, PanelTab>()
|
||||
tabs.set(input.session.key, cache)
|
||||
|
||||
return input.open.flatMap((id) => {
|
||||
if (saved.ready() && !saved.value?.chats.some((item) => item.id === id)) return []
|
||||
|
||||
const existing = cache.get(id)
|
||||
|
||||
if (existing) return [existing]
|
||||
|
||||
const tab: PanelTab = {
|
||||
id,
|
||||
get title() {
|
||||
return saved.value?.chats.find((item) => item.id === id)?.question ?? ctx.t("tab.title")
|
||||
},
|
||||
get hidden() {
|
||||
return !saved.ready()
|
||||
},
|
||||
label: (state) => (
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<Icon name="bubble-5" size="small" />
|
||||
<span class="truncate">{btw.question(state.session, id) ?? ctx.t("tab.title")}</span>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
cache.set(id, tab)
|
||||
|
||||
return [tab]
|
||||
})
|
||||
},
|
||||
// The answer lives only in this window's memory, so the tab lists while its session has one.
|
||||
list: (input) => (input.open.includes("main") && btw.has(input.session) ? [tab] : []),
|
||||
render: (props) => (
|
||||
<Suspense>
|
||||
<SessionBtwPanel btw={btw} session={props.session} id={props.tab.id} />
|
||||
<SessionBtwPanel btw={btw} session={props.session} />
|
||||
</Suspense>
|
||||
),
|
||||
close: (input) => {
|
||||
btw.remove(input.session, input.tab.id)
|
||||
tabs.get(input.session.key)?.delete(input.tab.id)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -875,63 +875,6 @@ describe("FlowchartDiagram", () => {
|
||||
expect(output.match(/[▼◀]/g)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("orders rank nodes to remove crossings between branches", () => {
|
||||
const content = `flowchart TD
|
||||
A{Question} -->|Path one| B[Step one]
|
||||
A -->|Path two| C[Step two]
|
||||
B --> D{Check one}
|
||||
D -->|No| E{Retry one}
|
||||
E -->|No| B
|
||||
C --> F{Check two}
|
||||
F -->|No| G{Retry two}
|
||||
G -->|No| C
|
||||
E -->|Yes| H[Rethink]
|
||||
G -->|Yes| H
|
||||
H --> A
|
||||
D -->|Yes| I([Done])
|
||||
F -->|Yes| I`
|
||||
const layout = layoutFlowchartDiagram(content, { compact: true, layoutMaxWidth: 120 })
|
||||
const left = (id: string) => layout.bounds.get(id)!.left
|
||||
|
||||
expect(left("E")).toBeLessThan(left("I"))
|
||||
expect(left("I")).toBeLessThan(left("G"))
|
||||
expectDiagram(renderFlowchartDiagram(content, { compact: true, layoutMaxWidth: 120 })).toEqualDiagram(`
|
||||
╭─────────╮
|
||||
╭─╯ ╰─╮
|
||||
│ Question │◀──────────────────────────╮
|
||||
╰─╮ ╭─╯ │
|
||||
╰────┬────╯ │
|
||||
╭─ Path one ──┴── Path two ───╮ │
|
||||
│ │ │
|
||||
▼ ▼ │
|
||||
╭──────────╮ ╭──────────╮ │
|
||||
╭──── No ─────▶│ Step one │ │ Step two │◀──── No ────┤
|
||||
│ ╰─────┬────╯ ╰─────┬────╯ │
|
||||
│ │ │ │
|
||||
│ │ ╭─╯ │
|
||||
│ ▼ ▼ │
|
||||
│ ╭─────────╮ ╭─────────╮ │
|
||||
│ ╭─╯ ╰─╮ ╭─╯ ╰─╮ │
|
||||
│ │ Check one │ │ Check two │ │
|
||||
│ ╰─╮ ╭─╯ ╰─╮ ╭─╯ │
|
||||
│ ╰────┬────╯ ╰────┬────╯ │
|
||||
│ ╭── No ───┴──── Yes ─────┬─── Yes ────┴─── No ───╮ │
|
||||
│ │ │ │ │
|
||||
│ ▼ │ ▼ │
|
||||
│ ╭─────────╮ ▼ ╭─────────╮ │
|
||||
│ ╭─╯ ╰─╮ ╭──────╮ ╭─╯ ╰─╮ │
|
||||
╰───┤ Retry one │ │ Done │ │ Retry two ├──┤
|
||||
╰─╮ ╭─╯ ╰──────╯ ╰─╮ ╭─╯ │
|
||||
╰────┬────╯ ╰────┬────╯ │
|
||||
│ │ │
|
||||
╰───────── Yes ─────────┬───────── Yes ──────────╯ │
|
||||
▼ │
|
||||
╭─────────╮ │
|
||||
│ Rethink ├─────────────────────────────╯
|
||||
╰─────────╯
|
||||
`)
|
||||
})
|
||||
|
||||
test("routes transitive horizontal shortcuts around intermediate stages", () => {
|
||||
const content = `flowchart LR
|
||||
A[Start] --> B[Validate]
|
||||
|
||||
@@ -381,7 +381,6 @@ function layoutRankedNodes(
|
||||
nodes.push(node)
|
||||
ranksByIndex.set(normalizedRank, nodes)
|
||||
}
|
||||
reduceRankCrossings(diagram, ranks, normalizedRanks, ranksByIndex)
|
||||
|
||||
const spaciousNodeGap = Math.max(minNodeGap, DEFAULT_MIN_BRANCH_LABEL_GAP)
|
||||
const widestUnlabeledRank = Math.max(
|
||||
@@ -524,103 +523,6 @@ function layoutRankedNodes(
|
||||
return { bounds, wrapped }
|
||||
}
|
||||
|
||||
const CROSSING_SWEEPS = 8
|
||||
|
||||
// Barycenter ordering within ranks. Source order is kept unless a sweep strictly reduces crossings, so
|
||||
// crossing-free diagrams render exactly as authored.
|
||||
function reduceRankCrossings(
|
||||
diagram: FlowchartDiagram,
|
||||
ranks: ReadonlyMap<string, number>,
|
||||
normalizedRanks: ReadonlyMap<string, number>,
|
||||
ranksByIndex: Map<number, FlowchartNode[]>,
|
||||
): void {
|
||||
// Reordering could interleave members of different subgraphs and break their frames.
|
||||
if (diagram.subgraphs?.length) return
|
||||
const rankKeys = [...ranksByIndex.keys()].sort((a, b) => a - b)
|
||||
const layerByRank = new Map(rankKeys.map((rank, index) => [rank, index]))
|
||||
// Feedback edges route around the diagram, so only forward edges between adjacent ranks can cross.
|
||||
const links = diagram.edges.flatMap((edge) => {
|
||||
const fromRank = ranks.get(edge.from)
|
||||
const toRank = ranks.get(edge.to)
|
||||
if (edge.orderOnly || fromRank === undefined || toRank === undefined || toRank !== fromRank + 1) return []
|
||||
const from = normalizedRanks.get(edge.from)!
|
||||
const to = normalizedRanks.get(edge.to)!
|
||||
if (Math.abs(from - to) !== 1) return []
|
||||
return [
|
||||
from < to
|
||||
? { layer: layerByRank.get(from)!, upper: edge.from, lower: edge.to }
|
||||
: { layer: layerByRank.get(to)!, upper: edge.to, lower: edge.from },
|
||||
]
|
||||
})
|
||||
const original = rankKeys.map((rank) => ranksByIndex.get(rank)!.map((node) => node.id))
|
||||
const initialCrossings = layerCrossings(original, links)
|
||||
if (initialCrossings === 0) return
|
||||
|
||||
const best = Array.from({ length: CROSSING_SWEEPS }).reduce(
|
||||
(state: { current: string[][]; best: string[][]; crossings: number }, _, sweep) => {
|
||||
const current = sweepLayers(state.current, links, sweep % 2 === 0)
|
||||
const crossings = layerCrossings(current, links)
|
||||
return crossings < state.crossings ? { current, best: current, crossings } : { ...state, current }
|
||||
},
|
||||
{ current: original, best: original, crossings: initialCrossings },
|
||||
).best
|
||||
if (best === original) return
|
||||
|
||||
const nodeById = new Map(diagram.nodes.map((node) => [node.id, node]))
|
||||
for (const [index, rank] of rankKeys.entries()) {
|
||||
ranksByIndex.set(
|
||||
rank,
|
||||
best[index]!.map((id) => nodeById.get(id)!),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function sweepLayers(
|
||||
layers: readonly string[][],
|
||||
links: readonly { layer: number; upper: string; lower: string }[],
|
||||
down: boolean,
|
||||
): string[][] {
|
||||
const next = layers.map((layer) => [...layer])
|
||||
const indices = next.map((_, index) => index)
|
||||
for (const index of down ? indices.slice(1) : indices.slice(0, -1).reverse()) {
|
||||
const fixed = next[down ? index - 1 : index + 1]!
|
||||
const keys = new Map(
|
||||
next[index]!.map((id, position) => {
|
||||
const neighbors = links.flatMap((link) => {
|
||||
if (down && link.layer === index - 1 && link.lower === id) return [fixed.indexOf(link.upper)]
|
||||
if (!down && link.layer === index && link.upper === id) return [fixed.indexOf(link.lower)]
|
||||
return []
|
||||
})
|
||||
if (neighbors.length === 0) return [id, position]
|
||||
return [id, neighbors.reduce((total, neighbor) => total + neighbor, 0) / neighbors.length]
|
||||
}),
|
||||
)
|
||||
next[index] = next[index]!.toSorted((a, b) => keys.get(a)! - keys.get(b)!)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function layerCrossings(
|
||||
layers: readonly string[][],
|
||||
links: readonly { layer: number; upper: string; lower: string }[],
|
||||
): number {
|
||||
const position = new Map(layers.flatMap((layer) => layer.map((id, index) => [id, index] as const)))
|
||||
return links.reduce(
|
||||
(total, link, index) =>
|
||||
total +
|
||||
links
|
||||
.slice(index + 1)
|
||||
.filter(
|
||||
(other) =>
|
||||
other.layer === link.layer &&
|
||||
(position.get(link.upper)! - position.get(other.upper)!) *
|
||||
(position.get(link.lower)! - position.get(other.lower)!) <
|
||||
0,
|
||||
).length,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
function layoutLocalSubgraphDirections(
|
||||
diagram: FlowchartDiagram,
|
||||
nodeBounds: Map<string, FlowchartNodeBounds>,
|
||||
|
||||
@@ -153,28 +153,6 @@ const flowFamilies = {
|
||||
],
|
||||
)
|
||||
},
|
||||
"decision-loops"(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
profile,
|
||||
["A", "B", "C", "D", "E", "F", "G", "H", "I"],
|
||||
[
|
||||
["A", "B", "E01"],
|
||||
["A", "C", "E02"],
|
||||
["B", "D", "E03"],
|
||||
["D", "E", "E04"],
|
||||
["E", "B", "E05"],
|
||||
["C", "F", "E06"],
|
||||
["F", "G", "E07"],
|
||||
["G", "C", "E08"],
|
||||
["E", "H", "E09"],
|
||||
["G", "H", "E10"],
|
||||
["H", "A", "E11"],
|
||||
["D", "I", "E12"],
|
||||
["F", "I", "E13"],
|
||||
],
|
||||
)
|
||||
},
|
||||
parallel(direction: FlowchartDirection, profile: LabelProfile) {
|
||||
return flowSource(
|
||||
direction,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/sdk",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
+1
-2
@@ -91,8 +91,7 @@ if (Script.release && !Script.preview) {
|
||||
await $`git checkout -B v2 origin/v2`
|
||||
await prepareReleaseFiles()
|
||||
if ((await $`git diff --quiet`.nothrow()).exitCode !== 0) {
|
||||
// The release already published this code; a push-triggered dev publish of a version bump is wasted work.
|
||||
await $`git commit -am ${`sync release versions for ${tag} [skip ci]`}`
|
||||
await $`git commit -am "sync release versions for ${tag}"`
|
||||
await $`git push origin HEAD:v2 --no-verify`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.23",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in new issue
Block a user