mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-06 15:36:34 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3260f19fb6 | ||
|
|
20a0f4a93c | ||
|
|
cb039c3895 | ||
|
|
e293f57547 | ||
|
|
f5c246c11d | ||
|
|
673360d6b9 |
No files matched your search
@@ -422,74 +422,57 @@ const AnthropicUsage = Schema.StructWithRest(
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
// server_tool_use id in `tool_use_id`.
|
||||
tool_use_id: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
type: Schema.String,
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
// redacted_thinking blocks arrive whole in content_block_start with the
|
||||
// encrypted payload in `data`; there is no streaming delta sequence.
|
||||
data: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
// *_tool_result blocks arrive whole as content_block_start (no streaming
|
||||
// delta) with the structured payload in `content` and the originating
|
||||
// server_tool_use id in `tool_use_id`.
|
||||
tool_use_id: Schema.optional(Schema.String),
|
||||
content: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
partial_json: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
partial_json: Schema.optional(Schema.String),
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
|
||||
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
|
||||
|
||||
const AnthropicEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(
|
||||
Schema.StructWithRest(Schema.Struct({ usage: Schema.optional(AnthropicUsage) }), [JsonObject]),
|
||||
),
|
||||
content_block: Schema.optional(Schema.Unknown),
|
||||
delta: Schema.optional(Schema.Unknown),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
const AnthropicEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
index: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
|
||||
content_block: Schema.optional(Schema.Unknown),
|
||||
delta: Schema.optional(Schema.Unknown),
|
||||
usage: Schema.optional(AnthropicUsage),
|
||||
// `type` and `message` are both required per Anthropic's spec, but
|
||||
// OpenAI-compatible proxies and gateway translations occasionally drop one
|
||||
// or the other; mark them optional so a partial payload still parses and
|
||||
// the parser can fall back to whichever field is populated.
|
||||
error: Schema.optional(
|
||||
Schema.Struct({ type: Schema.optional(Schema.String), message: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
|
||||
@@ -301,26 +301,18 @@ export const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
export const OpenResponsesUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[JsonObject],
|
||||
),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }), [JsonObject]),
|
||||
),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
type OpenResponsesUsage = Schema.Schema.Type<typeof OpenResponsesUsage>
|
||||
|
||||
// The spec requires `id` on every output item, but some gateways drop it from
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -341,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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { Script } from "@opencode/script"
|
||||
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "../src/plugin-runtime"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
import { resolveOpencodePty } from "./opencode-pty"
|
||||
@@ -79,31 +78,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}))
|
||||
},
|
||||
}
|
||||
const pluginRuntimeEntries = discoverPluginRuntimeSpecifiers()
|
||||
const pluginRuntimeTemplate = await Bun.file("./src/plugin-runtime.ts").text()
|
||||
const pluginRuntimeMarker =
|
||||
"const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined"
|
||||
if (!pluginRuntimeTemplate.includes(pluginRuntimeMarker)) {
|
||||
throw new Error("Missing prebundledModules marker in packages/cli/src/plugin-runtime.ts")
|
||||
}
|
||||
const pluginRuntimeSource = pluginRuntimeTemplate.replace(
|
||||
pluginRuntimeMarker,
|
||||
`const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = {\n${[...pluginRuntimeEntries.keys()].map((specifier) => ` ${JSON.stringify(specifier)}: ${pluginRuntimeLoaderCode(specifier, pluginRuntimeEntries)},`).join("\n")}\n}`,
|
||||
)
|
||||
const pluginRuntimePlugin: BunPlugin = {
|
||||
name: "opencode-plugin-runtime",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /cli[/\\]src[/\\]plugin-runtime\.ts$/ }, () => ({
|
||||
contents: pluginRuntimeSource,
|
||||
loader: "ts",
|
||||
}))
|
||||
build.onLoad({ filter: /[/\\]internal[/\\]httpApi(?:Scalar|Swagger)\.js$/ }, () => ({
|
||||
contents:
|
||||
'export const css = ""; export const javascript = \'document.body.textContent = "Scalar/Swagger UI assets are not bundled in OpenCode"\'',
|
||||
loader: "js",
|
||||
}))
|
||||
},
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const opencodePty = await resolveOpencodePty({
|
||||
@@ -150,14 +124,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [
|
||||
appAssetsPlugin,
|
||||
solidPlugin,
|
||||
parcelWatcherPlugin,
|
||||
opencodePtyPlugin,
|
||||
pluginRuntimePlugin,
|
||||
simulationGraphPlugin,
|
||||
],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { ensurePluginRuntime } from "./plugin-runtime"
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { getErrorReported } from "effect/Runtime"
|
||||
@@ -18,8 +17,6 @@ import { EffectFlock } from "@opencode/util/effect-flock"
|
||||
import { Heap } from "./heap"
|
||||
import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
ensurePluginRuntime()
|
||||
|
||||
if (process.env.OPENCODE_SSH_ASKPASS_PORT) {
|
||||
const { askpass } = await import("./ssh-askpass")
|
||||
process.exit(await Effect.runPromise(askpass.pipe(Effect.provide(NodeServices.layer))))
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { existsSync, realpathSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
|
||||
type RuntimeModuleLoader = () => Record<string, unknown> | Promise<Record<string, unknown>>
|
||||
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
const runtimePackages = ["effect", "@opencode/plugin"] as const
|
||||
const foreignPkgSuffix = String.raw`(?:node_modules[/\\](@opencode[/\\]plugin|effect)|(@opencode[/\\]plugin|effect)@[^/\\]+@@@\d+)`
|
||||
const foreignPkgPattern = new RegExp(String.raw`^(.*[/\\]${foreignPkgSuffix})[/\\](.+)$`)
|
||||
const prebundledModules: Readonly<Record<string, RuntimeModuleLoader>> | undefined = undefined
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, RuntimeModuleLoader>>
|
||||
}
|
||||
|
||||
export function discoverPluginRuntimeSpecifiers(
|
||||
from = import.meta.dir,
|
||||
packages: readonly string[] = runtimePackages,
|
||||
): ReadonlyMap<string, string> {
|
||||
const entries = new Map<string, string>()
|
||||
for (const pkgName of packages) {
|
||||
const realDir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, from))
|
||||
const loadDir = findNodeModulesDir(pkgName, from, realDir)
|
||||
const toLoadPath = (resolved: string) =>
|
||||
loadDir === realDir ? resolved : path.join(loadDir, path.relative(realDir, resolved))
|
||||
const rootEntry = Bun.resolveSync(pkgName, from)
|
||||
const relParts = path.relative(realDir, rootEntry).replaceAll("\\", "/").split("/")
|
||||
const scanDir = relParts.length > 1 ? path.join(realDir, relParts[0]) : realDir
|
||||
const ext = path.extname(rootEntry) || ".js"
|
||||
entries.set(pkgName, toLoadPath(rootEntry))
|
||||
for (const file of new Bun.Glob(`**/*${ext}`).scanSync({ cwd: scanDir })) {
|
||||
const normalized = file.replaceAll("\\", "/")
|
||||
if (normalized.startsWith("internal/") || normalized.includes("/internal/") || normalized.startsWith("source.")) {
|
||||
continue
|
||||
}
|
||||
for (const specifier of specifierCandidates(pkgName, normalized)) {
|
||||
if (entries.has(specifier)) continue
|
||||
try {
|
||||
entries.set(specifier, toLoadPath(Bun.resolveSync(specifier, from)))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export function pluginRuntimeLoaderCode(specifier: string, entries: ReadonlyMap<string, string>) {
|
||||
if (specifier.startsWith("effect/")) {
|
||||
const slash = specifier.lastIndexOf("/")
|
||||
const parent = specifier.slice(0, slash)
|
||||
const member = specifier.slice(slash + 1)
|
||||
const parentResolved = entries.get(parent)
|
||||
const resolved = entries.get(specifier)
|
||||
if (
|
||||
member !== "index" &&
|
||||
parentResolved &&
|
||||
resolved &&
|
||||
(require(parentResolved) as Record<string, unknown>)[member] === require(resolved)
|
||||
) {
|
||||
return `() => require(${JSON.stringify(parent)})[${JSON.stringify(member)}]`
|
||||
}
|
||||
}
|
||||
return `() => require(${JSON.stringify(specifier)})`
|
||||
}
|
||||
|
||||
export function ensurePluginRuntime() {
|
||||
if (typeof Bun === "undefined") return {}
|
||||
const state = globalThis as GlobalState
|
||||
if (state[runtimeModulesKey]) return state[runtimeModulesKey]
|
||||
const modules =
|
||||
prebundledModules ??
|
||||
(() => {
|
||||
const entries = discoverPluginRuntimeSpecifiers()
|
||||
const effectEntry = entries.get("effect")
|
||||
if (effectEntry) require(effectEntry)
|
||||
return Object.fromEntries(
|
||||
[...entries.entries()].map(([specifier, resolved]) => [specifier, createLoader(resolved)]),
|
||||
)
|
||||
})()
|
||||
state[runtimeModulesKey] = modules
|
||||
const hostPluginDir = prebundledModules
|
||||
? undefined
|
||||
: path.dirname(Bun.resolveSync("@opencode/plugin/package.json", import.meta.dir))
|
||||
Bun.plugin({
|
||||
name: "opencode-plugin-runtime",
|
||||
setup(build) {
|
||||
for (const [specifier, load] of Object.entries(modules)) {
|
||||
build.module(specifier, () => {
|
||||
const exports = load()
|
||||
return exports instanceof Promise
|
||||
? exports.then((value) => ({ exports: value, loader: "object" as const }))
|
||||
: { exports, loader: "object" as const }
|
||||
})
|
||||
}
|
||||
// Temporary until OpenTUI preserves host specifiers (anomalyco/opentui#1569).
|
||||
build.onResolve({ filter: /^(?:file:\/\/|\/|[A-Za-z]:[/\\])/ }, (args) => {
|
||||
if (!args.importer || args.importer === import.meta.path) return undefined
|
||||
const matched = resolveRewrittenHostSpecifier(args.path, modules, hostPluginDir)
|
||||
return matched ? { path: matched } : undefined
|
||||
})
|
||||
build.onLoad({ filter: createForeignPackageFilter() }, (args) => {
|
||||
const match = args.path.match(foreignPkgPattern)
|
||||
const target = match ? `${match[2] ?? match[3]}/${match[4]}`.replaceAll("\\", "/") : args.path
|
||||
throw new Error(
|
||||
`Cannot load "${target}" from plugin node_modules: "${target}" is not provided by OpenCode; plugins must use the host's "effect" and "@opencode/plugin" modules.`,
|
||||
)
|
||||
})
|
||||
},
|
||||
})
|
||||
return modules
|
||||
}
|
||||
|
||||
export function createLoader(resolved: string): RuntimeModuleLoader {
|
||||
let cached: Record<string, unknown> | undefined
|
||||
let pending: Promise<Record<string, unknown>> | undefined
|
||||
return () => {
|
||||
if (cached) return cached
|
||||
if (pending) return pending
|
||||
try {
|
||||
return (cached = require(resolved) as Record<string, unknown>)
|
||||
} catch {
|
||||
return (pending = import(pathToFileURL(resolved).href).then(
|
||||
(mod: Record<string, unknown>) => (cached = mod),
|
||||
(error) => {
|
||||
pending = undefined
|
||||
throw error
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createForeignPackageFilter(rootsInput?: Iterable<string>) {
|
||||
const suffix = String.raw`[/\\]${foreignPkgSuffix}[/\\].*\.[cm]?[jt]sx?(?:[?#].*)?$`
|
||||
if (prebundledModules && !rootsInput) return new RegExp(suffix)
|
||||
const roots = new Set<string>(
|
||||
rootsInput ??
|
||||
runtimePackages.flatMap((pkgName) => {
|
||||
const dir = path.dirname(Bun.resolveSync(`${pkgName}/package.json`, import.meta.dir))
|
||||
return [dir, findNodeModulesDir(pkgName, import.meta.dir, dir)]
|
||||
}),
|
||||
)
|
||||
const escaped = [...roots]
|
||||
.map((value) =>
|
||||
value
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/(?:\\\/|\\\\|\/)+/g, "[/\\\\]"),
|
||||
)
|
||||
.join("|")
|
||||
return new RegExp(`^(?!(?:${escaped})[/\\\\]).*${suffix}`)
|
||||
}
|
||||
|
||||
function findNodeModulesDir(pkgName: string, from: string, realDir: string) {
|
||||
if (/[/\\]node_modules[/\\]/.test(realDir)) return realDir
|
||||
for (let dir = path.resolve(from); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", pkgName)
|
||||
if (existsSync(candidate) && realpathSync(candidate) === realDir) return candidate
|
||||
if (path.dirname(dir) === dir) return realDir
|
||||
}
|
||||
}
|
||||
|
||||
function specifierCandidates(pkgName: string, subpath: string) {
|
||||
const base = subpath
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^(?:dist(?:\/(?:esm|cjs))?|src)\//, "")
|
||||
.replace(/\.[cm]?[jt]sx?(?:[?#].*)?$/, "")
|
||||
if (base === "index") return [pkgName]
|
||||
return base.endsWith("/index")
|
||||
? [`${pkgName}/${base.slice(0, -"/index".length)}`, `${pkgName}/${base}`, pkgName]
|
||||
: [`${pkgName}/${base}`, pkgName]
|
||||
}
|
||||
|
||||
function resolveRewrittenHostSpecifier(
|
||||
specifier: string,
|
||||
modules: Readonly<Record<string, unknown>>,
|
||||
hostPluginDir?: string,
|
||||
) {
|
||||
const targetPath = (() => {
|
||||
try {
|
||||
return specifier.startsWith("file://") ? fileURLToPath(specifier) : specifier.replace(/[?#].*$/, "")
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
if (!targetPath) return undefined
|
||||
const match = targetPath.match(foreignPkgPattern)
|
||||
const rel = !match && hostPluginDir ? path.relative(hostPluginDir, targetPath) : undefined
|
||||
if ((!match && (!rel || rel.startsWith("..") || path.isAbsolute(rel))) || !existsSync(targetPath)) return undefined
|
||||
const pkgDir = match ? match[1] : hostPluginDir!
|
||||
const pkgName = match ? (match[2] ?? match[3]).replaceAll("\\", "/") : "@opencode/plugin"
|
||||
const targetReal = realpathSync(targetPath)
|
||||
return specifierCandidates(pkgName, match ? match[4] : rel!).find((candidate) => {
|
||||
if (!(candidate in modules)) return false
|
||||
try {
|
||||
return Bun.resolveSync(candidate, pkgDir) === targetReal
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Effect, FileSystem, Option, Schedule, Schema } from "effect"
|
||||
import type { DiscoverOptions, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
@@ -48,9 +53,12 @@ export const incumbent = Effect.fn("service.incumbent")(function* (
|
||||
/** Ensure a healthy, compatible local service is running. */
|
||||
export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOptions = {}) {
|
||||
const timing = ensureTiming(options)
|
||||
const pool = contenderPool(timing)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
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
|
||||
@@ -82,13 +90,19 @@ 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)
|
||||
pool.evict(info.pid)
|
||||
pool.recruitNow()
|
||||
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
|
||||
}
|
||||
} else timeouts = undefined
|
||||
if (service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
spawnDelay = timing.spawnDelay
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
if (!service.compatible && versionMatches)
|
||||
@@ -111,15 +125,30 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).pipe(Effect.ignore)
|
||||
pool.evict(service.info.pid)
|
||||
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 failed = pool.reap()
|
||||
if (failed !== undefined) return yield* Effect.fail(failed)
|
||||
if (pool.shouldRecruit(info !== undefined)) {
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
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) {
|
||||
yield* announce("missing")
|
||||
pool.add(yield* spawnContender)
|
||||
contenders.add(yield* spawnContender)
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
return Option.none<LocalService>()
|
||||
}).pipe(
|
||||
@@ -129,10 +158,10 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
// like the Promise variant rather than by attempt count.
|
||||
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => pool.releaseAll())),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
if (Option.isNone(found))
|
||||
return yield* Effect.fail(pool.failure() ?? new Error("Timed out waiting for the background service to start"))
|
||||
return yield* Effect.fail(failure ?? new Error("Timed out waiting for the background service to start"))
|
||||
return found.value.endpoint
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { readFile, rm } from "node:fs/promises"
|
||||
import type { DiscoverOptions, Endpoint, Info, EnsureOptions, StopOptions } from "../service.js"
|
||||
import { contenderPool, spawnServiceContender } from "../service-contender.js"
|
||||
import {
|
||||
contenderFailure,
|
||||
contenderFinished,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../service-contender.js"
|
||||
import { defaultEnsureTiming, ensureTiming, type EnsureTiming } from "../service-timing.js"
|
||||
import { matchesVersion } from "../service-version.js"
|
||||
import { PtyHandoff } from "../pty-handoff.js"
|
||||
@@ -28,9 +33,12 @@ export async function discover(options: DiscoverOptions = {}) {
|
||||
export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
const timing = ensureTiming(options)
|
||||
const deadline = Date.now() + timing.promiseTimeout
|
||||
const pool = contenderPool(timing)
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let timeouts: { readonly info: Info; readonly count: number } | undefined
|
||||
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
|
||||
@@ -49,8 +57,7 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (Date.now() >= deadline)
|
||||
throw pool.failure() ?? new Error("Timed out waiting for the background service to start")
|
||||
if (Date.now() >= deadline) throw failure ?? 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 = {
|
||||
@@ -62,14 +69,20 @@ 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)
|
||||
pool.evict(registration.info.pid)
|
||||
pool.recruitNow()
|
||||
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
|
||||
}
|
||||
} else timeouts = undefined
|
||||
|
||||
if (registration.service !== undefined) {
|
||||
pool.serviceAnswered()
|
||||
spawnDelay = timing.spawnDelay
|
||||
const service = registration.service
|
||||
const versionMatches = matchesVersion(service.version, options)
|
||||
const compatible = service.compatible && versionMatches
|
||||
@@ -90,20 +103,36 @@ export async function ensure(options: EnsureOptions = {}): Promise<Endpoint> {
|
||||
file: options.file,
|
||||
pty: service.state === "ready" ? "handoff" : "clear",
|
||||
}).catch(() => undefined)
|
||||
pool.evict(service.info.pid)
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === service.info.pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
}
|
||||
} else {
|
||||
const failed = pool.reap()
|
||||
if (failed !== undefined) throw failed
|
||||
if (pool.shouldRecruit(registration.info !== undefined)) {
|
||||
if (lastSpawn === 0 && registration.info !== undefined) lastSpawn = Date.now()
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
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) {
|
||||
announce("missing")
|
||||
pool.add(await spawnContender())
|
||||
contenders.add(await spawnContender())
|
||||
lastSpawn = Date.now()
|
||||
}
|
||||
}
|
||||
await delay(timing.pollInterval)
|
||||
}
|
||||
} finally {
|
||||
pool.releaseAll()
|
||||
contenders.forEach((contender) => contender.release())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import type { EnsureTiming } from "./service-timing.js"
|
||||
|
||||
export type ServiceContender = {
|
||||
readonly child: ChildProcess
|
||||
@@ -54,70 +53,7 @@ export function spawnServiceContender(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup attempts of one `ensure()` call. It keeps at most two attempts alive, remembers
|
||||
* the first startup failure, and backs off when attempts exit cleanly because another one won.
|
||||
*/
|
||||
export function contenderPool(timing: EnsureTiming) {
|
||||
const contenders = new Set<ServiceContender>()
|
||||
let failure: Error | undefined
|
||||
let spawnDelay = timing.spawnDelay
|
||||
let lastSpawn = 0
|
||||
return {
|
||||
/** The first startup failure seen since the last eviction. */
|
||||
failure: () => failure,
|
||||
/** A registered service answered, so the next attempt waits the base delay again. */
|
||||
serviceAnswered() {
|
||||
spawnDelay = timing.spawnDelay
|
||||
},
|
||||
/**
|
||||
* The owner `pid` was replaced. Drop its attempt and any finished ones, forget their
|
||||
* failure, and restart the spawn clock.
|
||||
*/
|
||||
evict(pid: number) {
|
||||
for (const item of contenders) {
|
||||
if (item.child.pid === pid || contenderFinished(item)) {
|
||||
item.release()
|
||||
contenders.delete(item)
|
||||
}
|
||||
}
|
||||
failure = undefined
|
||||
lastSpawn = 0
|
||||
},
|
||||
/** Let the next attempt start without waiting a spawn delay. */
|
||||
recruitNow() {
|
||||
lastSpawn = Date.now() - spawnDelay
|
||||
},
|
||||
/** Collect finished attempts. Returns the startup failure once no attempt is left alive. */
|
||||
reap() {
|
||||
const finished = [...contenders].filter(contenderFinished)
|
||||
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))
|
||||
return contenders.size === 0 ? failure : undefined
|
||||
},
|
||||
/**
|
||||
* Whether to start another attempt now. A registration that has not answered yet gets one
|
||||
* spawn delay before an attempt competes with it.
|
||||
*/
|
||||
shouldRecruit(registered: boolean) {
|
||||
if (lastSpawn === 0 && registered) lastSpawn = Date.now()
|
||||
// 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.
|
||||
return failure === undefined && contenders.size < 2 && Date.now() - lastSpawn >= spawnDelay
|
||||
},
|
||||
add(contender: ServiceContender) {
|
||||
contenders.add(contender)
|
||||
lastSpawn = Date.now()
|
||||
},
|
||||
releaseAll() {
|
||||
contenders.forEach((contender) => contender.release())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function contenderFailure(contender: ServiceContender) {
|
||||
export function contenderFailure(contender: ServiceContender) {
|
||||
const error = contender.error()
|
||||
if (error !== undefined) return error
|
||||
if (contender.child.exitCode !== null && contender.child.exitCode !== 0)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import {
|
||||
contenderFinished,
|
||||
contenderPool,
|
||||
type ServiceContender,
|
||||
spawnServiceContender,
|
||||
} from "../src/service-contender"
|
||||
import { defaultEnsureTiming } from "../src/service-timing"
|
||||
|
||||
const spawned: ServiceContender[] = []
|
||||
afterEach(() => {
|
||||
spawned.splice(0).forEach((contender) => {
|
||||
contender.release()
|
||||
contender.child.kill("SIGKILL")
|
||||
})
|
||||
})
|
||||
|
||||
test("remembers the first startup failure and reports it once no attempt is left", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
|
||||
const survivor = running()
|
||||
const failed = failing("first failure")
|
||||
pool.add(survivor)
|
||||
pool.add(failed)
|
||||
await finished(failed)
|
||||
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.failure()?.message).toContain("first failure")
|
||||
expect(pool.shouldRecruit(false)).toBe(false)
|
||||
|
||||
survivor.child.kill("SIGKILL")
|
||||
await finished(survivor)
|
||||
expect(pool.reap()?.message).toContain("first failure")
|
||||
})
|
||||
|
||||
test("evicting a replaced owner forgets its failure and restarts the spawn clock", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 60_000 })
|
||||
const owner = running()
|
||||
const failed = failing("old failure")
|
||||
pool.add(owner)
|
||||
pool.add(failed)
|
||||
await finished(failed)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.failure()).toBeDefined()
|
||||
|
||||
pool.evict(owner.child.pid!)
|
||||
|
||||
expect(pool.failure()).toBeUndefined()
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
expect(owner.child.exitCode).toBeNull()
|
||||
})
|
||||
|
||||
test("an unanswered registration gets one spawn delay before an attempt competes", () => {
|
||||
const timing = { ...defaultEnsureTiming, spawnDelay: 60_000 }
|
||||
expect(contenderPool(timing).shouldRecruit(false)).toBe(true)
|
||||
|
||||
const registered = contenderPool(timing)
|
||||
expect(registered.shouldRecruit(true)).toBe(false)
|
||||
registered.recruitNow()
|
||||
expect(registered.shouldRecruit(true)).toBe(true)
|
||||
})
|
||||
|
||||
test("keeps at most two attempts alive", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 0 })
|
||||
const exiting = clean()
|
||||
pool.add(running())
|
||||
pool.add(exiting)
|
||||
expect(pool.shouldRecruit(false)).toBe(false)
|
||||
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
test("clean exits back off up to the maximum spawn delay", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 300 })
|
||||
const exiting = clean()
|
||||
pool.add(exiting)
|
||||
const start = Date.now()
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
|
||||
// The base delay (200 ms) has passed, but the backed-off delay (300 ms) has not, unless a
|
||||
// loaded machine overslept past it.
|
||||
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
|
||||
if (Date.now() - start < 290) expect(pool.shouldRecruit(false)).toBe(false)
|
||||
// Doubling would give 400 ms; the cap allows an attempt at 300 ms.
|
||||
await Bun.sleep(Math.max(0, 320 - (Date.now() - start)))
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
test("an answering service resets the backoff to the base spawn delay", async () => {
|
||||
const pool = contenderPool({ ...defaultEnsureTiming, spawnDelay: 200, maxSpawnDelay: 1_000 })
|
||||
const exiting = clean()
|
||||
pool.add(exiting)
|
||||
const start = Date.now()
|
||||
await finished(exiting)
|
||||
expect(pool.reap()).toBeUndefined()
|
||||
|
||||
pool.serviceAnswered()
|
||||
// Backed off, the next attempt would wait 400 ms; after the reset it waits the base 200 ms.
|
||||
await Bun.sleep(Math.max(0, 250 - (Date.now() - start)))
|
||||
expect(pool.shouldRecruit(false)).toBe(true)
|
||||
})
|
||||
|
||||
function spawn(code: string) {
|
||||
const contender = spawnServiceContender(process.execPath, ["-e", code])
|
||||
spawned.push(contender)
|
||||
return contender
|
||||
}
|
||||
|
||||
function running() {
|
||||
return spawn("setTimeout(() => {}, 60_000)")
|
||||
}
|
||||
|
||||
function failing(message: string) {
|
||||
return spawn(`console.error(${JSON.stringify(message)}); process.exit(1)`)
|
||||
}
|
||||
|
||||
function clean() {
|
||||
return spawn("process.exit(0)")
|
||||
}
|
||||
|
||||
async function finished(contender: ServiceContender) {
|
||||
while (!contenderFinished(contender)) await Bun.sleep(5)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copy existing snapshot stores and run the real object packing on the copies. Verifies
|
||||
* that every locally stored object survives and reports size, time, and peak RSS
|
||||
* of the Git child. The originals are only read.
|
||||
*
|
||||
* bun run script/benchmark-snapshot-pack.ts <store>...
|
||||
*/
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Git } from "../src/git"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
|
||||
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-pack")
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
|
||||
// Local objects only: alternates are hidden so borrowed source objects do not count.
|
||||
async function objects(store: string) {
|
||||
const alternates = path.join(store, "objects", "info", "alternates")
|
||||
const borrowed = await fs.readFile(alternates, "utf8").catch(() => undefined)
|
||||
if (borrowed !== undefined) await fs.rm(alternates)
|
||||
const listed =
|
||||
await $`git --git-dir ${store} --work-tree ${store} cat-file --batch-all-objects ${"--batch-check=%(objectname)"}`
|
||||
.quiet()
|
||||
.text()
|
||||
if (borrowed !== undefined) await fs.writeFile(alternates, borrowed)
|
||||
return listed.split("\n").filter(Boolean).toSorted()
|
||||
}
|
||||
|
||||
const size = async (directory: string) => Number((await $`du -sk ${directory}`.quiet().text()).split("\t")[0])
|
||||
const loose = async (store: string) =>
|
||||
(await $`find ${path.join(store, "objects")} -path '*/objects/??/*' -type f`.quiet().text())
|
||||
.split("\n")
|
||||
.filter(Boolean).length
|
||||
|
||||
let failed = false
|
||||
for (const source of process.argv.slice(2)) {
|
||||
const copy = await fs.mkdtemp(path.join(root, "store-"))
|
||||
await $`cp -R ${source}/. ${copy}`.quiet()
|
||||
const before = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) }
|
||||
const started = performance.now()
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
yield* git.objects.pack(
|
||||
new Git.Repository({
|
||||
worktree: AbsolutePath.make(copy),
|
||||
gitDirectory: AbsolutePath.make(copy),
|
||||
commonDirectory: AbsolutePath.make(copy),
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))),
|
||||
)
|
||||
const elapsed = performance.now() - started
|
||||
const after = { objects: await objects(copy), size: await size(copy), loose: await loose(copy) }
|
||||
const same = before.objects.join("\n") === after.objects.join("\n")
|
||||
if (!same) failed = true
|
||||
console.log(
|
||||
`${path.basename(source)} ${before.size} KiB -> ${after.size} KiB loose ${before.loose} -> ${after.loose} objects ${before.objects.length} -> ${after.objects.length} ${same ? "identical" : "MISMATCH"} ${elapsed.toFixed(0)} ms`,
|
||||
)
|
||||
await fs.rm(copy, { recursive: true, force: true })
|
||||
}
|
||||
if (failed) process.exit(1)
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Drives the real SessionStep.attempt through a scripted agent trace against a real
|
||||
* snapshot store, then repeats it with snapshots disabled. The difference is the
|
||||
* wall-clock cost snapshots add to the session.
|
||||
*
|
||||
* bun run script/benchmark-snapshot-session.ts [--fixture opencode] [--ttft 400] [--rounds 2]
|
||||
*
|
||||
* Uses the fixtures generated by benchmark-snapshot.ts.
|
||||
*/
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { LanguageModel, LLM, LLMEvent } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Logger, Stream } from "effect"
|
||||
import { Agent } from "../src/agent"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Database } from "../src/database/database"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Location } from "../src/location"
|
||||
import { Project } from "../src/project"
|
||||
import { ProjectTable } from "../src/project/sql"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Session } from "../src/session"
|
||||
import { SessionMessage } from "../src/session/message"
|
||||
import { SessionProjector } from "../src/session/projector"
|
||||
import { SessionRunnerModel } from "../src/session/runner/model"
|
||||
import { SessionStep } from "../src/session/runner/step"
|
||||
import { SessionTable } from "../src/session/sql"
|
||||
import { Snapshot } from "../src/snapshot"
|
||||
import { ToolOutput } from "../src/tool-output"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
return index === -1 ? undefined : args[index + 1]
|
||||
}
|
||||
const fixture = flag("fixture") ?? "opencode"
|
||||
const ttft = Number(flag("ttft") ?? 400)
|
||||
const rounds = Number(flag("rounds") ?? 2)
|
||||
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench")
|
||||
const directory = path.join(root, "fixtures", fixture)
|
||||
|
||||
// A read-heavy coding loop: explore, edit, run checks, answer.
|
||||
const trace = [
|
||||
"read",
|
||||
"grep",
|
||||
"read",
|
||||
"glob",
|
||||
"read",
|
||||
"edit",
|
||||
"shell",
|
||||
"read",
|
||||
"edit",
|
||||
"shell",
|
||||
"grep",
|
||||
"read",
|
||||
"edit",
|
||||
"shell",
|
||||
"text",
|
||||
] as const
|
||||
|
||||
let spawns = 0
|
||||
const countingProcess = Layer.effect(
|
||||
AppProcess.Service,
|
||||
Effect.gen(function* () {
|
||||
const real = yield* AppProcess.Service
|
||||
return AppProcess.Service.of({
|
||||
...real,
|
||||
run: (command, options) => {
|
||||
spawns++
|
||||
return real.run(command, options)
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node)))
|
||||
|
||||
const snapshotLayer = (data: string) =>
|
||||
AppNodeBuilder.build(Snapshot.node, [
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
AppProcess.node.replace(countingProcess),
|
||||
])
|
||||
|
||||
const files = (await $`git ls-files -z -- '*.ts'`.cwd(directory).text()).split("\0").filter(Boolean).slice(0, 50)
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "bench-model", provider: "test", route: OpenAIChat.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
limit: { context: 100_000, output: 1_000 },
|
||||
cost: [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(2),
|
||||
cache: { read: Money.USDPerMillionTokens.make(0.1), write: Money.USDPerMillionTokens.make(0.5) },
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const delayed = (response: ReturnType<typeof TestLLM.stop>) =>
|
||||
Stream.unwrap(Effect.sleep(ttft).pipe(Effect.as(Stream.fromIterable(response as Iterable<LLMEvent>))))
|
||||
|
||||
const run = (snapshots: Layer.Layer<Snapshot.Service>) =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const llm = yield* TestLLM.Test
|
||||
const sessionID = Session.ID.create()
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make(directory), sandboxes: [] })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({ id: sessionID, project_id: Project.ID.global, slug: "bench", directory, version: "bench" })
|
||||
.run()
|
||||
const steps = yield* SessionStep.make
|
||||
const snapshot = yield* Snapshot.Service
|
||||
// Warm the snapshot store so the first step does not pay repository creation.
|
||||
yield* snapshot.capture()
|
||||
const before = spawns
|
||||
const start = performance.now()
|
||||
let edits = 0
|
||||
for (let round = 0; round < rounds; round++)
|
||||
for (const kind of trace) {
|
||||
const call = `call-${round}-${edits}-${kind}-${Math.random().toString(36).slice(2)}`
|
||||
yield* llm.push(
|
||||
delayed(
|
||||
kind === "text"
|
||||
? TestLLM.text("Done.", `text-${call}`)
|
||||
: TestLLM.tool(call, kind, { path: files[edits % files.length] }),
|
||||
),
|
||||
)
|
||||
yield* steps.attempt({
|
||||
isLocationClosed: () => false,
|
||||
sessionID,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
agent: Agent.defaultID,
|
||||
model,
|
||||
prepared: {
|
||||
retry: () => Effect.void,
|
||||
request: LLM.request({ model: model.model, prompt: "bench" }),
|
||||
options: {},
|
||||
executeTool: (input) =>
|
||||
Effect.gen(function* () {
|
||||
if (input.call.name === "edit")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(path.join(directory, files[edits++ % files.length]!), `// bench ${edits}\n`),
|
||||
)
|
||||
return { content: [{ type: "text" as const, text: "ok" }] }
|
||||
}),
|
||||
},
|
||||
retry: () => Effect.succeed({ retry: false as const }),
|
||||
recoverContinuation: false,
|
||||
recoverOverflow: Effect.succeed(false),
|
||||
})
|
||||
}
|
||||
return { ms: performance.now() - start, spawns: spawns - before }
|
||||
}).pipe(
|
||||
Effect.provide(snapshots),
|
||||
Effect.provide(
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, ToolOutput.node]), [
|
||||
Bus.node.replace(Bus.configured({ persist: true })),
|
||||
]),
|
||||
TestLLM.testLayer(),
|
||||
),
|
||||
),
|
||||
Effect.provide(Logger.layer([])),
|
||||
)
|
||||
|
||||
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
|
||||
const data = await fs.mkdtemp(path.join(root, "session-data-"))
|
||||
const noop = await Effect.runPromise(run(Snapshot.noopLayer))
|
||||
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
|
||||
const real = await Effect.runPromise(run(snapshotLayer(data)))
|
||||
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(directory).quiet()
|
||||
await fs.rm(data, { recursive: true, force: true })
|
||||
|
||||
const count = trace.length * rounds
|
||||
console.log(`fixture ${fixture}, ${count} steps, simulated time to first event ${ttft} ms`)
|
||||
console.log(`no snapshots ${noop.ms.toFixed(0).padStart(7)} ms`)
|
||||
console.log(`snapshots ${real.ms.toFixed(0).padStart(7)} ms git ${real.spawns}`)
|
||||
console.log(
|
||||
`overhead ${(real.ms - noop.ms).toFixed(0).padStart(7)} ms ${((real.ms - noop.ms) / count).toFixed(1)} ms/step ${(real.spawns / count).toFixed(1)} git/step`,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
// Child process for the cross-process snapshot probe: edits its own file and captures repeatedly,
|
||||
// printing the number of failed captures.
|
||||
import path from "path"
|
||||
import { Effect, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Location } from "../src/location"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Snapshot } from "../src/snapshot"
|
||||
import { Global } from "@opencode/util/global"
|
||||
|
||||
const [data, directory, id, count] = process.argv.slice(2)
|
||||
const layer = AppNodeBuilder.build(Snapshot.node, [
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory!) }))),
|
||||
Global.node.replace(Global.layerWith({ data: data!, config: path.join(data!, "config") })),
|
||||
])
|
||||
|
||||
const failures = await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
let failed = 0
|
||||
for (let index = 0; index < Number(count); index++) {
|
||||
yield* Effect.promise(() => Bun.write(path.join(directory!, `worker-${id}.txt`), `${index}\n`))
|
||||
if ((yield* snapshot.capture()) === undefined) failed++
|
||||
}
|
||||
return failed
|
||||
}).pipe(Effect.provide(layer), Effect.provide(Logger.layer([]))),
|
||||
)
|
||||
console.log(failures)
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Snapshot benchmark + robustness probe.
|
||||
*
|
||||
* bun run script/benchmark-snapshot.ts [--fixtures small,medium,large,opencode] [--iterations 10] [--json out.json]
|
||||
*
|
||||
* Fixtures are generated once under $TMPDIR/opencode-snapshot-bench and reset before each run.
|
||||
* Every timed scenario also reports how many git processes it spawned.
|
||||
*/
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Logger } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Git } from "../src/git"
|
||||
import { Location } from "../src/location"
|
||||
import { AbsolutePath, RelativePath } from "../src/schema"
|
||||
import { Snapshot } from "../src/snapshot"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
return index === -1 ? undefined : args[index + 1]
|
||||
}
|
||||
const iterations = Number(flag("iterations") ?? 10)
|
||||
const selected = (flag("fixtures") ?? "small,medium,large,opencode").split(",")
|
||||
const jsonOut = flag("json")
|
||||
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-bench")
|
||||
|
||||
type Fixture = { name: string; tracked: number; ignored: number; dirs: number }
|
||||
const fixtures: Record<string, Fixture> = {
|
||||
small: { name: "small", tracked: 1_000, ignored: 0, dirs: 20 },
|
||||
medium: { name: "medium", tracked: 20_000, ignored: 20_000, dirs: 400 },
|
||||
large: { name: "large", tracked: 100_000, ignored: 50_000, dirs: 2_000 },
|
||||
}
|
||||
|
||||
// ---------- fixture generation ----------
|
||||
|
||||
async function writeMany(files: Array<[string, string]>) {
|
||||
const dirs = new Set(files.map(([file]) => path.dirname(file)))
|
||||
await Promise.all([...dirs].map((dir) => fs.mkdir(dir, { recursive: true })))
|
||||
for (let index = 0; index < files.length; index += 512)
|
||||
await Promise.all(files.slice(index, index + 512).map(([file, content]) => Bun.write(file, content)))
|
||||
}
|
||||
|
||||
async function generate(fixture: Fixture) {
|
||||
const dir = path.join(root, "fixtures", fixture.name)
|
||||
if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
console.error(`generating fixture ${fixture.name} (${fixture.tracked} tracked, ${fixture.ignored} ignored)`)
|
||||
const tracked = Array.from({ length: fixture.tracked }, (_, index) => {
|
||||
const file = path.join(dir, "src", `d${index % fixture.dirs}`, `f${index}.ts`)
|
||||
return [file, `export const value${index} = ${index}\n`.repeat(8)] as [string, string]
|
||||
})
|
||||
const ignored = Array.from({ length: fixture.ignored }, (_, index) => {
|
||||
const file = path.join(dir, "node_modules", `pkg${index % 500}`, `f${index}.js`)
|
||||
return [file, `module.exports = ${index}\n`] as [string, string]
|
||||
})
|
||||
await writeMany([...tracked, ...ignored, [path.join(dir, ".gitignore"), "node_modules\ndist\n"]])
|
||||
await gitInit(dir)
|
||||
await Bun.write(path.join(dir, ".bench-ready"), "")
|
||||
return dir
|
||||
}
|
||||
|
||||
async function cloneOpencode() {
|
||||
const dir = path.join(root, "fixtures", "opencode")
|
||||
if (await Bun.file(path.join(dir, ".bench-ready")).exists()) return dir
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
const source = (await $`git rev-parse --show-toplevel`.cwd(import.meta.dir).text()).trim()
|
||||
console.error(`cloning ${source} into fixture opencode`)
|
||||
await $`git clone --quiet --local --no-hardlinks ${source} ${dir}`.quiet()
|
||||
// A realistic ignored dependency tree without paying for a full install.
|
||||
await writeMany(
|
||||
Array.from({ length: 30_000 }, (_, index) => [
|
||||
path.join(dir, "node_modules", `pkg${index % 700}`, `f${index}.js`),
|
||||
`module.exports = ${index}\n`,
|
||||
]),
|
||||
)
|
||||
await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n")
|
||||
await Bun.write(path.join(dir, ".bench-ready"), "")
|
||||
return dir
|
||||
}
|
||||
|
||||
async function gitInit(dir: string) {
|
||||
await $`git init -q`.cwd(dir).quiet()
|
||||
await Bun.write(path.join(dir, ".git", "info", "exclude"), ".bench-ready\n")
|
||||
await $`git -c core.fsmonitor=false add -A`.cwd(dir).quiet()
|
||||
await $`git -c user.email=bench@opencode.test -c user.name=Bench commit -q --no-gpg-sign -m initial`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
async function reset(dir: string) {
|
||||
await $`git -c core.fsmonitor=false reset -q --hard`.cwd(dir).quiet()
|
||||
await $`git -c core.fsmonitor=false clean -qfd`.cwd(dir).quiet()
|
||||
}
|
||||
|
||||
// ---------- harness ----------
|
||||
|
||||
let spawns = 0
|
||||
const countingProcess = Layer.effect(
|
||||
AppProcess.Service,
|
||||
Effect.gen(function* () {
|
||||
const real = yield* AppProcess.Service
|
||||
return AppProcess.Service.of({
|
||||
...real,
|
||||
run: (command, options) => {
|
||||
spawns++
|
||||
return real.run(command, options)
|
||||
},
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(AppNodeBuilder.build(AppProcess.node)))
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
return AppNodeBuilder.build(Snapshot.node, [
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
AppProcess.node.replace(countingProcess),
|
||||
])
|
||||
}
|
||||
|
||||
type Sample = { ms: number; spawns: number }
|
||||
const results: Array<{ fixture: string; scenario: string; samples: Sample[] }> = []
|
||||
|
||||
const time = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const before = spawns
|
||||
const start = performance.now()
|
||||
const value = yield* effect
|
||||
return { value, sample: { ms: performance.now() - start, spawns: spawns - before } }
|
||||
})
|
||||
|
||||
function record(fixture: string, scenario: string, samples: Sample[]) {
|
||||
results.push({ fixture, scenario, samples })
|
||||
const sorted = samples.map((sample) => sample.ms).toSorted((a, b) => a - b)
|
||||
const p = (value: number) => sorted[Math.min(Math.ceil(sorted.length * value) - 1, sorted.length - 1)] ?? 0
|
||||
const mean = sorted.reduce((total, value) => total + value, 0) / sorted.length
|
||||
const spawned = samples.reduce((total, sample) => total + sample.spawns, 0) / samples.length
|
||||
console.log(
|
||||
`${fixture.padEnd(9)} ${scenario.padEnd(22)} p50 ${p(0.5).toFixed(1).padStart(8)} ms mean ${mean
|
||||
.toFixed(1)
|
||||
.padStart(8)} ms max ${(sorted.at(-1) ?? 0).toFixed(1).padStart(8)} ms git ${spawned.toFixed(1).padStart(5)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const edit = (dir: string, files: string[], tag: string) =>
|
||||
Effect.promise(() => Promise.all(files.map((file) => Bun.write(path.join(dir, file), `// ${tag}\n${file}\n`))))
|
||||
|
||||
function trackedSample(dir: string, count: number) {
|
||||
return $`git ls-files -z`
|
||||
.cwd(dir)
|
||||
.text()
|
||||
.then((text) => {
|
||||
const files = text.split("\0").filter((file) => file.endsWith(".ts") || file.endsWith(".md"))
|
||||
const stride = Math.max(1, Math.floor(files.length / count))
|
||||
return Array.from({ length: count }, (_, index) => files[(index * stride) % files.length]!)
|
||||
})
|
||||
}
|
||||
|
||||
const repeat = <A, E, R>(count: number, effect: (index: number) => Effect.Effect<Sample, E, R>) =>
|
||||
Effect.forEach(
|
||||
Array.from({ length: count }, (_, index) => index),
|
||||
effect,
|
||||
)
|
||||
|
||||
async function bench(name: string, dir: string) {
|
||||
await reset(dir)
|
||||
const data = await fs.mkdtemp(path.join(root, "data-"))
|
||||
const sample = await trackedSample(dir, 100)
|
||||
const program = Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const capture = snapshot
|
||||
.capture()
|
||||
.pipe(Effect.flatMap((id) => (id ? Effect.succeed(id) : Effect.die(new Error("capture returned undefined")))))
|
||||
|
||||
const cold = yield* time(capture)
|
||||
record(name, "capture cold", [cold.sample])
|
||||
|
||||
record(
|
||||
name,
|
||||
"capture clean",
|
||||
yield* repeat(iterations, () => time(capture).pipe(Effect.map((result) => result.sample))),
|
||||
)
|
||||
|
||||
record(
|
||||
name,
|
||||
"capture edit 1",
|
||||
yield* repeat(iterations, (index) =>
|
||||
edit(dir, [sample[0]!], `edit-${index}`).pipe(
|
||||
Effect.andThen(time(capture)),
|
||||
Effect.map((result) => result.sample),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
record(
|
||||
name,
|
||||
"capture add 1",
|
||||
yield* repeat(iterations, (index) =>
|
||||
edit(dir, [`src/new-${index}.ts`], "new").pipe(
|
||||
Effect.andThen(time(capture)),
|
||||
Effect.map((result) => result.sample),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const before = yield* capture
|
||||
const edited = yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) =>
|
||||
edit(dir, sample, `bulk-${index}`).pipe(
|
||||
Effect.andThen(time(capture)),
|
||||
Effect.map((result) => result.sample),
|
||||
),
|
||||
)
|
||||
record(name, "capture edit 100", edited)
|
||||
const after = yield* capture
|
||||
|
||||
record(
|
||||
name,
|
||||
"files (100 changed)",
|
||||
yield* repeat(iterations, () =>
|
||||
time(snapshot.files({ from: before, to: after })).pipe(Effect.map((result) => result.sample)),
|
||||
),
|
||||
)
|
||||
record(
|
||||
name,
|
||||
"diff (100 changed)",
|
||||
yield* repeat(iterations, () =>
|
||||
time(snapshot.diff({ from: before, to: after })).pipe(Effect.map((result) => result.sample)),
|
||||
),
|
||||
)
|
||||
const plan = new Map(sample.map((file) => [RelativePath.make(file), before] as const))
|
||||
record(
|
||||
name,
|
||||
"restore 100",
|
||||
yield* repeat(Math.max(3, Math.ceil(iterations / 2)), (index) =>
|
||||
edit(dir, sample, `restore-${index}`).pipe(
|
||||
Effect.andThen(time(snapshot.restore({ files: plan }))),
|
||||
Effect.map((result) => result.sample),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
// A read-mostly agent loop under the step policy: capture at attempt start, capture at settlement,
|
||||
// then list changed files. One step in five edits two files.
|
||||
const steps = yield* repeat(20, (index) =>
|
||||
Effect.gen(function* () {
|
||||
const before = spawns
|
||||
const start = performance.now()
|
||||
const from = yield* capture
|
||||
if (index % 5 === 0) yield* edit(dir, [sample[index]!, sample[index + 1]!], `step-${index}`)
|
||||
const to = yield* capture
|
||||
if (from !== to) yield* snapshot.files({ from, to })
|
||||
return { ms: performance.now() - start, spawns: spawns - before }
|
||||
}),
|
||||
)
|
||||
record(name, "step loop (per step)", steps)
|
||||
}).pipe(Effect.provide(snapshotLayer(data, dir)))
|
||||
await Effect.runPromise(program.pipe(Effect.provide(Logger.layer([]))))
|
||||
await reset(dir)
|
||||
const store = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.text()).trim()
|
||||
const before = Number((await $`du -sk ${store}`.text()).split("\t")[0])
|
||||
const started = performance.now()
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const git = yield* Git.Service
|
||||
// The baseline worktree predates object packing.
|
||||
if (!("objects" in git)) return
|
||||
yield* git.objects.pack(
|
||||
new Git.Repository({
|
||||
worktree: AbsolutePath.make(dir),
|
||||
gitDirectory: AbsolutePath.make(store),
|
||||
commonDirectory: AbsolutePath.make(store),
|
||||
}),
|
||||
)
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Git.node)), Effect.provide(Logger.layer([]))),
|
||||
)
|
||||
const after = Number((await $`du -sk ${store}`.text()).split("\t")[0])
|
||||
console.log(
|
||||
`${name.padEnd(9)} packing ${before} KiB -> ${after} KiB in ${(performance.now() - started).toFixed(0)} ms`,
|
||||
)
|
||||
const size = await $`du -sk ${data}`.text()
|
||||
const loose = (await $`find ${data}/snapshot -path '*/objects/??/*' -type f`.text()).split("\n").filter(Boolean)
|
||||
const files = (await $`find ${data}/snapshot -maxdepth 3 -type f`.text()).split("\n").filter(Boolean)
|
||||
console.log(
|
||||
`${name.padEnd(9)} snapshot store ${size.split("\t")[0]} KiB, ${loose.length} loose objects, top-level files: ${files.map((file) => path.basename(file)).join(" ")}`,
|
||||
)
|
||||
await fs.rm(data, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// ---------- robustness probes ----------
|
||||
|
||||
async function probe(label: string, run: () => Promise<boolean>) {
|
||||
const ok = await run().catch((error) => {
|
||||
console.error(error)
|
||||
return false
|
||||
})
|
||||
console.log(`probe ${label.padEnd(44)} ${ok ? "PASS" : "FAIL"}`)
|
||||
results.push({ fixture: "probe", scenario: label, samples: [{ ms: ok ? 1 : 0, spawns: 0 }] })
|
||||
}
|
||||
|
||||
async function withRepo<A>(setup: (dir: string) => Promise<void>, body: (dir: string, data: string) => Promise<A>) {
|
||||
const dir = await fs.realpath(await fs.mkdtemp(path.join(root, "probe-")))
|
||||
const project = path.join(dir, "project")
|
||||
await fs.mkdir(project)
|
||||
await setup(project)
|
||||
await gitInit(project)
|
||||
const result = await body(project, path.join(dir, "data"))
|
||||
await fs.rm(dir, { recursive: true, force: true })
|
||||
return result
|
||||
}
|
||||
|
||||
const captureOnce = (data: string, directory: string) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* snapshot.capture()
|
||||
}).pipe(Effect.provide(snapshotLayer(data, directory)), Effect.provide(Logger.layer([]))),
|
||||
)
|
||||
|
||||
async function snapshotGitDir(data: string) {
|
||||
const projects = await fs.readdir(path.join(data, "snapshot"))
|
||||
const project = path.join(data, "snapshot", projects[0]!)
|
||||
return path.join(project, (await fs.readdir(project))[0]!)
|
||||
}
|
||||
|
||||
async function probes() {
|
||||
await probe("capture self-heals a zeroed index", () =>
|
||||
withRepo(
|
||||
(dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}),
|
||||
async (dir, data) => {
|
||||
await captureOnce(data, dir)
|
||||
const gitDir = await snapshotGitDir(data)
|
||||
await Bun.write(path.join(gitDir, "index"), new Uint8Array(1024))
|
||||
await Bun.write(path.join(dir, "a.txt"), "b\n")
|
||||
return (await captureOnce(data, dir)) !== undefined
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await probe("capture survives a stale index.lock", () =>
|
||||
withRepo(
|
||||
(dir) => Bun.write(path.join(dir, "a.txt"), "a\n").then(() => {}),
|
||||
async (dir, data) => {
|
||||
await captureOnce(data, dir)
|
||||
const gitDir = await snapshotGitDir(data)
|
||||
await Bun.write(path.join(gitDir, "index.lock"), "")
|
||||
await Bun.write(path.join(dir, "a.txt"), "b\n")
|
||||
return (await captureOnce(data, dir)) !== undefined
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await probe("capture works in a `..scope` directory", () =>
|
||||
withRepo(
|
||||
(dir) => Bun.write(path.join(dir, "..scope", "a.txt"), "a\n").then(() => {}),
|
||||
async (dir, data) => (await captureOnce(data, path.join(dir, "..scope"))) !== undefined,
|
||||
),
|
||||
)
|
||||
|
||||
await probe("two processes capture concurrently (4x25)", () =>
|
||||
withRepo(
|
||||
(dir) => writeMany(Array.from({ length: 2000 }, (_, i) => [path.join(dir, `d${i % 20}`, `f${i}.txt`), `${i}\n`])),
|
||||
async (dir, data) => {
|
||||
await captureOnce(data, dir)
|
||||
const worker = path.join(import.meta.dir, "benchmark-snapshot-worker.ts")
|
||||
const outputs = await Promise.all(
|
||||
Array.from({ length: 4 }, (_, id) => $`bun run ${worker} ${data} ${dir} ${id} 25`.nothrow().quiet()),
|
||||
)
|
||||
const failures = outputs.map((output) => Number(output.stdout.toString().trim() || "25"))
|
||||
console.log(` concurrent capture failures per process: ${failures.join(", ")}`)
|
||||
return failures.every((count) => count === 0)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
for (const name of selected) {
|
||||
if (name === "probes") continue
|
||||
const dir = name === "opencode" ? await cloneOpencode() : await generate(fixtures[name]!)
|
||||
await bench(name, dir)
|
||||
}
|
||||
if (selected.includes("probes") || !flag("fixtures")) await probes()
|
||||
if (jsonOut) await Bun.write(jsonOut, JSON.stringify(results, null, 2))
|
||||
@@ -0,0 +1,22 @@
|
||||
// Compare two snapshot-parity.ts outputs and print every diverging step per scenario.
|
||||
const [left, right] = await Promise.all(process.argv.slice(2, 4).map((file) => Bun.file(file!).json()))
|
||||
let divergent = 0
|
||||
for (const name of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
||||
const a: unknown[] = left[name] ?? []
|
||||
const b: unknown[] = right[name] ?? []
|
||||
const differences = Array.from({ length: Math.max(a.length, b.length) }, (_, index) => index).filter(
|
||||
(index) => JSON.stringify(a[index]) !== JSON.stringify(b[index]),
|
||||
)
|
||||
if (!differences.length) {
|
||||
console.log(`same ${name}`)
|
||||
continue
|
||||
}
|
||||
divergent++
|
||||
console.log(`DIFFERENT ${name}`)
|
||||
for (const index of differences) {
|
||||
console.log(` step ${index}`)
|
||||
console.log(` base: ${JSON.stringify(a[index])}`)
|
||||
console.log(` new: ${JSON.stringify(b[index])}`)
|
||||
}
|
||||
}
|
||||
console.log(divergent ? `${divergent} divergent scenarios` : "all scenarios identical")
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Run the snapshot parity corpus from two worktrees under every Git config variant and compare.
|
||||
# script/snapshot-parity-run.sh <baseline-worktree> [variant...]
|
||||
set -e
|
||||
base="$1"
|
||||
shift
|
||||
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
out="${SNAPSHOT_BENCH_ROOT:-${TMPDIR:-/tmp}}/opencode-snapshot-parity-results"
|
||||
mkdir -p "$out"
|
||||
cp "$here/script/snapshot-parity.ts" "$base/packages/core/script/snapshot-parity.ts"
|
||||
variants="${*:-user empty split-index skip-hash autocrlf-and-no-untracked-cache fsmonitor template-hook}"
|
||||
status=0
|
||||
for name in $variants; do
|
||||
variant="$(echo "$name" | tr '-' ' ')"
|
||||
(cd "$base/packages/core" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-base.json" >/dev/null 2>&1)
|
||||
(cd "$here" && PARITY_CONFIG="$variant" bun run script/snapshot-parity.ts "$out/$name-new.json" >/dev/null 2>&1)
|
||||
echo "=== $variant"
|
||||
bun run "$here/script/snapshot-parity-compare.ts" "$out/$name-base.json" "$out/$name-new.json" | grep -v "^same" || status=1
|
||||
done
|
||||
exit $status
|
||||
@@ -0,0 +1,710 @@
|
||||
/**
|
||||
* Differential corpus for the snapshot engine. Run the same file from two worktrees
|
||||
* and compare the JSON: tree IDs, changed-file lists, diffs, and post-restore
|
||||
* worktree digests must be byte-identical.
|
||||
*
|
||||
* bun run script/snapshot-parity.ts <result.json> [scenario...]
|
||||
*/
|
||||
import { $ } from "bun"
|
||||
import crypto from "crypto"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer, Logger, ManagedRuntime } from "effect"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Location } from "../src/location"
|
||||
import { AbsolutePath, RelativePath } from "../src/schema"
|
||||
import { Snapshot } from "../src/snapshot"
|
||||
import { Global } from "@opencode/util/global"
|
||||
|
||||
const root = path.join(process.env.SNAPSHOT_BENCH_ROOT ?? os.tmpdir(), "opencode-snapshot-parity")
|
||||
|
||||
// User-level Git configuration reaches every snapshot command, so the corpus runs under several.
|
||||
const variants: Record<string, string | undefined> = {
|
||||
user: undefined,
|
||||
empty: "",
|
||||
"split index": "[core]\n\tsplitIndex = true\n",
|
||||
"skip hash": "[index]\n\tskipHash = true\n[feature]\n\tmanyFiles = true\n",
|
||||
"autocrlf and no untracked cache": "[core]\n\tautocrlf = true\n\tuntrackedCache = false\n",
|
||||
fsmonitor: "[core]\n\tfsmonitor = true\n",
|
||||
"template hook": "[init]\n\ttemplateDir = TEMPLATE\n",
|
||||
}
|
||||
const variant = process.env.PARITY_CONFIG ?? "user"
|
||||
if (variants[variant] !== undefined) {
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const template = path.join(root, `template-${process.pid}`)
|
||||
await fs.mkdir(path.join(template, "hooks"), { recursive: true })
|
||||
await fs.writeFile(path.join(template, "hooks", "post-checkout"), '#!/bin/sh\necho "checkout $3" > .hooklog\n', {
|
||||
mode: 0o755,
|
||||
})
|
||||
// Stores are created with `git init`, so template ignore rules must keep applying to them.
|
||||
await fs.mkdir(path.join(template, "info"), { recursive: true })
|
||||
await fs.writeFile(path.join(template, "info", "exclude"), "template-ignored/\n")
|
||||
const config = path.join(root, `gitconfig-${process.pid}`)
|
||||
await fs.writeFile(config, variants[variant]!.replace("TEMPLATE", template))
|
||||
process.env.GIT_CONFIG_GLOBAL = config
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_ALLOW_PROTOCOL: "file",
|
||||
GIT_AUTHOR_NAME: "Parity",
|
||||
GIT_AUTHOR_EMAIL: "parity@opencode.test",
|
||||
GIT_COMMITTER_NAME: "Parity",
|
||||
GIT_COMMITTER_EMAIL: "parity@opencode.test",
|
||||
GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
|
||||
GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
type Context = {
|
||||
readonly dir: string
|
||||
readonly write: (file: string, content: string | Uint8Array) => Promise<void>
|
||||
readonly remove: (file: string) => Promise<void>
|
||||
readonly git: (args: string[], cwd?: string) => Promise<string>
|
||||
readonly capture: (label: string, location?: string) => Promise<void>
|
||||
readonly files: (from: string, to: string) => Promise<void>
|
||||
readonly diff: (from: string, to: string, paths?: string[]) => Promise<void>
|
||||
readonly restore: (label: string, files: Record<string, string>) => Promise<void>
|
||||
readonly digest: (label: string) => Promise<void>
|
||||
}
|
||||
|
||||
type Scenario = (ctx: Context) => Promise<void>
|
||||
|
||||
const commit = async (ctx: Context, message = "initial") => {
|
||||
await ctx.git(["add", "-A"])
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "--allow-empty", "-m", message])
|
||||
}
|
||||
|
||||
const scenarios: Record<string, Scenario> = {
|
||||
async basic(ctx) {
|
||||
await ctx.write("a.txt", "a\n")
|
||||
await ctx.write("b.txt", "b\n")
|
||||
await ctx.write("dir/c.txt", "c\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("a.txt", "a2\n")
|
||||
await ctx.remove("b.txt")
|
||||
await ctx.write("dir/new.txt", "new\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.capture("t1-again")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.diff("t0", "t1", ["a.txt"])
|
||||
await ctx.restore("undo", { "a.txt": "t0", "b.txt": "t0", "dir/new.txt": "t0" })
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "rename directory"(ctx) {
|
||||
for (let i = 0; i < 20; i++) await ctx.write(`src/f${i}.ts`, `${i}\n`)
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await fs.rename(path.join(ctx.dir, "src"), path.join(ctx.dir, "lib"))
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
},
|
||||
|
||||
async "file becomes directory"(ctx) {
|
||||
await ctx.write("a", "file\n")
|
||||
await ctx.write("keep.txt", "keep\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.remove("a")
|
||||
await ctx.write("a/b", "nested\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.restore("undo", { a: "t0", "a/b": "t0" })
|
||||
await ctx.capture("t2")
|
||||
},
|
||||
|
||||
async "directory becomes file"(ctx) {
|
||||
await ctx.write("d/x", "x\n")
|
||||
await ctx.write("d/y", "y\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.remove("d")
|
||||
await ctx.write("d", "file now\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.restore("undo", { "d/x": "t0", "d/y": "t0", d: "t0" })
|
||||
await ctx.capture("t2")
|
||||
},
|
||||
|
||||
async "embedded repository"(ctx) {
|
||||
await ctx.write("top.txt", "top\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("sub/inner.txt", "inner\n")
|
||||
await ctx.git(["init", "-q"], path.join(ctx.dir, "sub"))
|
||||
await ctx.git(["add", "-A"], path.join(ctx.dir, "sub"))
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "inner"], path.join(ctx.dir, "sub"))
|
||||
await ctx.capture("t1")
|
||||
await ctx.write("sub/inner.txt", "changed\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "inner 2"], path.join(ctx.dir, "sub"))
|
||||
await ctx.capture("t3")
|
||||
await ctx.files("t0", "t3")
|
||||
await ctx.diff("t0", "t3")
|
||||
},
|
||||
|
||||
async symlinks(ctx) {
|
||||
await ctx.write("target.txt", "target\n")
|
||||
await ctx.write("dir/inside.txt", "inside\n")
|
||||
await ctx.write("plain.txt", "plain\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await fs.symlink("target.txt", path.join(ctx.dir, "link"))
|
||||
await fs.symlink("dir", path.join(ctx.dir, "dirlink"))
|
||||
await fs.symlink("missing", path.join(ctx.dir, "dangling"))
|
||||
await ctx.remove("plain.txt")
|
||||
await fs.symlink("target.txt", path.join(ctx.dir, "plain.txt"))
|
||||
await ctx.capture("t1")
|
||||
await ctx.write("target.txt", "target 2\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
await ctx.diff("t0", "t2")
|
||||
await ctx.restore("undo", { link: "t0", dirlink: "t0", dangling: "t0", "plain.txt": "t0", "target.txt": "t0" })
|
||||
await ctx.capture("t3")
|
||||
},
|
||||
|
||||
async "executable bit"(ctx) {
|
||||
await ctx.write("run.sh", "echo hi\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await fs.chmod(path.join(ctx.dir, "run.sh"), 0o755)
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.restore("undo", { "run.sh": "t0" })
|
||||
await ctx.capture("t2")
|
||||
},
|
||||
|
||||
async "special names"(ctx) {
|
||||
const names = [
|
||||
"with space.txt",
|
||||
"caf\u00e9.txt",
|
||||
"cafe\u0301-nfd.txt",
|
||||
'quote"s.txt',
|
||||
"-rf",
|
||||
":colon.txt",
|
||||
"!bang.txt",
|
||||
"#hash.txt",
|
||||
"app/[slug]/page.tsx",
|
||||
"star*.txt",
|
||||
"q?.txt",
|
||||
"back\\slash.txt",
|
||||
"tab\tname.txt",
|
||||
"new\nline.txt",
|
||||
"\u65e5\u672c\u8a9e/\u30d5\u30a1\u30a4\u30eb.txt",
|
||||
]
|
||||
for (const name of names) await ctx.write(name, `${name}\n`)
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
for (const name of names) await ctx.write(name, `${name} changed\n`)
|
||||
await ctx.write("added [x].txt", "added\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.restore("undo", Object.fromEntries([...names, "added [x].txt"].map((name) => [name, "t0"])))
|
||||
await ctx.capture("t2")
|
||||
},
|
||||
|
||||
async "ignore rules"(ctx) {
|
||||
await ctx.write(".gitignore", "*.log\nbuild/\n!keep.log\n")
|
||||
await ctx.write("keep.log", "keep\n")
|
||||
await ctx.write("src/a.ts", "a\n")
|
||||
await ctx.write("build/forced.txt", "forced\n")
|
||||
await ctx.git(["add", "-A"])
|
||||
await ctx.git(["add", "-f", "build/forced.txt"])
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "initial"])
|
||||
await ctx.write(".git/info/exclude", "secret/\n*.local\n")
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("debug.log", "ignored\n")
|
||||
await ctx.write("keep.log", "keep 2\n")
|
||||
await ctx.write("build/forced.txt", "forced 2\n")
|
||||
await ctx.write("build/out.js", "out\n")
|
||||
await ctx.write("secret/key.txt", "key\n")
|
||||
await ctx.write("config.local", "local\n")
|
||||
await ctx.write("template-ignored/x.txt", "template\n")
|
||||
await ctx.write("src/a.ts", "a2\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.write(".git/info/exclude", "")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t1", "t2")
|
||||
},
|
||||
|
||||
async "gitignore changes"(ctx) {
|
||||
await ctx.write("gen/out.txt", "1\n")
|
||||
await ctx.write("src.txt", "src\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write(".gitignore", "gen/\n")
|
||||
await ctx.write("gen/out.txt", "2\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.write(".gitignore", "")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t1", "t2")
|
||||
},
|
||||
|
||||
async "large files"(ctx) {
|
||||
await ctx.write("tracked.bin", "small\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("big.bin", new Uint8Array(3 * 1024 * 1024).fill(7))
|
||||
await ctx.write("tracked.bin", new Uint8Array(3 * 1024 * 1024).fill(9))
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.write("big.bin", "now small\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t1", "t2")
|
||||
},
|
||||
|
||||
async "line endings"(ctx) {
|
||||
await ctx.write(".gitattributes", "* text=auto\n*.bat eol=crlf\n")
|
||||
await ctx.write("unix.txt", "one\ntwo\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("unix.txt", "one\r\ntwo\r\nthree\r\n")
|
||||
await ctx.write("run.bat", "echo\r\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.restore("undo", { "unix.txt": "t0", "run.bat": "t0" })
|
||||
await ctx.digest("after-undo")
|
||||
},
|
||||
|
||||
async "merge conflict in source"(ctx) {
|
||||
await ctx.write("c.txt", "base\n")
|
||||
await commit(ctx)
|
||||
await ctx.git(["checkout", "-q", "-b", "other"])
|
||||
await ctx.write("c.txt", "other\n")
|
||||
await commit(ctx, "other")
|
||||
await ctx.git(["checkout", "-q", "-"])
|
||||
await ctx.write("c.txt", "main\n")
|
||||
await commit(ctx, "main")
|
||||
await ctx.git(["merge", "-q", "other"]).catch(() => "")
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("c.txt", "resolved\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "case-only rename"(ctx) {
|
||||
await ctx.write("Readme.md", "readme\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await fs.rename(path.join(ctx.dir, "Readme.md"), path.join(ctx.dir, "README.md"))
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.write("README.md", "changed\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "location subdirectory"(ctx) {
|
||||
await ctx.write("pkg/a.txt", "a\n")
|
||||
await ctx.write("other/b.txt", "b\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0", "pkg")
|
||||
await ctx.write("pkg/a.txt", "a2\n")
|
||||
await ctx.write("pkg/new.txt", "new\n")
|
||||
await ctx.write("other/b.txt", "b2\n")
|
||||
await ctx.capture("t1", "pkg")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.capture("t2", ".")
|
||||
await ctx.files("t1", "t2")
|
||||
await ctx.capture("t3", "pkg")
|
||||
await ctx.files("t2", "t3")
|
||||
},
|
||||
|
||||
async "many untracked"(ctx) {
|
||||
await ctx.write("seed.txt", "seed\n")
|
||||
await commit(ctx)
|
||||
for (let i = 0; i < 3000; i++) await ctx.write(`gen/d${i % 30}/f${i}.txt`, `${i}\n`)
|
||||
await ctx.capture("t0")
|
||||
await ctx.capture("t0-again")
|
||||
},
|
||||
|
||||
async "empty directories and delete all"(ctx) {
|
||||
await ctx.write("a/b/c.txt", "c\n")
|
||||
await ctx.write("d.txt", "d\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await fs.mkdir(path.join(ctx.dir, "empty/nested"), { recursive: true })
|
||||
await ctx.capture("t1")
|
||||
await ctx.remove("a")
|
||||
await ctx.remove("d.txt")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
await ctx.restore("undo", { "a/b/c.txt": "t0", "d.txt": "t0" })
|
||||
await ctx.capture("t3")
|
||||
},
|
||||
|
||||
async "restore with overlapping paths"(ctx) {
|
||||
await ctx.write("a", "file a\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.remove("a")
|
||||
await ctx.write("a/b", "nested b\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.remove("a")
|
||||
await ctx.write("a", "file again\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.restore("order-1", { a: "t0", "a/b": "t0" })
|
||||
await ctx.restore("order-2", { "a/b": "t1", a: "t1" })
|
||||
await ctx.restore("order-3", { a: "t1", "a/b": "t1" })
|
||||
await ctx.restore("order-4", { "a/b": "t0", a: "t2" })
|
||||
},
|
||||
|
||||
async "restore from several trees"(ctx) {
|
||||
await ctx.write("x.txt", "x0\n")
|
||||
await ctx.write("y.txt", "y0\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("x.txt", "x1\n")
|
||||
await ctx.write("z.txt", "z1\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.write("x.txt", "x2\n")
|
||||
await ctx.write("y.txt", "y2\n")
|
||||
await ctx.write("z.txt", "z2\n")
|
||||
await ctx.write("w.txt", "w2\n")
|
||||
await ctx.restore("mixed", { "x.txt": "t1", "y.txt": "t0", "z.txt": "t0", "w.txt": "t1" })
|
||||
},
|
||||
|
||||
async "staged changes in source"(ctx) {
|
||||
await ctx.write("a.txt", "a\n")
|
||||
await commit(ctx)
|
||||
await ctx.write("a.txt", "staged\n")
|
||||
await ctx.write("new.txt", "staged new\n")
|
||||
await ctx.git(["add", "-A"])
|
||||
await ctx.write("a.txt", "worktree\n")
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("a.txt", "worktree 2\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "binary and replaced types"(ctx) {
|
||||
await ctx.write("img.bin", new Uint8Array([0, 1, 2, 3, 0, 255]))
|
||||
await ctx.write("f.txt", "file\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("img.bin", new Uint8Array([0, 9, 9, 3, 0, 255, 1]))
|
||||
await ctx.remove("f.txt")
|
||||
await fs.symlink("img.bin", path.join(ctx.dir, "f.txt"))
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
await ctx.diff("t0", "t1")
|
||||
await ctx.remove("f.txt")
|
||||
await ctx.write("f.txt", "file\n")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "sparse checkout source"(ctx) {
|
||||
await ctx.write("in/a.txt", "a\n")
|
||||
await ctx.write("out/b.txt", "b\n")
|
||||
await commit(ctx)
|
||||
await ctx.git(["sparse-checkout", "set", "in"])
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("in/a.txt", "a2\n")
|
||||
await ctx.write("in/new.txt", "new\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "unreadable file"(ctx) {
|
||||
await ctx.write("ok.txt", "ok\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("locked.txt", "locked\n")
|
||||
await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o000)
|
||||
await ctx.capture("t1")
|
||||
await fs.chmod(path.join(ctx.dir, "locked.txt"), 0o644)
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "no commits yet"(ctx) {
|
||||
await ctx.write("a.txt", "a\n")
|
||||
await ctx.capture("t0")
|
||||
await ctx.git(["add", "a.txt"])
|
||||
await ctx.write("b.txt", "b\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "special files"(ctx) {
|
||||
await ctx.write("a.txt", "a\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await $`mkfifo ${path.join(ctx.dir, "pipe")}`.quiet()
|
||||
await ctx.capture("t1")
|
||||
await ctx.remove("pipe")
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "tracked submodule"(ctx) {
|
||||
const other = path.join(path.dirname(ctx.dir), "library")
|
||||
await fs.mkdir(other)
|
||||
await ctx.git(["init", "-q"], other)
|
||||
await fs.writeFile(path.join(other, "lib.txt"), "lib\n")
|
||||
await ctx.git(["add", "-A"], other)
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "-m", "lib"], other)
|
||||
await ctx.git(["-c", "protocol.file.allow=always", "submodule", "add", "-q", other, "vendor/library"])
|
||||
// The absolute temporary URL differs between runs; pin it so trees are comparable.
|
||||
await ctx.git(["config", "-f", ".gitmodules", "submodule.vendor/library.url", "../library"])
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("vendor/library/lib.txt", "changed\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.git(["commit", "-q", "--no-gpg-sign", "-am", "bump"], path.join(ctx.dir, "vendor/library"))
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
await ctx.diff("t0", "t2")
|
||||
},
|
||||
|
||||
async "index flags in source"(ctx) {
|
||||
await ctx.write("assumed.txt", "a\n")
|
||||
await ctx.write("skipped.txt", "s\n")
|
||||
await ctx.write("plain.txt", "p\n")
|
||||
await commit(ctx)
|
||||
await ctx.git(["update-index", "--assume-unchanged", "assumed.txt"])
|
||||
await ctx.git(["update-index", "--skip-worktree", "skipped.txt"])
|
||||
await ctx.write("intent.txt", "intent\n")
|
||||
await ctx.git(["add", "-N", "intent.txt"])
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("assumed.txt", "a2\n")
|
||||
await ctx.write("skipped.txt", "s2\n")
|
||||
await ctx.write("intent.txt", "intent 2\n")
|
||||
await ctx.write("plain.txt", "p2\n")
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "restore key spellings"(ctx) {
|
||||
await ctx.write("Readme.md", "readme\n")
|
||||
await ctx.write("dir/a.txt", "a\n")
|
||||
await ctx.write("dir/b.txt", "b\n")
|
||||
await ctx.write("caf\u00e9.txt", "nfc\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.write("Readme.md", "changed\n")
|
||||
await ctx.write("dir/a.txt", "a2\n")
|
||||
await ctx.write("dir/c.txt", "c2\n")
|
||||
await ctx.write("caf\u00e9.txt", "nfc 2\n")
|
||||
await ctx.restore("case", { "readme.md": "t0" })
|
||||
await ctx.restore("directory", { dir: "t0" })
|
||||
await ctx.restore("nfd", { "cafe\u0301.txt": "t0" })
|
||||
await ctx.restore("dot-slash", { "./Readme.md": "t0" })
|
||||
},
|
||||
|
||||
async "large batched restore"(ctx) {
|
||||
for (let i = 0; i < 300; i++) await ctx.write(`src/m${i % 7}/f${i}.ts`, `export const v = ${i}\n`)
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
for (let i = 0; i < 300; i += 2) await ctx.write(`src/m${i % 7}/f${i}.ts`, `changed ${i}\n`)
|
||||
for (let i = 0; i < 50; i++) await ctx.write(`src/new/n${i}.ts`, `new ${i}\n`)
|
||||
for (let i = 1; i < 300; i += 3) await ctx.remove(`src/m${i % 7}/f${i}.ts`)
|
||||
await ctx.capture("t1")
|
||||
await ctx.files("t0", "t1")
|
||||
const changed = await Promise.resolve().then(async () => {
|
||||
const files: Record<string, string> = {}
|
||||
for (let i = 0; i < 300; i++) files[`src/m${i % 7}/f${i}.ts`] = "t0"
|
||||
for (let i = 0; i < 50; i++) files[`src/new/n${i}.ts`] = "t0"
|
||||
return files
|
||||
})
|
||||
await ctx.restore("all", changed)
|
||||
await ctx.capture("t2")
|
||||
await ctx.files("t0", "t2")
|
||||
},
|
||||
|
||||
async "restore blocked by a file"(ctx) {
|
||||
await ctx.write("a/b.txt", "b\n")
|
||||
await ctx.write("z.txt", "z\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.remove("a")
|
||||
await ctx.write("a", "now a file\n")
|
||||
await ctx.write("z.txt", "z2\n")
|
||||
await ctx.restore("blocked", { "a/b.txt": "t0", "z.txt": "t0" })
|
||||
},
|
||||
|
||||
async "location with pattern characters"(ctx) {
|
||||
await ctx.write("app/[id]/page.tsx", "page\n")
|
||||
await ctx.write("app/i/other.tsx", "other\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0", "app/[id]")
|
||||
await ctx.write("app/[id]/page.tsx", "page 2\n")
|
||||
await ctx.write("app/i/other.tsx", "other 2\n")
|
||||
await ctx.capture("t1", "app/[id]")
|
||||
await ctx.files("t0", "t1")
|
||||
},
|
||||
|
||||
async "restore paths absent from both sides"(ctx) {
|
||||
await ctx.write("a.txt", "a\n")
|
||||
await commit(ctx)
|
||||
await ctx.capture("t0")
|
||||
await ctx.restore("missing", { "never.txt": "t0", "gone/deep/x.txt": "t0" })
|
||||
await ctx.write("later.txt", "later\n")
|
||||
await ctx.restore("remove-later", { "later.txt": "t0" })
|
||||
},
|
||||
}
|
||||
|
||||
async function run(name: string, scenario: Scenario) {
|
||||
const base = await fs.realpath(await fs.mkdtemp(path.join(root, "case-")))
|
||||
const dir = path.join(base, "project")
|
||||
const data = path.join(base, "data")
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
const git = (args: string[], cwd = dir) =>
|
||||
$`git -c core.fsmonitor=false -c core.splitIndex=false -c init.defaultBranch=main ${args}`
|
||||
.cwd(cwd)
|
||||
.env(env)
|
||||
.quiet()
|
||||
.text()
|
||||
await git(["init", "-q"])
|
||||
const log: unknown[] = []
|
||||
const trees = new Map<string, Snapshot.ID | undefined>()
|
||||
// One long-lived runtime per Location, as in production, so in-process caches are exercised.
|
||||
const runtimes = new Map<string, ManagedRuntime.ManagedRuntime<Snapshot.Service, never>>()
|
||||
const runtime = (location = ".") => {
|
||||
const existing = runtimes.get(location)
|
||||
if (existing) return existing
|
||||
const created = ManagedRuntime.make(
|
||||
Layer.provide(snapshotLayer(data, path.join(dir, location)), Logger.layer([])) as Layer.Layer<Snapshot.Service>,
|
||||
)
|
||||
runtimes.set(location, created)
|
||||
return created
|
||||
}
|
||||
const use = <A>(location: string | undefined, body: (snapshot: Snapshot.Interface) => Effect.Effect<A, unknown>) =>
|
||||
runtime(location)
|
||||
.runPromise(
|
||||
Effect.gen(function* () {
|
||||
return yield* body(yield* Snapshot.Service)
|
||||
}).pipe(Effect.exit),
|
||||
)
|
||||
.then((exit) => (exit._tag === "Success" ? { ok: exit.value } : { error: true }))
|
||||
const tree = (label: string) => {
|
||||
const id = trees.get(label)
|
||||
if (!id) throw new Error(`no tree ${label}`)
|
||||
return id
|
||||
}
|
||||
const ctx: Context = {
|
||||
dir,
|
||||
write: async (file, content) => {
|
||||
await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true })
|
||||
await fs.writeFile(path.join(dir, file), content)
|
||||
},
|
||||
remove: (file) => fs.rm(path.join(dir, file), { recursive: true, force: true }),
|
||||
git,
|
||||
capture: async (label, location) => {
|
||||
const result = await use(location, (snapshot) => snapshot.capture())
|
||||
const id = "ok" in result ? result.ok : undefined
|
||||
trees.set(label, id)
|
||||
log.push({ capture: label, tree: id ?? null, entries: id ? await listing(data, id) : null })
|
||||
},
|
||||
files: async (from, to) => {
|
||||
if (!trees.get(from) || !trees.get(to)) return void log.push({ files: [from, to], skipped: true })
|
||||
log.push({ files: [from, to], result: await use(undefined, (s) => s.files({ from: tree(from), to: tree(to) })) })
|
||||
},
|
||||
diff: async (from, to, paths) => {
|
||||
if (!trees.get(from) || !trees.get(to)) return void log.push({ diff: [from, to], skipped: true })
|
||||
const result = await use(undefined, (s) =>
|
||||
s.diff({ from: tree(from), to: tree(to), paths: paths?.map((file) => RelativePath.make(file)) }),
|
||||
)
|
||||
log.push({
|
||||
diff: [from, to, paths ?? null],
|
||||
result:
|
||||
"ok" in result && result.ok
|
||||
? result.ok.map((file) => ({
|
||||
...file,
|
||||
patch: crypto.createHash("sha1").update(file.patch).digest("hex"),
|
||||
}))
|
||||
: result,
|
||||
})
|
||||
},
|
||||
restore: async (label, files) => {
|
||||
const plan = new Map(
|
||||
Object.entries(files).flatMap(([file, from]) => {
|
||||
const id = trees.get(from)
|
||||
return id ? [[RelativePath.make(file), id] as const] : []
|
||||
}),
|
||||
)
|
||||
const result = await use(undefined, (s) => s.restore({ files: plan }))
|
||||
log.push({ restore: label, result: "ok" in result ? "ok" : result, worktree: await digest(dir) })
|
||||
},
|
||||
digest: async (label) => {
|
||||
log.push({ digest: label, worktree: await digest(dir) })
|
||||
},
|
||||
}
|
||||
const failure = await scenario(ctx).then(
|
||||
() => undefined,
|
||||
(error: unknown) => String(error),
|
||||
)
|
||||
if (failure) log.push({ scenarioError: failure })
|
||||
await Promise.all([...runtimes.values()].map((item) => item.dispose()))
|
||||
await $`chmod -R u+rwX ${base}`.quiet().nothrow()
|
||||
if (process.env.PARITY_KEEP) console.error(`kept ${base}`)
|
||||
if (!process.env.PARITY_KEEP) await fs.rm(base, { recursive: true, force: true })
|
||||
return log
|
||||
}
|
||||
|
||||
function snapshotLayer(data: string, directory: string) {
|
||||
return AppNodeBuilder.build(Snapshot.node, [
|
||||
Location.node.replace(Location.boundNode(Location.Ref.make({ directory: AbsolutePath.make(directory) }))),
|
||||
Global.node.replace(Global.layerWith({ data, config: path.join(data, "config") })),
|
||||
])
|
||||
}
|
||||
|
||||
/** The recursive listing of a captured tree, read from whichever snapshot store holds it. */
|
||||
async function listing(data: string, tree: string) {
|
||||
const stores = (await $`find ${path.join(data, "snapshot")} -mindepth 2 -maxdepth 2 -type d`.quiet().text())
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
for (const store of stores) {
|
||||
const result = await $`git --git-dir ${store} ls-tree -r -z ${tree}`.quiet().nothrow()
|
||||
if (result.exitCode === 0) return result.stdout.toString().split("\0").filter(Boolean)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Paths, types, modes, and content hashes of the worktree, excluding `.git`. */
|
||||
async function digest(dir: string) {
|
||||
const entries: string[] = []
|
||||
const walk = async (current: string) => {
|
||||
for (const entry of (await fs.readdir(current, { withFileTypes: true })).toSorted((a, b) =>
|
||||
a.name < b.name ? -1 : 1,
|
||||
)) {
|
||||
const full = path.join(current, entry.name)
|
||||
const relative = path.relative(dir, full)
|
||||
if (relative === ".git" || relative.startsWith(".git/")) continue
|
||||
const stat = await fs.lstat(full)
|
||||
if (stat.isSymbolicLink()) entries.push(`L ${relative} -> ${await fs.readlink(full)}`)
|
||||
else if (stat.isDirectory()) {
|
||||
entries.push(`D ${relative}`)
|
||||
await walk(full)
|
||||
} else {
|
||||
const content = await fs.readFile(full).catch(() => Buffer.from("<unreadable>"))
|
||||
entries.push(
|
||||
`F ${relative} ${stat.mode & 0o111 ? "x" : "-"} ${crypto.createHash("sha1").update(content).digest("hex")}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(dir)
|
||||
return entries
|
||||
}
|
||||
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const [outputFile, ...selected] = process.argv.slice(2)
|
||||
const output: Record<string, unknown> = {}
|
||||
for (const [name, scenario] of Object.entries(scenarios)) {
|
||||
if (selected.length && !selected.includes(name)) continue
|
||||
output[name] = await run(name, scenario)
|
||||
}
|
||||
await Bun.write(outputFile!, JSON.stringify(output, null, 2))
|
||||
@@ -14,12 +14,7 @@ type Target<ID extends string> = Overlays & {
|
||||
variants?: (Overlays & { id: ID })[]
|
||||
}
|
||||
|
||||
type Context = {
|
||||
readonly providerID: string
|
||||
readonly canonical?: string
|
||||
readonly modelID?: string
|
||||
readonly shape?: "responses" | "completions"
|
||||
}
|
||||
type Context = { readonly providerID: string; readonly canonical?: string; readonly modelID?: string }
|
||||
|
||||
export function rewrite<ID extends string>(
|
||||
target: Target<ID>,
|
||||
@@ -131,13 +126,11 @@ export function native(npm: string, context: Context & { readonly settings?: Pro
|
||||
const host = HOSTS[context.providerID]?.[npm]
|
||||
if (host) return host
|
||||
if (npm === "@ai-sdk/amazon-bedrock/mantle") return mantle(context.modelID)
|
||||
if (npm === "@ai-sdk/azure" && azureChat(context)) return "@opencode/ai/providers/azure/chat"
|
||||
if (npm === "@ai-sdk/azure" && context.settings?.useCompletionUrls === true)
|
||||
return "@opencode/ai/providers/azure/chat"
|
||||
return PACKAGES[npm]
|
||||
}
|
||||
|
||||
const azureChat = (context: Context & { readonly settings?: Provider.Settings }) =>
|
||||
context.shape === "completions" || context.settings?.useCompletionUrls === true
|
||||
|
||||
const mantle = (modelID: string | undefined) => {
|
||||
if (modelID === undefined) return "@opencode/ai/providers/amazon-bedrock/mantle"
|
||||
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
|
||||
@@ -148,7 +141,7 @@ function resolve(specifier: string, context: Context & { readonly settings?: Pro
|
||||
if (Provider.isAISDK(specifier) || npm in PACKAGES || npm in (HOSTS[context.providerID] ?? {}))
|
||||
return native(npm, context)
|
||||
if (npm === "@opencode/ai/providers/amazon-bedrock/mantle") return mantle(context.modelID)
|
||||
if (npm === "@opencode/ai/providers/azure/responses" && azureChat(context))
|
||||
if (npm === "@opencode/ai/providers/azure/responses" && context.settings?.useCompletionUrls === true)
|
||||
return "@opencode/ai/providers/azure/chat"
|
||||
return NATIVE.has(npm) ? npm : undefined
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ export * as ConfigInstructionPlugin from "./instruction.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { sameDirectory } from "@opencode/util/path"
|
||||
import { dirname, join } from "path"
|
||||
import { Effect, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
@@ -134,10 +133,7 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
// `start` keeps the client's spelling while `stop` may come from git, so a Windows drive
|
||||
// letter can differ only in case (`c:\repo` vs `C:\repo`). Compare the way FSUtil.contains
|
||||
// admitted `start` beneath `stop`, or the walk passes `stop` and recurses at the drive root.
|
||||
function ancestorDirectories(start: string, stop: string): string[] {
|
||||
if (sameDirectory(start, stop)) return [start]
|
||||
if (start === stop) return [start]
|
||||
return [start, ...ancestorDirectories(dirname(start), stop)]
|
||||
}
|
||||
+778
-121
File diff suppressed because it is too large.
Load diff
@@ -17,7 +17,6 @@ import {
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import type { ServerResponse } from "node:http"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { Credential } from "../credential.js"
|
||||
import { OauthCallbackPage } from "../oauth/page.js"
|
||||
@@ -349,7 +348,7 @@ export const authorize = (input: {
|
||||
const client = clientFromCredential(previous)
|
||||
if (client) yield* Effect.promise(() => store.saveClientInformation(client))
|
||||
}
|
||||
const code = yield* Deferred.make<{ code: string; iss: string | undefined; response: ServerResponse }, Error>()
|
||||
const code = yield* Deferred.make<{ code: string; iss: string | undefined }, Error>()
|
||||
const redirect = oauth?.redirect_uri ? new URL(oauth.redirect_uri) : undefined
|
||||
const redirectPath = redirect?.pathname ?? "/callback"
|
||||
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
|
||||
@@ -364,7 +363,7 @@ export const authorize = (input: {
|
||||
}
|
||||
const fail = (reason: string, failure: string) => {
|
||||
runFork(Effect.logWarning("mcp oauth callback rejected", { ...fields, reason: failure }))
|
||||
Effect.runSync(Deferred.fail(code, new Error(reason)))
|
||||
Effect.runFork(Deferred.fail(code, new Error(reason)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(reason, { provider: input.name }))
|
||||
@@ -374,11 +373,8 @@ export const authorize = (input: {
|
||||
if (url.searchParams.get("state") !== state) return fail("OAuth state mismatch", "state_mismatch")
|
||||
const value = url.searchParams.get("code")
|
||||
if (!value) return fail("Missing authorization code", "missing_code")
|
||||
// The page waits for the token exchange so the browser never reports success for a rejected code.
|
||||
if (
|
||||
!Effect.runSync(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined, response }))
|
||||
)
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
Effect.runFork(Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined }))
|
||||
response.writeHead(200, { "Content-Type": "text/html" }).end(OauthCallbackPage.success({ provider: input.name }))
|
||||
})
|
||||
|
||||
// callback_port, else the port pinned by redirect_uri, else ephemeral; a mismatch strands the browser.
|
||||
@@ -474,52 +470,30 @@ export const authorize = (input: {
|
||||
if (!authorizationUrl)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" did not provide an authorization URL`))
|
||||
|
||||
const completed = yield* Deferred.make<Credential.OAuth, Error>()
|
||||
yield* Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) => {
|
||||
const respond = (error?: string) =>
|
||||
Effect.sync(() =>
|
||||
value.response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: input.name })
|
||||
: OauthCallbackPage.success({ provider: input.name }),
|
||||
),
|
||||
)
|
||||
return Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value.code,
|
||||
iss: value.iss,
|
||||
scope: oauth?.scope ?? challenge.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}).pipe(
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.tap(() => respond()),
|
||||
Effect.tapError((error) => respond(error.message)),
|
||||
Effect.onInterrupt(() => Effect.sync(() => value.response.destroy())),
|
||||
)
|
||||
}),
|
||||
Effect.onErrorIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
Effect.exit,
|
||||
Effect.flatMap((exit) => Deferred.done(completed, exit)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
return {
|
||||
url: authorizationUrl.toString(),
|
||||
instructions: `Authorize ${input.name} in your browser. This window will close automatically.`,
|
||||
mode: "auto" as const,
|
||||
callback: Deferred.await(completed),
|
||||
callback: Deferred.await(code).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
Effect.tryPromise({
|
||||
try: () =>
|
||||
auth(oauthProvider, {
|
||||
serverUrl: input.config.url,
|
||||
authorizationCode: value.code,
|
||||
iss: value.iss,
|
||||
scope: oauth?.scope ?? challenge.scope,
|
||||
fetchFn,
|
||||
}),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
}),
|
||||
),
|
||||
Effect.flatMap(() => finalize),
|
||||
Effect.onError((cause) =>
|
||||
Effect.logWarning("mcp oauth authorization failed", { errors: ErrorSummary.from(Cause.squash(cause)) }),
|
||||
),
|
||||
Effect.annotateLogs(fields),
|
||||
),
|
||||
}
|
||||
}).pipe(
|
||||
Effect.onError((cause) =>
|
||||
|
||||
@@ -408,9 +408,7 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((model) =>
|
||||
model && hasPackage(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(models.available(), (models) =>
|
||||
models.find((model) => hasPackage(model) && Model.supportsText(model)),
|
||||
),
|
||||
: Effect.map(models.available(), (models) => models.find(hasPackage)),
|
||||
),
|
||||
)
|
||||
if (!selected) return undefined
|
||||
|
||||
@@ -261,7 +261,7 @@ const layer = Layer.effect(
|
||||
const value = yield* read()
|
||||
const requested = value.data.defaultModel
|
||||
const model = requested && value.byProvider.get(requested.providerID)?.get(requested.modelID)
|
||||
return model?.enabled ? model : value.available.find(supportsText)
|
||||
return model?.enabled ? model : value.available[0]
|
||||
}),
|
||||
small: Effect.fn("Model.small")(function* (providerID) {
|
||||
const value = yield* read()
|
||||
@@ -282,13 +282,6 @@ const layer = Layer.effect(
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Provider.node, Bus.node, Location.node] })
|
||||
|
||||
export function supportsText(model: Pick<Info, "capabilities">) {
|
||||
return (
|
||||
(model.capabilities.input.length === 0 || model.capabilities.input.some((item) => item.startsWith("text"))) &&
|
||||
(model.capabilities.output.length === 0 || model.capabilities.output.some((item) => item.startsWith("text")))
|
||||
)
|
||||
}
|
||||
|
||||
export function compatibility(input: unknown): Compatibility | undefined {
|
||||
if (typeof input === "string") return { reasoningField: input }
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input) || !("field" in input)) return undefined
|
||||
|
||||
@@ -60,11 +60,7 @@ type SourceModel = {
|
||||
>
|
||||
}
|
||||
readonly status?: CatalogModelStatus
|
||||
readonly provider?: {
|
||||
readonly npm?: string
|
||||
readonly api?: string
|
||||
readonly shape?: "responses" | "completions"
|
||||
}
|
||||
readonly provider?: { readonly npm?: string; readonly api?: string }
|
||||
}
|
||||
|
||||
type SourceProvider = {
|
||||
@@ -84,13 +80,7 @@ export type Snapshot = {
|
||||
|
||||
function nativePackage(provider: SourceProvider, model?: SourceModel) {
|
||||
const npm = model?.provider?.npm ?? provider.npm
|
||||
return (
|
||||
AISDKNative.native(npm, {
|
||||
providerID: provider.id,
|
||||
modelID: model?.id,
|
||||
shape: model?.provider?.shape,
|
||||
}) ?? Provider.aisdk(npm)
|
||||
)
|
||||
return AISDKNative.native(npm, { providerID: provider.id, modelID: model?.id }) ?? Provider.aisdk(npm)
|
||||
}
|
||||
|
||||
function normalize(input: Record<string, SourceProvider>): readonly Snapshot[] {
|
||||
@@ -213,12 +203,11 @@ function modelInfo(
|
||||
} = {},
|
||||
): Model.Info {
|
||||
const providerID = Provider.ID.make(provider.id)
|
||||
const resolved = nativePackage(provider, model)
|
||||
const pkg = model.provider?.npm || resolved !== nativePackage(provider) ? resolved : undefined
|
||||
const pkg = model.provider?.npm ? nativePackage(provider, model) : undefined
|
||||
// Per model, so it never merges into a model that overrides to a different package.
|
||||
const settings = {
|
||||
...(model.provider?.api ? { baseURL: model.provider.api } : {}),
|
||||
...(resolved === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
|
||||
...(nativePackage(provider, model) === "@opencode/ai/providers/openai-compatible" ? { provider: providerID } : {}),
|
||||
}
|
||||
return {
|
||||
id,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-coLine truncated
|
||||
@@ -7,13 +7,19 @@ 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:
|
||||
- Your role is EXCLUSIVELY to search and analyze
|
||||
- Parallelize independent tool calls for searches and reads whenever possible
|
||||
- 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
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Return file paths as absolute paths in your final response
|
||||
- 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
|
||||
- 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
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
@@ -109,7 +115,6 @@ 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" },
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Integration } from "@opencode/schema/integration"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Model } from "../model.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
|
||||
// These catalog entries require inference profiles on Bedrock Runtime.
|
||||
@@ -101,7 +100,6 @@ function snapshots(data: readonly ModelsDev.Snapshot[]) {
|
||||
models: provider.models.filter(
|
||||
(model) =>
|
||||
model.status !== "deprecated" &&
|
||||
Model.supportsText(model) &&
|
||||
!(
|
||||
provider.info.id === Provider.ID.amazonBedrock &&
|
||||
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
|
||||
|
||||
@@ -470,8 +470,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
// Scoped so responses whose body is never read (404, errors) are released here instead of by a GC-time abort.
|
||||
return HttpClient.withScope(http)
|
||||
return http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
@@ -499,7 +498,6 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
}),
|
||||
),
|
||||
Effect.scoped,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ type Input = {
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
readonly providerMetadataKey: string
|
||||
readonly snapshot?: Snapshot.ID
|
||||
/** The start snapshot, awaited before `Step.Started` so its capture can overlap the provider request. */
|
||||
readonly pendingSnapshot?: Effect.Effect<Snapshot.ID | undefined>
|
||||
readonly started: number
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
}
|
||||
@@ -98,6 +99,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
let stepSettlement: StepRecord["finish"]
|
||||
|
||||
const startAssistant = Effect.fnUntraced(function* () {
|
||||
if (stepStarted) return assistantMessageID
|
||||
const snapshot = input.pendingSnapshot ? yield* input.pendingSnapshot : undefined
|
||||
// Check again after the await, so the check and the mark below never straddle a yield.
|
||||
if (stepStarted) return assistantMessageID
|
||||
stepStarted = true
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
@@ -105,7 +109,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
assistantMessageID,
|
||||
snapshot: input.snapshot,
|
||||
snapshot,
|
||||
started: input.started,
|
||||
})
|
||||
return assistantMessageID
|
||||
|
||||
@@ -70,14 +70,16 @@ export const make = Effect.gen(function* () {
|
||||
const toolOutput = yield* ToolOutput.Service
|
||||
|
||||
const attempt = Effect.fn("SessionStep.attempt")(function* (input: Input) {
|
||||
const startSnapshot = yield* snapshots.capture()
|
||||
// The start snapshot only has to exist before local tools run, which cannot happen before Step.Started,
|
||||
// so it is captured while the provider request is in flight instead of delaying it.
|
||||
const pendingStartSnapshot = yield* snapshots.capture().pipe(Effect.forkScoped)
|
||||
const publisher = createLLMEventPublisher(bus, {
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID: input.assistantMessageID,
|
||||
agent: input.agent,
|
||||
model: input.model.ref,
|
||||
providerMetadataKey: input.model.model.route.providerMetadataKey ?? input.model.model.provider,
|
||||
snapshot: startSnapshot,
|
||||
pendingSnapshot: Fiber.join(pendingStartSnapshot),
|
||||
started: yield* Clock.currentTimeMillis,
|
||||
})
|
||||
const toolRuns: Array<{
|
||||
@@ -105,6 +107,8 @@ export const make = Effect.gen(function* () {
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
// Wait here, where cancellation still works, rather than inside the uninterruptible publish.
|
||||
if (!publisher.hasStarted()) yield* Fiber.join(pendingStartSnapshot)
|
||||
if (
|
||||
LLMEvent.is.providerError(event) &&
|
||||
isContextOverflowFailure(event) &&
|
||||
@@ -138,6 +142,9 @@ export const make = Effect.gen(function* () {
|
||||
const stream = yield* restore(providerStream).pipe(Effect.exit)
|
||||
const streamFailure = Option.getOrUndefined(Exit.findErrorOption(stream))
|
||||
const streamInterrupted = Exit.hasInterrupts(stream)
|
||||
// Cancelled before the start snapshot existed: record nothing, as when the capture preceded the request.
|
||||
if (streamInterrupted && !publisher.hasStarted() && !pendingStartSnapshot.pollUnsafe())
|
||||
return yield* Effect.failCause(stream.cause)
|
||||
if (!overflowFailure && publisher.hasStarted()) yield* publisher.streamed()
|
||||
if (streamInterrupted) yield* interruptTools
|
||||
const joined = yield* restore(Fiber.awaitAll(toolRuns.map((run) => run.fiber))).pipe(Effect.exit)
|
||||
@@ -224,6 +231,7 @@ export const make = Effect.gen(function* () {
|
||||
|
||||
const record = publisher.record()
|
||||
if (record.finish || record.failure) {
|
||||
const startSnapshot = yield* Fiber.join(pendingStartSnapshot)
|
||||
const snapshot = yield* snapshots.capture()
|
||||
const files =
|
||||
startSnapshot && snapshot
|
||||
|
||||
@@ -13,8 +13,7 @@ export function text(message: SessionMessage.Info | undefined) {
|
||||
message.content
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n") || NO_TEXT
|
||||
.join("") || NO_TEXT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -210,10 +210,7 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
catch: (cause) => new Error(`Portable shell scanner failed to load: ${cause}`, { cause }),
|
||||
})
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const name = ShellSelect.name(shell)
|
||||
const result = powershell
|
||||
? ShellScan.scanPowerShell(command)
|
||||
: ShellScan.scan(command, name === "bash" || name === "zsh" ? name : "posix")
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque")
|
||||
return yield* Effect.fail(new Error(`Portable shell scanner cannot analyze command: ${result.reason}`))
|
||||
|
||||
@@ -228,21 +225,15 @@ export const scanPortable = Effect.fnUntraced(function* (command: string, shell:
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(
|
||||
...directoryArgs(
|
||||
powershell
|
||||
? words.flatMap((text): Part[] => {
|
||||
const parameter = /^(-(?:literalpath|path)):(.*)$/i.exec(text)
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
})
|
||||
: item.rawWords.map((text, index) => ({
|
||||
type: "word",
|
||||
// Only literal quoting resolves to a static directory.
|
||||
text: text.startsWith("$'") || (!/[$`~\\]/.test(text) && /['"]/.test(text)) ? item.words[index] : text,
|
||||
})),
|
||||
words.flatMap((text): Part[] => {
|
||||
const parameter = powershell ? /^(-(?:literalpath|path)):(.*)$/i.exec(text) : undefined
|
||||
if (parameter)
|
||||
return [
|
||||
{ type: "command_parameter", text: parameter[1] },
|
||||
{ type: "word", text: parameter[2] },
|
||||
]
|
||||
return [{ type: powershell && text.startsWith("-") ? "command_parameter" : "word", text }]
|
||||
}),
|
||||
powershell,
|
||||
cwd,
|
||||
shell,
|
||||
|
||||
+744
-1553
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,7 @@ export * as Snapshot from "./snapshot.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import path from "path"
|
||||
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { Clock, Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
@@ -106,26 +106,37 @@ const layer = Layer.effect(
|
||||
const repository = repositoryFiber.pipe(Effect.uninterruptible, Effect.flatMap(Fiber.join))
|
||||
|
||||
const scope = Effect.fnUntraced(function* (worktree: AbsolutePath) {
|
||||
const relative = path.relative(worktree, location.directory)
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative))
|
||||
if (!FSUtil.contains(worktree, location.directory))
|
||||
return yield* new Error({ operation: "capture", message: "Location is outside the project" })
|
||||
return RelativePath.make(relative.replaceAll("\\", "/") || ".")
|
||||
return RelativePath.make(path.relative(worktree, location.directory).replaceAll("\\", "/") || ".")
|
||||
})
|
||||
|
||||
const enabled = () => location.vcs?.type === "git" && state.get().enabled
|
||||
|
||||
// `objects.pack` takes a cross-process lock, so a run that outlasts the interval never overlaps the next.
|
||||
let lastPackCheck = Number.NEGATIVE_INFINITY
|
||||
const packWhenDue = Effect.fnUntraced(function* (repository: Git.Repository) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (now - lastPackCheck < 10 * 60 * 1000) return
|
||||
lastPackCheck = now
|
||||
yield* git.objects.pack(repository).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("failed to pack snapshot objects", { cause })),
|
||||
Effect.forkIn(lifetime),
|
||||
)
|
||||
})
|
||||
|
||||
const capture = Effect.fn("Snapshot.capture")(function* () {
|
||||
if (!enabled()) return undefined
|
||||
return yield* Effect.gen(function* () {
|
||||
const repo = yield* repository
|
||||
return ID.make(
|
||||
yield* git.tree.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: [yield* scope(repo.worktree)],
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
}),
|
||||
)
|
||||
const tree = yield* git.tree.capture({
|
||||
repository: repo.snapshotRepository,
|
||||
scopes: [yield* scope(repo.worktree)],
|
||||
ignores: repo.source,
|
||||
maximumUntrackedFileBytes: 2 * 1024 * 1024,
|
||||
})
|
||||
yield* packWhenDue(repo.snapshotRepository)
|
||||
return ID.make(tree)
|
||||
}).pipe(
|
||||
Effect.catch((cause) => Effect.logWarning("failed to capture snapshot", { cause }).pipe(Effect.as(undefined))),
|
||||
)
|
||||
|
||||
@@ -575,7 +575,6 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/zai-coding-plan/responses": openaiResponses,
|
||||
|
||||
"@opencode/ai/providers/openai-compatible": openaiCompatible,
|
||||
"@opencode/ai/providers/azure/chat": openaiChat,
|
||||
"@opencode/ai/providers/google-vertex/chat": openaiChat,
|
||||
"@opencode/ai/providers/alibaba/chat": alibabaChat,
|
||||
"@opencode/ai/providers/baseten": basetenChat,
|
||||
|
||||
@@ -203,8 +203,6 @@ 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")
|
||||
|
||||
@@ -224,9 +224,6 @@ describe("AISDKNative", () => {
|
||||
const chat = map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")
|
||||
expect(chat?.package).toBe("@opencode/ai/providers/azure/chat")
|
||||
expect(chat?.settings).not.toHaveProperty("useCompletionUrls")
|
||||
expect(AISDKNative.native("@ai-sdk/azure", { providerID: "azure", shape: "completions" })).toBe(
|
||||
"@opencode/ai/providers/azure/chat",
|
||||
)
|
||||
})
|
||||
|
||||
test("maps Bedrock provider and request options", () => {
|
||||
|
||||
@@ -553,16 +553,6 @@ describe("Provider and Model", () => {
|
||||
editor.models.update(providerID, Model.ID.make("new"), (model) => {
|
||||
model.time.released = 2000
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("newest-video"), (model) => {
|
||||
model.capabilities.input = ["text", "image"]
|
||||
model.capabilities.output = ["video"]
|
||||
model.time.released = 3000
|
||||
})
|
||||
editor.models.update(providerID, Model.ID.make("newest-stt"), (model) => {
|
||||
model.capabilities.input = ["audio"]
|
||||
model.capabilities.output = ["text"]
|
||||
model.time.released = 4000
|
||||
})
|
||||
})
|
||||
|
||||
expect((yield* models.default())?.id).toMatch("new")
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
@@ -296,3 +296,214 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Git objects", () => {
|
||||
it.live(
|
||||
"packs loose objects and combines packs without losing or delegating any object",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = path.join(root.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await initRepo(project)
|
||||
await Bun.write(path.join(project, "seed.txt"), "seed\n")
|
||||
await $`git add . && git commit -q -m initial`.cwd(project).quiet()
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(project))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const repository = yield* git.repo.create({
|
||||
worktree: source.worktree,
|
||||
gitDirectory: AbsolutePath.make(path.join(root.path, "storage")),
|
||||
seed: source,
|
||||
})
|
||||
const capture = () => git.tree.capture({ repository, scopes: [RelativePath.make(".")], ignores: source })
|
||||
const trees = [yield* capture()]
|
||||
// Enough distinct content to cross the loose-object threshold.
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
Array.from({ length: 2100 }, (_, index) =>
|
||||
Bun.write(path.join(project, `gen/d${index % 20}/f${index}.txt`), `${index}\n`),
|
||||
),
|
||||
),
|
||||
)
|
||||
trees.push(yield* capture())
|
||||
const objects = path.join(repository.gitDirectory, "objects")
|
||||
const alternates = path.join(objects, "info", "alternates")
|
||||
// Objects stored in the snapshot repository itself, excluding anything borrowed through alternates.
|
||||
const everything = () =>
|
||||
Effect.promise(async () => {
|
||||
const borrowed = await fs.readFile(alternates, "utf8")
|
||||
await fs.rm(alternates)
|
||||
const listed =
|
||||
await $`git --git-dir ${repository.gitDirectory} cat-file --batch-all-objects ${"--batch-check=%(objectname)"}`.text()
|
||||
await fs.writeFile(alternates, borrowed)
|
||||
return listed.split("\n").filter(Boolean).toSorted()
|
||||
})
|
||||
const loose = () => Effect.promise(() => looseObjectIDs(objects))
|
||||
const before = yield* everything()
|
||||
expect((yield* loose()).length).toBeGreaterThan(2048)
|
||||
|
||||
yield* git.objects.pack(repository)
|
||||
expect(yield* loose()).toEqual([])
|
||||
expect(yield* everything()).toEqual(before)
|
||||
|
||||
for (let batch = 0; batch < 16; batch++) {
|
||||
yield* Effect.promise(() => Bun.write(path.join(project, `batch-${batch}.txt`), `${batch}\n`))
|
||||
trees.push(yield* capture())
|
||||
yield* Effect.promise(async () => {
|
||||
const list = await looseObjectIDs(objects)
|
||||
await $`git --git-dir ${repository.gitDirectory} pack-objects -q ${path.join(objects, "pack", "pack")} < ${Buffer.from(list.join("\n") + "\n")}`.quiet()
|
||||
await $`git --git-dir ${repository.gitDirectory} prune-packed`.quiet()
|
||||
})
|
||||
}
|
||||
const packs = () =>
|
||||
Effect.promise(async () =>
|
||||
(await fs.readdir(path.join(objects, "pack"))).filter((file) => file.endsWith(".pack")),
|
||||
)
|
||||
expect((yield* packs()).length).toBeGreaterThanOrEqual(16)
|
||||
const merged = yield* everything()
|
||||
|
||||
yield* git.objects.pack(repository)
|
||||
expect((yield* packs()).length).toBe(1)
|
||||
expect(yield* everything()).toEqual(merged)
|
||||
|
||||
expect((yield* git.tree.files({ repository, from: trees[0]!, to: trees.at(-1)! })).length).toBe(2100 + 16)
|
||||
}),
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Git capture", () => {
|
||||
it.live("applies both the store's own and the source's ignore rules and never splits the index", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = path.join(root.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await initRepo(project)
|
||||
await Bun.write(path.join(project, "tracked.bin"), "small\n")
|
||||
await $`git add . && git commit -q -m initial`.cwd(project).quiet()
|
||||
await Bun.write(path.join(project, ".git", "info", "exclude"), "source-ignored/\n")
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(project))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const storage = AbsolutePath.make(path.join(root.path, "storage"))
|
||||
const repository = yield* git.repo.create({ worktree: source.worktree, gitDirectory: storage, seed: source })
|
||||
yield* Effect.promise(async () => {
|
||||
// Rules an init template left in the store keep applying, and a split index would lose entries.
|
||||
await Bun.write(path.join(storage, "info", "exclude"), "store-ignored/\n")
|
||||
await $`git --git-dir ${storage} config core.splitIndex true`.quiet()
|
||||
})
|
||||
const capture = () => git.tree.capture({ repository, scopes: [RelativePath.make(".")], ignores: source })
|
||||
const before = yield* capture()
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(project, "tracked.bin"), "changed\n")
|
||||
await Bun.write(path.join(project, "store-ignored", "a.txt"), "a\n")
|
||||
await Bun.write(path.join(project, "source-ignored", "b.txt"), "b\n")
|
||||
await Bun.write(path.join(project, "kept.txt"), "kept\n")
|
||||
})
|
||||
const after = yield* capture()
|
||||
expect(yield* git.tree.files({ repository, from: before, to: after })).toEqual([
|
||||
RelativePath.make("kept.txt"),
|
||||
RelativePath.make("tracked.bin"),
|
||||
])
|
||||
expect(yield* capture()).toBe(after)
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(storage, "info", "exclude"), "utf8"))).toStartWith(
|
||||
"store-ignored/\n",
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Git capture recovery", () => {
|
||||
const setup = Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
const project = path.join(root.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await initRepo(project)
|
||||
await Bun.write(path.join(project, "a.txt"), "a\n")
|
||||
await Bun.write(path.join(project, "b.txt"), "b\n")
|
||||
await $`git add . && git commit -q -m initial`.cwd(project).quiet()
|
||||
})
|
||||
const git = yield* Git.Service
|
||||
const source = yield* git.repo.discover(AbsolutePath.make(project))
|
||||
if (!source) throw new Error("Repository not found")
|
||||
const repository = yield* git.repo.create({
|
||||
worktree: source.worktree,
|
||||
gitDirectory: AbsolutePath.make(path.join(root.path, "storage")),
|
||||
seed: source,
|
||||
})
|
||||
const capture = () => git.tree.capture({ repository, scopes: [RelativePath.make(".")], ignores: source })
|
||||
return { root, project, repository, capture }
|
||||
})
|
||||
|
||||
it.live("sweeps abandoned temporary indexes by their creation time, not their mtime", () =>
|
||||
Effect.gen(function* () {
|
||||
const { repository, capture } = yield* setup
|
||||
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000
|
||||
const live = path.join(repository.gitDirectory, `index.opencode-${Date.now()}-1-live`)
|
||||
const abandoned = path.join(repository.gitDirectory, `index.opencode-${twoHoursAgo}-1-abandoned`)
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(live, "")
|
||||
await Bun.write(abandoned, "")
|
||||
// A hard link to an index nobody wrote for hours carries that old mtime while in use.
|
||||
await fs.utimes(live, new Date(twoHoursAgo), new Date(twoHoursAgo))
|
||||
})
|
||||
yield* capture()
|
||||
expect(yield* Effect.promise(() => Bun.file(live).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(abandoned).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("records a tracked file replaced by an embedded repository as a gitlink", () =>
|
||||
Effect.gen(function* () {
|
||||
const { project, repository, capture } = yield* setup
|
||||
yield* capture()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.rm(path.join(project, "a.txt"))
|
||||
await Bun.write(path.join(project, "a.txt", "inner.txt"), "inner\n")
|
||||
await initRepo(path.join(project, "a.txt"))
|
||||
await $`git add . && git commit -q -m inner`.cwd(path.join(project, "a.txt")).quiet()
|
||||
})
|
||||
const tree = yield* capture()
|
||||
const entry = yield* Effect.promise(() =>
|
||||
$`git --git-dir ${repository.gitDirectory} ls-tree ${tree} a.txt`.text(),
|
||||
)
|
||||
expect(entry).toStartWith("160000 commit ")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("keeps the index when Git cannot even start", () =>
|
||||
Effect.gen(function* () {
|
||||
const { root, project, repository, capture } = yield* setup
|
||||
const before = yield* capture()
|
||||
const moved = path.join(root.path, "moved")
|
||||
yield* Effect.promise(() => fs.rename(project, moved))
|
||||
expect(Exit.isFailure(yield* capture().pipe(Effect.exit))).toBe(true)
|
||||
yield* Effect.promise(() => fs.rename(moved, project))
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(repository.gitDirectory, "index")).exists())).toBe(true)
|
||||
expect(yield* capture()).toBe(before)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
async function looseObjectIDs(objects: string) {
|
||||
const prefixes = (await fs.readdir(objects)).filter((entry) => /^[0-9a-f]{2}$/.test(entry))
|
||||
const listed = await Promise.all(
|
||||
prefixes.map(async (prefix) => (await fs.readdir(path.join(objects, prefix))).map((entry) => prefix + entry)),
|
||||
)
|
||||
return listed.flat()
|
||||
}
|
||||
@@ -364,53 +364,6 @@ describe("ConfigInstructionPlugin.Plugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// A client CWD can spell the drive `c:` while git reports the project root as `C:`.
|
||||
;(process.platform === "win32" ? it.live : it.live.skip)(
|
||||
"stops at the project root when the location drive letter differs in case",
|
||||
() =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) => {
|
||||
const global = path.join(tmp.path, "global")
|
||||
const home = path.join(tmp.path, "home")
|
||||
const project = path.join(tmp.path, "repo")
|
||||
const directory = project[0].toLowerCase() + project.slice(1)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.promise(() => fs.mkdir(project, { recursive: true }))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(project, "AGENTS.md"), "project"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "AGENTS.md"), "outside"))
|
||||
const discovery = yield* start()
|
||||
const watcher = yield* Watcher.Test
|
||||
expect(yield* watcher.subscriptions()).toEqual([
|
||||
{ path: path.join(global, "AGENTS.md"), type: "file" },
|
||||
{ path: path.join(directory, "AGENTS.md"), type: "file" },
|
||||
])
|
||||
expect((yield* readInitial(yield* discovery.load())).text).toBe(
|
||||
`Instructions from: ${path.join(directory, "AGENTS.md")}\nproject`,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
instructionLayer({
|
||||
config: global,
|
||||
home,
|
||||
locationServiceLayer: Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(
|
||||
location(
|
||||
{ directory: AbsolutePath.make(directory) },
|
||||
{ projectDirectory: AbsolutePath.make(project) },
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("isolates source failure without failing activation", () => {
|
||||
const failingFS = Layer.effect(
|
||||
FSUtil.Service,
|
||||
|
||||
@@ -145,7 +145,6 @@ describe("MCP OAuth", () => {
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", state)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(200)
|
||||
expect((yield* Effect.promise(() => fetch(redirect))).status).toBe(409)
|
||||
return yield* authorization.callback
|
||||
}),
|
||||
),
|
||||
@@ -160,40 +159,6 @@ describe("MCP OAuth", () => {
|
||||
expect(tokenRequests[0]?.get("resource")).toBe(server.url.href)
|
||||
})
|
||||
|
||||
test("reports a rejected token exchange on the callback page", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
if (request.method !== "POST" || new URL(request.url).pathname !== "/token")
|
||||
return new Response(null, { status: 404 })
|
||||
return Response.json(
|
||||
{ error: "invalid_client", error_description: "bad_client_secret" },
|
||||
{ status: 400 },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const { authorization, url: authorizationUrl } = yield* start(server, { client_id: "client" })
|
||||
const redirect = new URL(authorizationUrl.searchParams.get("redirect_uri") ?? "")
|
||||
redirect.searchParams.set("code", "accepted")
|
||||
redirect.searchParams.set("state", authorizationUrl.searchParams.get("state") ?? "")
|
||||
const response = yield* Effect.promise(() => fetch(redirect))
|
||||
const page = yield* Effect.promise(() => response.text())
|
||||
const exit = yield* Effect.exit(authorization.callback)
|
||||
return { status: response.status, page, exit }
|
||||
}),
|
||||
),
|
||||
).finally(() => server.stop(true))
|
||||
|
||||
expect(result.status).toBe(400)
|
||||
expect(result.page).toContain("Authorization failed")
|
||||
expect(result.page).toContain("bad_client_secret")
|
||||
expect(result.exit._tag).toBe("Failure")
|
||||
})
|
||||
|
||||
test("refreshes tokens loaded from a persisted credential", async () => {
|
||||
const tokenRequests: URLSearchParams[] = []
|
||||
const server = Bun.serve({
|
||||
|
||||
@@ -255,23 +255,6 @@ describe("ModelsDev Service", () => {
|
||||
api: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1",
|
||||
models: {},
|
||||
},
|
||||
azure: {
|
||||
id: "azure",
|
||||
name: "Azure",
|
||||
env: ["AZURE_RESOURCE_NAME", "AZURE_API_KEY"],
|
||||
npm: "@ai-sdk/azure",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
...fixture.acme.models["acme-1"],
|
||||
id: "gpt-5",
|
||||
},
|
||||
"custom-chat": {
|
||||
...fixture.acme.models["acme-1"],
|
||||
id: "custom-chat",
|
||||
provider: { shape: "completions" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
const state = yield* Ref.make(initialState)
|
||||
const result = yield* provided(
|
||||
@@ -283,11 +266,6 @@ describe("ModelsDev Service", () => {
|
||||
expect(result[0]?.models[0]?.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(result[1]?.info.package).toBe("@opencode/ai/providers/cloudflare-workers-ai")
|
||||
expect(result[1]?.info.settings).toBeUndefined()
|
||||
expect(result[2]?.info.package).toBe("@opencode/ai/providers/azure/responses")
|
||||
expect(result[2]?.models.find((model) => model.id === "gpt-5")?.package).toBeUndefined()
|
||||
expect(result[2]?.models.find((model) => model.id === "custom-chat")?.package).toBe(
|
||||
"@opencode/ai/providers/azure/chat",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -624,7 +624,7 @@ describe("ModelsDevPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits deprecated and non-text model definitions", () =>
|
||||
it.effect("omits deprecated model definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
const providers = yield* Provider.Service
|
||||
@@ -632,13 +632,11 @@ describe("ModelsDevPlugin", () => {
|
||||
const providerID = Provider.ID.make("acme")
|
||||
const activeID = Model.ID.make("current")
|
||||
const deprecatedID = Model.ID.make("legacy")
|
||||
const videoID = Model.ID.make("video-gen")
|
||||
const sttID = Model.ID.make("transcribe")
|
||||
const model = {
|
||||
modelID: activeID,
|
||||
providerID,
|
||||
name: "Current",
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [],
|
||||
@@ -664,21 +662,6 @@ describe("ModelsDevPlugin", () => {
|
||||
name: "Legacy",
|
||||
status: "deprecated" as const,
|
||||
},
|
||||
{
|
||||
id: videoID,
|
||||
...model,
|
||||
modelID: videoID,
|
||||
name: "Video Gen",
|
||||
capabilities: { tools: false, input: ["text", "image"], output: ["video"] },
|
||||
limit: { context: 1_024, output: 0 },
|
||||
},
|
||||
{
|
||||
id: sttID,
|
||||
...model,
|
||||
modelID: sttID,
|
||||
name: "Transcribe",
|
||||
capabilities: { tools: false, input: ["audio"], output: ["text"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies readonly ModelsDev.Snapshot[]
|
||||
@@ -701,8 +684,6 @@ describe("ModelsDevPlugin", () => {
|
||||
yield* activate(providers)
|
||||
expect(yield* modelState.get(providerID, activeID)).toBeDefined()
|
||||
expect(yield* modelState.get(providerID, deprecatedID)).toBeUndefined()
|
||||
expect(yield* modelState.get(providerID, videoID)).toBeUndefined()
|
||||
expect(yield* modelState.get(providerID, sttID)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
import { expect } from "bun:test"
|
||||
import { cp } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Brand, Cause, Deferred, Effect, Exit, Fiber, Layer, Option, Schedule, Schema, Scope, Stream } from "effect"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schedule, Scope, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Watcher } from "@opencode/core/filesystem/watcher"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginModule } from "@opencode/core/plugin/module"
|
||||
import { Rpc } from "@opencode/core/rpc"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { execute } from "@opencode/core/tool/runtime"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import {
|
||||
createForeignPackageFilter,
|
||||
createLoader,
|
||||
discoverPluginRuntimeSpecifiers,
|
||||
ensurePluginRuntime,
|
||||
pluginRuntimeLoaderCode,
|
||||
} from "../../../cli/src/plugin-runtime"
|
||||
import { tempGlobalLayer } from "../fixture/global"
|
||||
import { tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
ensurePluginRuntime()
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
PluginTestLayer,
|
||||
Layer.merge(
|
||||
AppNodeBuilder.build(Npm.node, [Global.node.replace(tempGlobalLayer)]),
|
||||
Watcher.layer().pipe(Layer.provide(Watcher.nativeLayer)),
|
||||
),
|
||||
@@ -109,294 +90,3 @@ it.live("interrupts pending watcher setup when the loader scope closes during mo
|
||||
yield* Effect.promise(() => Bun.sleep(50))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("loads plugins and their transitive dependencies against the host's Effect and @opencode/plugin instances", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "plugin")
|
||||
const pluginEffectDir = path.join(pluginDir, "node_modules/effect")
|
||||
const hostEffectDir = path.dirname(Bun.resolveSync("effect/package.json", import.meta.dir))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
await cp(path.join(hostEffectDir, "dist"), path.join(pluginEffectDir, "dist"), {
|
||||
recursive: true,
|
||||
filter: (src) => !src.endsWith(".d.ts") && !src.endsWith(".map") && !/httpApi(?:Scalar|Swagger)\.js$/.test(src),
|
||||
})
|
||||
const pkg = { ...(await Bun.file(path.join(hostEffectDir, "package.json")).json()), version: "4.0.0-rc.111" }
|
||||
|
||||
// Sabotage the plugin's own Effect copy with the version-skew failure modes so loading it would crash:
|
||||
// 1. Effect.log reading an incompatible fiber log-level property (crashing host logger with logLevel.toUpperCase)
|
||||
// 2. Effect.runPromise calling fiber.succeedWith on a host fiber
|
||||
// 3. Schema.withDecodingDefault / Schema.Int / Schema.isPattern / Schema.Trim using foreign parser sentinels
|
||||
const internalEffectPath = path.join(pluginEffectDir, "dist/internal/effect.js")
|
||||
const originalInternalEffect = await Bun.file(internalEffectPath).text()
|
||||
const logPattern =
|
||||
/const logLevel = level \?\? fiber\.(?:currentLogLevel|cache\.logLevel);\r?\n\s*if \(isLogLevelGreaterThan\(fiber\.(?:minimumLogLevel|cache\.minimumLogLevel), logLevel\)\) \{/
|
||||
const runPromisePattern = /const runPromiseExit = runPromiseExitWith\(context\);/
|
||||
expect(logPattern.test(originalInternalEffect)).toBe(true)
|
||||
expect(runPromisePattern.test(originalInternalEffect)).toBe(true)
|
||||
|
||||
await writeFiles(pluginDir, {
|
||||
"node_modules/effect/package.json": JSON.stringify(pkg),
|
||||
"node_modules/effect/dist/internal/effect.js": originalInternalEffect
|
||||
.replace(
|
||||
logPattern,
|
||||
"const logLevel = level ?? fiber.foreignSkew?.logLevel;\n if (isLogLevelGreaterThan(fiber.foreignSkew?.minimumLogLevel, logLevel)) {",
|
||||
)
|
||||
.replace(
|
||||
runPromisePattern,
|
||||
"if (true) return (effect) => Promise.resolve().then(() => { const fiber = {}; return fiber.succeedWith(effect); });\n const runPromiseExit = runPromiseExitWith(context);",
|
||||
),
|
||||
"node_modules/transitive-dep/package.json":
|
||||
'{"name":"transitive-dep","type":"module","exports":{".":"./index.js"}}',
|
||||
"node_modules/transitive-dep/index.js": `import { Effect, Schema } from "effect"
|
||||
import { some } from "effect/Option"
|
||||
export const depToolInput = Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("from-dep"))),
|
||||
count: Schema.Int,
|
||||
code: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
|
||||
})
|
||||
export const depCaptured = { Effect, Schema, some }`,
|
||||
"node_modules/@opencode/plugin/package.json":
|
||||
'{"name":"@opencode/plugin","type":"module","exports":{"./effect":"./effect.js","./rpc":"./rpc.js"}}',
|
||||
"node_modules/@opencode/plugin/effect.js": "export const Plugin = { define: (p) => p }",
|
||||
"node_modules/@opencode/plugin/rpc.js": "export const Rpc = { define: (d) => d }",
|
||||
"index.ts": `import { Plugin } from "@opencode/plugin/effect"
|
||||
import { Rpc } from "@opencode/plugin/rpc"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { some } from "effect/Option"
|
||||
import { nominal } from "effect/Brand"
|
||||
import { depCaptured, depToolInput } from "transitive-dep"
|
||||
|
||||
export const captured = {
|
||||
plugin: { Effect, Schema, some, nominal },
|
||||
dep: depCaptured,
|
||||
pluginCount: -1,
|
||||
}
|
||||
|
||||
const Contract = Rpc.define({
|
||||
id: "host-effect-rpc",
|
||||
methods: {
|
||||
check: {
|
||||
input: Schema.Struct({
|
||||
count: Schema.Int.pipe(Schema.withDecodingDefault(Effect.succeed(5))),
|
||||
tag: Schema.Trim.check(Schema.isPattern(/^v[0-9]+$/)),
|
||||
}),
|
||||
output: Schema.Struct({ value: Schema.String }),
|
||||
},
|
||||
},
|
||||
events: {},
|
||||
})
|
||||
|
||||
export default Plugin.define({
|
||||
id: "host-effect-fixture",
|
||||
effect: (ctx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.log("setup log from plugin")
|
||||
const listed = yield* Effect.promise(() =>
|
||||
Effect.runPromise(ctx.plugin.list().pipe(Effect.orDie)),
|
||||
)
|
||||
captured.pluginCount = listed.data.length
|
||||
|
||||
yield* ctx.tool.transform((editor) => {
|
||||
editor.add({
|
||||
name: "check_tool",
|
||||
description: "Tool with decoding default from transitive dependency and Int/Trim/isPattern checks",
|
||||
input: depToolInput,
|
||||
output: Schema.Struct({ formatted: Schema.String }),
|
||||
execute: ({ mode, count, code }) =>
|
||||
Effect.log("executing check_tool").pipe(
|
||||
Effect.as({
|
||||
output: { formatted: \`\${mode}:\${code}:\${count}\` },
|
||||
content: \`\${mode}:\${code}:\${count}\`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
yield* ctx.rpc.register(Contract, {
|
||||
check: ({ count, tag }) =>
|
||||
Effect.log("executing rpc check").pipe(
|
||||
Effect.as({ value: \`\${tag}#\${count}\` }),
|
||||
),
|
||||
}).pipe(Effect.orDie)
|
||||
}),
|
||||
})`,
|
||||
})
|
||||
})
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
const plugins = yield* Plugin.Service
|
||||
const tools = yield* Tool.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
|
||||
const definition = yield* modules.load({ type: "add", target: pluginDir, options: {} })
|
||||
if ("pending" in definition) return yield* Effect.die(new Error("Local plugin was not loaded"))
|
||||
yield* plugins.activate([definition])
|
||||
yield* plugins.awaitActivation
|
||||
|
||||
expect(yield* plugins.list()).toMatchObject([{ id: "host-effect-fixture", state: { status: "active" } }])
|
||||
|
||||
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
|
||||
expect(imported.captured.plugin.Effect).toBe(Effect)
|
||||
expect(imported.captured.plugin.Schema).toBe(Schema)
|
||||
expect(imported.captured.plugin.some).toBe(Option.some)
|
||||
expect(imported.captured.plugin.nominal).toBe(Brand.nominal)
|
||||
expect(imported.captured.dep.Effect).toBe(Effect)
|
||||
expect(imported.captured.dep.Schema).toBe(Schema)
|
||||
expect(imported.captured.dep.some).toBe(Option.some)
|
||||
expect(imported.captured.pluginCount).toBe(0)
|
||||
|
||||
const checkTool = (yield* tools.list()).find((tool) => tool.id === "check_tool")
|
||||
expect(checkTool).toBeDefined()
|
||||
if (!checkTool) return
|
||||
|
||||
const context = {
|
||||
sessionID: Session.ID.make("ses_host_effect"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_host_effect"),
|
||||
id: Tool.CallID.make("call_host_effect"),
|
||||
progress: () => Effect.void,
|
||||
}
|
||||
|
||||
expect(yield* execute(checkTool, { count: 3, code: " v42 " }, context)).toEqual({
|
||||
output: { formatted: "from-dep:v42:3" },
|
||||
content: [{ type: "text", text: "from-dep:v42:3" }],
|
||||
})
|
||||
expect(yield* rpc.call("host-effect-rpc", "check", { tag: " v9 " })).toEqual({ value: "v9#5" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("redirects plugin dependencies with a nested Effect 3 installation to the host, allows effect/package.json, and fails loudly on unprovided subpaths", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const pluginDir = path.join(directory.path, "v3-dep-plugin")
|
||||
const badDir = path.join(directory.path, "removed-subpath-plugin")
|
||||
const v3DepDir = path.join(pluginDir, "node_modules/v3-dep")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(directory.path, {
|
||||
"v3-dep-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"4.0.0-rc.111","type":"module","exports":{"./package.json":"./package.json"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":"./index.js","./Option":"./Option.js","./ReadonlyArray":"./ReadonlyArray.js"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/index.js":
|
||||
"export const Effect = { major: 3 }; export const Schema = { major: 3 }",
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/Option.js": "export const some = () => ({ major: 3 })",
|
||||
"v3-dep-plugin/node_modules/v3-dep/node_modules/effect/ReadonlyArray.js": "export const fromIterable = () => []",
|
||||
"v3-dep-plugin/node_modules/v3-dep/package.json":
|
||||
'{"name":"v3-dep","type":"module","exports":{".":"./index.js","./v3-only":"./v3-only.js"}}',
|
||||
"v3-dep-plugin/node_modules/v3-dep/index.js":
|
||||
'import { Effect, Schema } from "effect"; import { some } from "effect/Option"; export const v3DepCaptured = { Effect, Schema, some }',
|
||||
"v3-dep-plugin/node_modules/v3-dep/v3-only.js":
|
||||
'import { fromIterable } from "effect/ReadonlyArray"; export { fromIterable }',
|
||||
"v3-dep-plugin/index.ts":
|
||||
'import pkg from "effect/package.json" with { type: "json" }; import { v3DepCaptured } from "v3-dep"; export const effectPkgName = pkg.name; export { v3DepCaptured }; export default { id: "v3-dep-plugin", async setup() {} }',
|
||||
"removed-subpath-plugin/node_modules/effect/package.json":
|
||||
'{"name":"effect","type":"module","exports":{"./RemovedLegacySubpath":"./RemovedLegacySubpath.js"}}',
|
||||
"removed-subpath-plugin/node_modules/effect/RemovedLegacySubpath.js": "export const legacy = true",
|
||||
"removed-subpath-plugin/index.ts":
|
||||
'import { legacy } from "effect/RemovedLegacySubpath"; export default { id: "removed-subpath", async setup() { void legacy } }',
|
||||
}),
|
||||
)
|
||||
|
||||
const modules = yield* PluginModule.make()
|
||||
expect(yield* modules.load({ type: "add", target: pluginDir, options: {} })).toMatchObject({ id: "v3-dep-plugin" })
|
||||
|
||||
const imported = yield* Effect.promise(() => import(path.join(pluginDir, "index.ts")))
|
||||
expect(imported.effectPkgName).toBe("effect")
|
||||
expect(imported.v3DepCaptured.Effect).toBe(Effect)
|
||||
expect(imported.v3DepCaptured.Schema).toBe(Schema)
|
||||
expect(imported.v3DepCaptured.some).toBe(Option.some)
|
||||
yield* Effect.promise(async () => {
|
||||
await expect(import(path.join(v3DepDir, "v3-only.js"))).rejects.toThrow("effect/ReadonlyArray.js")
|
||||
})
|
||||
|
||||
const exit = yield* modules.load({ type: "add", target: badDir, options: {} }).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(String(Cause.squash(exit.cause))).toContain("effect/RemovedLegacySubpath.js")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers exported specifiers from the resolved tree even when dist/ exists, and validates barrel loader routing, async loader dedup, and Windows foreign filters", () =>
|
||||
Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped()
|
||||
const consumerDir = path.join(directory.path, "consumer")
|
||||
const jitFixtureDir = path.join(directory.path, "jit-fixture")
|
||||
const asyncModPath = path.join(directory.path, "async-mod.ts")
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
writeFiles(directory.path, {
|
||||
"consumer/node_modules/@opencode/plugin/package.json": JSON.stringify({
|
||||
name: "@opencode/plugin",
|
||||
type: "module",
|
||||
exports: {
|
||||
".": "./src/promise/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/*": "./src/effect/*.ts",
|
||||
},
|
||||
}),
|
||||
"consumer/node_modules/@opencode/plugin/src/promise/index.ts": "export const root = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/index.ts": "export * as plugin from './plugin.ts'",
|
||||
"consumer/node_modules/@opencode/plugin/src/effect/plugin.ts": "export const leaf = 'src'",
|
||||
"consumer/node_modules/@opencode/plugin/src/unexported.ts": "export const secret = true",
|
||||
"consumer/node_modules/@opencode/plugin/dist/promise/index.js": "export const root = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/index.js": "export const index = 'dist'",
|
||||
"consumer/node_modules/@opencode/plugin/dist/effect/plugin.js": "export const leaf = 'dist'",
|
||||
"jit-fixture/SchemaJITCompiler.js": "export const enable = () => 'fn'",
|
||||
"jit-fixture/enable.js": "export {}",
|
||||
"async-mod.ts": "await new Promise((r) => setTimeout(r, 20)); export const nonce = Math.random()",
|
||||
}),
|
||||
)
|
||||
|
||||
const discovered = discoverPluginRuntimeSpecifiers(consumerDir, ["@opencode/plugin"])
|
||||
expect(discovered.get("@opencode/plugin")?.replaceAll("\\", "/")).toEndWith("src/promise/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect")?.replaceAll("\\", "/")).toEndWith("src/effect/index.ts")
|
||||
expect(discovered.get("@opencode/plugin/effect/plugin")?.replaceAll("\\", "/")).toEndWith("src/effect/plugin.ts")
|
||||
expect(discovered.has("@opencode/plugin/unexported")).toBe(false)
|
||||
|
||||
const hostDiscovered = new Map(discoverPluginRuntimeSpecifiers())
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler", path.join(jitFixtureDir, "SchemaJITCompiler.js"))
|
||||
hostDiscovered.set("effect/schema/SchemaJITCompiler/enable", path.join(jitFixtureDir, "enable.js"))
|
||||
expect(pluginRuntimeLoaderCode("effect/Option", hostDiscovered)).toBe('() => require("effect")["Option"]')
|
||||
expect(pluginRuntimeLoaderCode("effect/testing", hostDiscovered)).toBe('() => require("effect/testing")')
|
||||
expect(pluginRuntimeLoaderCode("effect/unstable/http/MultipartParser/HeadersParser", hostDiscovered)).toBe(
|
||||
'() => require("effect/unstable/http/MultipartParser/HeadersParser")',
|
||||
)
|
||||
expect(pluginRuntimeLoaderCode("effect/schema/SchemaJITCompiler/enable", hostDiscovered)).toBe(
|
||||
'() => require("effect/schema/SchemaJITCompiler/enable")',
|
||||
)
|
||||
|
||||
const loadAsync = createLoader(asyncModPath)
|
||||
const first = loadAsync()
|
||||
const second = loadAsync()
|
||||
expect(first).toBeInstanceOf(Promise)
|
||||
expect(first).toBe(second)
|
||||
const [res1, res2] = yield* Effect.promise(() => Promise.all([first, second]))
|
||||
expect(res1).toBe(res2)
|
||||
|
||||
const winFilter = createForeignPackageFilter([
|
||||
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect",
|
||||
])
|
||||
expect(
|
||||
winFilter.test(
|
||||
"C:\\runner\\_work\\opencode\\node_modules\\.bun\\effect@4.0.0-rc.112\\node_modules\\effect\\dist\\index.js",
|
||||
),
|
||||
).toBe(false)
|
||||
expect(
|
||||
winFilter.test(
|
||||
"C:/runner/_work/opencode/node_modules/.bun/effect@4.0.0-rc.112/node_modules/effect/dist/index.js",
|
||||
),
|
||||
).toBe(false)
|
||||
expect(winFilter.test("C:\\Users\\plugin\\node_modules\\effect\\dist\\index.js")).toBe(true)
|
||||
expect(winFilter.test("C:/Users/plugin/node_modules/effect/dist/index.js")).toBe(true)
|
||||
expect(winFilter.test("C:\\Users\\user\\.bun\\install\\cache\\effect@4.0.1@@@1\\dist\\index.js")).toBe(true)
|
||||
expect(winFilter.test("C:/Users/user/.bun/install/cache/effect@4.0.1@@@1/dist/index.js")).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
async function writeFiles(root: string, files: Record<string, string>) {
|
||||
await Promise.all(Object.entries(files).map(([file, text]) => Bun.write(path.join(root, file), text)))
|
||||
}
|
||||
@@ -1072,30 +1072,6 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("releases unread Console config responses when the fetch completes", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const signals: AbortSignal[] = []
|
||||
const state = { status: 404 }
|
||||
const http = HttpClient.make((request, _url, signal) => {
|
||||
signals.push(signal)
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, new Response("unavailable", state)))
|
||||
})
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: "https://console.test" } }),
|
||||
})
|
||||
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, http))
|
||||
yield* drain
|
||||
state.status = 503
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
|
||||
// An unreleased response is only aborted when garbage collected, which fails outside a request on workerd.
|
||||
expect(signals.map((signal) => signal.aborted)).toEqual([true, true])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
|
||||
@@ -30,7 +30,11 @@ const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
|
||||
|
||||
const capture = (
|
||||
providerMetadataKey = "anthropic",
|
||||
options?: { readonly interruptProgress?: boolean; readonly beforeTextDelta?: Effect.Effect<void> },
|
||||
options?: {
|
||||
readonly interruptProgress?: boolean
|
||||
readonly beforeTextDelta?: Effect.Effect<void>
|
||||
readonly pendingSnapshot?: Effect.Effect<Snapshot.ID | undefined>
|
||||
},
|
||||
) => {
|
||||
const published: Array<{ readonly type: string; readonly data: unknown }> = []
|
||||
const bus: Pick<Bus.Interface, "publish"> = {
|
||||
@@ -60,6 +64,7 @@ const capture = (
|
||||
providerID: Provider.ID.opencode,
|
||||
},
|
||||
providerMetadataKey,
|
||||
pendingSnapshot: options?.pendingSnapshot,
|
||||
started: 0,
|
||||
assistantMessageID: SessionMessage.ID.create(),
|
||||
}),
|
||||
@@ -580,6 +585,20 @@ test("success event data can carry provider-executed result state", () => {
|
||||
expect(decoded.resultState).toMatchObject({ result: { type: "content" } })
|
||||
})
|
||||
|
||||
test("step start waits for the pending start snapshot", async () => {
|
||||
const snapshot = Effect.runSync(Deferred.make<Snapshot.ID | undefined>())
|
||||
const { published, publisher } = capture("anthropic", { pendingSnapshot: Deferred.await(snapshot) })
|
||||
const started = Effect.runFork(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
await Effect.runPromise(Effect.yieldNow)
|
||||
expect(published).toEqual([])
|
||||
|
||||
Effect.runSync(Deferred.succeed(snapshot, Snapshot.ID.make("tree-start")))
|
||||
await Effect.runPromise(Fiber.join(started))
|
||||
expect(published.map((event) => [event.type, (event.data as { snapshot?: string }).snapshot])).toEqual([
|
||||
["session.step.started.1", "tree-start"],
|
||||
])
|
||||
})
|
||||
|
||||
test("step finish records settlement without publishing step ended", async () => {
|
||||
const { published, publisher } = capture()
|
||||
await Effect.runPromise(publisher.publish(LLMEvent.stepStart({ index: 0 })))
|
||||
|
||||
@@ -42,21 +42,6 @@ describe("ShellScan adversarial corpus", () => {
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
// Only shell sinks evaluate subscripts; ordinary arguments, heredoc bodies, and bound data without a sink are safe.
|
||||
test.each([
|
||||
"bun -e 'f(`a[${x}]`)'",
|
||||
"rg 'a[$(x)]'",
|
||||
"cat <<'EOF'\na[$(x)]\nEOF",
|
||||
"echo 'a[$(x)]'",
|
||||
'BODY="- [x] Fix \\`scan.ts\\`"; gh pr create --title "fix" --body "$BODY"',
|
||||
"MSG='[feat] Fix `foo`'; git commit -m \"$MSG\"",
|
||||
"PATTERN='a[${b}]'; rg \"$PATTERN\"",
|
||||
"export REGEX='[0-9]+${foo}'",
|
||||
"cat <<< 'const a = arr[0]; const b = `${a}`'",
|
||||
])("scans subscript-shaped text outside shell sinks: %s", (input) => {
|
||||
expect(ShellScan.scan(input).kind).toBe("scanned")
|
||||
})
|
||||
|
||||
test.each(['printf "unterminated', "printf ok &&", "printf ok >", "echo > >out"])(
|
||||
"keeps structurally uncertain Bash input opaque: %s",
|
||||
(input) => {
|
||||
@@ -64,19 +49,6 @@ describe("ShellScan adversarial corpus", () => {
|
||||
},
|
||||
)
|
||||
|
||||
test.each([
|
||||
["repeated assignment value operators", "x[a]" + "=]".repeat(32_000)],
|
||||
["unclosed assignment subscripts", "a[\n".repeat(21_000)],
|
||||
["case patterns with substitutions", "case x in " + "[$(:)".repeat(10_000)],
|
||||
["nested groups with bracket words", "{ ".repeat(31) + "echo " + "a[] ".repeat(15_000) + "; }".repeat(31)],
|
||||
["continued heredoc lines", "cat <<E\n" + "x\\\n".repeat(20_000) + "E\n"],
|
||||
["heredoc backslash runs", "cat <<E\n" + "\\".repeat(60_000) + "x\nE\n"],
|
||||
])("scans adversarial Bash input in bounded time: %s", (_, input) => {
|
||||
const start = performance.now()
|
||||
ShellScan.scan(input)
|
||||
expect(performance.now() - start).toBeLessThan(500)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['pwsh --command "Remove-Item victim.txt"', ["pwsh"]],
|
||||
["Import-Module ./evil.psm1", ["Import-Module"]],
|
||||
|
||||
@@ -60,41 +60,23 @@ const fixtures = [
|
||||
["! scan_probe", ["scan_probe"]],
|
||||
["time scan_probe", ["time"]],
|
||||
["{fd}>/dev/null scan_probe", ["scan_probe"]],
|
||||
["case $r in a) ls | head;; esac", ["ls", "head"]],
|
||||
["case $r in a) ls && echo;; esac", ["ls", "echo"]],
|
||||
["{ find . -exec echo {} \\; ; }", ["find"]],
|
||||
["{ echo {a,{b,c}}; }", ["echo"]],
|
||||
["if true; then\\\n echo hi; fi", ["true", "echo"]],
|
||||
["for ((i=0; i<2; i++)) do echo hi; done", ["echo"]],
|
||||
['echo "$(case x in @(a)) echo hi;; esac)"', ["echo", "echo"]],
|
||||
["set -- 1; for x do scan_probe; done", ["set", "scan_probe"]],
|
||||
["'q'; x=1 a", ["q", "a"]],
|
||||
["cat <<E; ( true\nscan_ignored\nE\n)", ["cat", "true"]],
|
||||
["cat <<E; f() { true\nscan_ignored\nE\n}; f", ["cat", "true", "f"]],
|
||||
["cat <<E; case x in\nscan_ignored\nE\nx) scan_probe;; esac", ["cat", "scan_probe"]],
|
||||
["time [[ ( -f foo ) ]]", []],
|
||||
["time ! { echo a; echo b; }", ["echo", "echo"]],
|
||||
] as const
|
||||
|
||||
describe("ordinary Bash and Zsh syntax", () => {
|
||||
test.each(fixtures)("extracts actual command nodes: %s", (source, names) => {
|
||||
for (const dialect of ["bash", "zsh"] as const) {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
}
|
||||
const result = ShellScan.scan(source)
|
||||
expect(result.kind).toBe("scanned")
|
||||
if (result.kind !== "scanned") throw new Error(result.reason)
|
||||
expect(result.commands.map((command) => command.words[0])).toEqual([...names])
|
||||
})
|
||||
|
||||
for (const shell of ["bash", "zsh"]) {
|
||||
const executable = Bun.which(shell)
|
||||
for (const [source] of fixtures) {
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement. Bash parses extglob
|
||||
// patterns only when extglob is enabled.
|
||||
// These are Bash spellings; Zsh's fd allocation is a standalone statement.
|
||||
test.skipIf(
|
||||
!executable ||
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))) ||
|
||||
(shell === "bash" && source.includes("@(")),
|
||||
(shell === "zsh" && (source.includes('$"') || source.startsWith("{fd}") || source.includes("$["))),
|
||||
)(`${shell} accepts the source grammar: ${source}`, () => {
|
||||
const result = Bun.spawnSync([
|
||||
executable ?? shell,
|
||||
@@ -150,15 +132,10 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
"cat <<EOF\nunclosed",
|
||||
"echo ${missing",
|
||||
"echo $'missing",
|
||||
"case $r in a) ls |;; esac",
|
||||
])("rejects incomplete syntax: %s", (source) => {
|
||||
expect(ShellScan.scan(source).kind).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans a Zsh brace group closed after a redirect", () => {
|
||||
expect(ShellScan.scan("{ a && >f }")).toMatchObject({ kind: "scanned", commands: [{ words: ["a"] }] })
|
||||
})
|
||||
|
||||
test("preserves raw lexical spelling of ANSI-C and locale quoted words", () => {
|
||||
expect(ShellScan.scan("$'pri\\x6etf' $'line\\n' $\"text\"")).toMatchObject({
|
||||
kind: "scanned",
|
||||
@@ -168,7 +145,7 @@ describe("ordinary Bash and Zsh syntax", () => {
|
||||
|
||||
test.each([
|
||||
["coproc job { scan_probe; }", ["scan_probe"]],
|
||||
["case x in @(one|$(scan_probe))) ;; esac", ["scan_probe"]],
|
||||
["printf '%s' @(one|$(scan_probe))", ["printf", "scan_probe"]],
|
||||
["printf '%s' $((1 + '$(scan_probe)'))", ["printf", "scan_probe"]],
|
||||
["printf '%s' $(((1 + '$(scan_probe)')))", ["printf", "scan_probe"]],
|
||||
['printf %s "${ scan_probe; }"', ["printf", "scan_probe"]],
|
||||
@@ -264,56 +241,3 @@ describe("Bash shared heredoc delimiter grammar", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bash dialects", () => {
|
||||
const heads = (source: string, dialect?: ShellScan.Dialect) => {
|
||||
const result = ShellScan.scan(source, dialect)
|
||||
return result.kind === "scanned" ? result.commands.map((command) => command.words[0]) : result.kind
|
||||
}
|
||||
|
||||
test("posix reports both readings of a double parenthesis", () => {
|
||||
expect(heads("(( x = 1 )); y", "bash")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "zsh")).toEqual(["y"])
|
||||
expect(heads("(( x = 1 )); y", "posix")).toEqual(["x", "y"])
|
||||
expect(heads("(( x = 1 )); y")).toEqual(["x", "y"])
|
||||
expect(heads("(( (1) + (2) ))", "bash")).toEqual([])
|
||||
expect(heads("(( (1) + (2) ))", "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test.each([
|
||||
["true &>/dev/null next", ["true"], "opaque"],
|
||||
["X=$[1 + 2] next", ["next"], "opaque"],
|
||||
] as const)("reads Bash and Zsh syntax precisely where Dash diverges: %s", (source, precise, posix) => {
|
||||
expect(heads(source, "bash")).toEqual([...precise])
|
||||
expect(heads(source, "zsh")).toEqual([...precise])
|
||||
expect(heads(source, "posix")).toEqual(posix)
|
||||
})
|
||||
|
||||
test("reads a reserved word after a redirect as a Zsh keyword", () => {
|
||||
const source = "if true; then >/dev/null fi; next"
|
||||
expect(heads(source, "zsh")).toEqual(["true", "next"])
|
||||
expect(heads(source, "bash")).toBe("opaque")
|
||||
expect(heads(source, "posix")).toBe("opaque")
|
||||
})
|
||||
|
||||
test("scans safe Zsh parameter flags, Zsh repeat loops, and Bash extglob arguments", () => {
|
||||
expect(heads("print -l ${(M)files:#*.ts}", "zsh")).toEqual(["print"])
|
||||
expect(heads("print -l ${(ps:\\n:)text}", "zsh")).toEqual(["print"])
|
||||
expect(heads("print -r -- ${(q-)x} ${(q+)x} ${(on-)x}", "zsh")).toEqual(["print"])
|
||||
expect(heads("repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
|
||||
expect(heads("repeat 3; do echo hi; done", "posix")).toEqual(["echo"])
|
||||
expect(heads("f() repeat 3; do echo hi; done", "zsh")).toEqual(["echo"])
|
||||
expect(heads("ls @(foo|bar)", "bash")).toEqual(["ls"])
|
||||
})
|
||||
|
||||
test.each([
|
||||
["/bin/bash", ["y"]],
|
||||
["/usr/local/bin/zsh", ["y"]],
|
||||
["/bin/sh", ["x = 1", "y"]],
|
||||
["/bin/dash", ["x = 1", "y"]],
|
||||
["/opt/bin/mksh", ["x = 1", "y"]],
|
||||
] as const)("derives the dialect from the shell executable: %s", async (shell, resources) => {
|
||||
const result = await Effect.runPromise(ShellParse.scanPortable("(( x = 1 )); y", shell, "/workspace"))
|
||||
expect(result.commands.map((command) => command.resource)).toEqual([...resources])
|
||||
})
|
||||
})
|
||||
@@ -1,751 +0,0 @@
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { ShellParse } from "../../src/shell/parse.js"
|
||||
import { ShellScan } from "../../src/shell/scan.js"
|
||||
|
||||
const shellCandidates = ["/bin/bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "bash", "zsh", "dash"]
|
||||
const shells = [
|
||||
...new Set(
|
||||
shellCandidates
|
||||
.map((item) => (item.startsWith("/") ? (fs.existsSync(item) ? item : undefined) : Bun.which(item)))
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
),
|
||||
]
|
||||
// Fixtures target specific shells and versions (bash 3.2 and 5.3, zsh, dash). Set SHELL_ORACLE_STRICT=1 on a
|
||||
// machine with all of them to also verify that every fixture still executes somewhere.
|
||||
const strict = process.env.SHELL_ORACLE_STRICT === "1"
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-shell-oracle-"))
|
||||
const bin = path.join(root, "bin")
|
||||
const target = path.join(root, "target")
|
||||
const log = path.join(root, "log")
|
||||
fs.mkdirSync(bin)
|
||||
fs.mkdirSync(target)
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "scan_probe"),
|
||||
'#!/bin/sh\nprintf \'%s\\n\' "scan_probe${1:+ $*}" >> "$SCAN_PROBE_LOG"\n',
|
||||
{
|
||||
mode: 0o755,
|
||||
},
|
||||
)
|
||||
// Dash runs `scan_probe[x y]=1` as the command `scan_probe[x`.
|
||||
fs.copyFileSync(path.join(bin, "scan_probe"), path.join(bin, "scan_probe[x"))
|
||||
// Zsh treats a trailing parenthesized group after an existing file name as glob qualifiers.
|
||||
fs.writeFileSync(path.join(root, "a="), "")
|
||||
|
||||
afterAll(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||
|
||||
function shellFlags(executable: string) {
|
||||
if (executable.endsWith("/bash")) return ["--noprofile", "--norc"]
|
||||
if (executable.endsWith("/zsh")) return ["-f"]
|
||||
return []
|
||||
}
|
||||
|
||||
// Runs source in a real shell and returns the lines it logged.
|
||||
function observe(executable: string, source: string) {
|
||||
fs.writeFileSync(log, "")
|
||||
Bun.spawnSync([executable, ...shellFlags(executable), "-c", source], {
|
||||
cwd: root,
|
||||
env: { PATH: `${bin}:/usr/bin:/bin`, HOME: root, LC_ALL: "C", SCAN_PROBE_LOG: log },
|
||||
timeout: 2_000,
|
||||
})
|
||||
return fs
|
||||
.readFileSync(log, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// Each dialect must report what its shells run; posix, the default, covers every shell.
|
||||
const dialects = {
|
||||
bash: (executable: string) => path.basename(executable) === "bash",
|
||||
zsh: (executable: string) => path.basename(executable) === "zsh",
|
||||
posix: () => true,
|
||||
} satisfies Record<ShellScan.Dialect, (executable: string) => boolean>
|
||||
|
||||
function expectProbesReported(source: string) {
|
||||
const runs = shells.map((executable) => [executable, observe(executable, source)] as const)
|
||||
if (strict)
|
||||
expect(
|
||||
runs.some(([, invocations]) => invocations.length > 0),
|
||||
`Fixture never executed scan_probe in any real shell: ${source}`,
|
||||
).toBe(true)
|
||||
expect(ShellScan.scan(source)).toEqual(ShellScan.scan(source, "posix"))
|
||||
for (const [dialect, runsIn] of Object.entries(dialects)) {
|
||||
const result = ShellScan.scan(source, dialect as ShellScan.Dialect)
|
||||
if (result.kind === "opaque") continue
|
||||
const reported = result.commands.filter((command) => !command.declaration).map((command) => command.words.join(" "))
|
||||
for (const [executable, invocations] of runs.filter(([executable]) => runsIn(executable)))
|
||||
for (const invocation of invocations)
|
||||
expect(reported, `${executable} executed ${invocation} with ${dialect} in: ${source}`).toContain(invocation)
|
||||
}
|
||||
}
|
||||
|
||||
// Each fixture runs scan_probe in at least one real shell, which the scanner must report or reject.
|
||||
const fixtures = [
|
||||
"a=(1)scan_probe",
|
||||
"a=(\n1)scan_probe; ",
|
||||
"a=(1)'scan_probe'",
|
||||
"alias p=scan_probe\np",
|
||||
"command alias p=scan_probe\np",
|
||||
"x='*(e:scan_probe:)'; echo $^~x",
|
||||
"x='$(scan_probe)'; echo \"${\\\n(e)x}\"",
|
||||
"x='$(scan_probe)'; echo ${(j:):e)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${=~x}",
|
||||
"x='*(e:scan_probe:)'; echo $=~x",
|
||||
"x='$(scan_probe)'; echo ${(%e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s.:.e)x}",
|
||||
"x='$(scan_probe)'; echo ${(s(:)e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ej: :)x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(s: :)~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${^~x}",
|
||||
"printf -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); getopts a: x -a 'a[$(scan_probe)]'; echo $((OPTARG))",
|
||||
"command -- declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); unset 'a[b[$\\\n(scan_probe)0]]'",
|
||||
'declare \'a["\\"]"$(scan_probe)0]=1\'',
|
||||
"declare -a 'a=(+ [$(scan_probe)]=1)'",
|
||||
"a=(1); echo ${a[b[\\$(scan_probe)]]}",
|
||||
'a["b[\\$(scan_probe)]"]=1',
|
||||
'b=1; a["b[\\$(scan_probe)1]"]=1',
|
||||
"a=(1); echo $[ ${x:-a[\\$(scan_probe)1]} ]",
|
||||
"a=(1); s=abc; echo ${s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${s:${x:-'a[$(scan_probe)1]'}}",
|
||||
"read x <<< 'a[$(scan_probe f)]'; echo $((x))",
|
||||
"mapfile -t a <<< 'b[$(scan_probe g)]'; echo $((a[0]))",
|
||||
"x=1; echo $(( x[\\$(scan_probe i)] ))",
|
||||
"a=(1); echo ${a[@]:'a[$(scan_probe k)]'}",
|
||||
"builtin -- declare 'a[$(scan_probe)]=1'",
|
||||
"command -p declare 'a[$(scan_probe)]=1'",
|
||||
'echo "${unset:+${x[\'"\']}}"]}} \'$(scan_probe)\' " # "',
|
||||
"echo \"${unset:+${x['}}\"']}}'; scan_probe # \"",
|
||||
"echo \"${x:-$'\\''}\"; scan_probe # '\"",
|
||||
"cat <<E\n${x:-'}'}' $(scan_probe)\nE",
|
||||
'echo "${x:-a\'b}c\'d}"$(scan_probe)"\'"',
|
||||
"echo \"${PATH//:/$'\\n'}\"; scan_probe ok",
|
||||
"echo $(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( $(echo 1) # ))'\n); scan_probe ) # '",
|
||||
"(( : # ))'\n); scan_probe ) # '",
|
||||
"echo $(( 16#ff + 2#1 + $# + ${#x} )); scan_probe ok",
|
||||
"(( scan_probe ))",
|
||||
"(( (scan_probe) & (scan_probe) ))",
|
||||
"(( (scan_probe)\n(scan_probe) ))",
|
||||
"cat <<'}'; {\n:\n}\nscan_probe; cat <<'}'; }\n}",
|
||||
"cat <<'x)'; case x in\nx)\nx) scan_probe; cat <<'x)'\nx)\n;; esac",
|
||||
"cat <<'if'; f()\nif\nif scan_probe; cat <<'if'\nif\ntrue; then :; fi; f",
|
||||
"cat <<E; ( true\nE\nscan_probe g\n)",
|
||||
"cat <<E; { true\nE\nscan_probe g\n}",
|
||||
"cat <<E; f() { true\nE\nscan_probe f\n}; f",
|
||||
"cat <<E; if true\nE\nscan_probe i\nthen :; fi",
|
||||
"cat <<E; echo $(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; echo `true\nscan_probe b\n`\nE\nscan_probe after",
|
||||
"cat <<E; cat <(true\nscan_probe s\n)\nE\nscan_probe after",
|
||||
"cat <<E; x=$(cat <<F\nF\n)\nE\nscan_probe out",
|
||||
"{ cat <<E; }\nscan_probe x\nE\nscan_probe y",
|
||||
"[[ a]]b# ]] && scan_probe",
|
||||
'[[ "a]]"# ]] && scan_probe',
|
||||
"[[ 'a]]'# ]] && scan_probe",
|
||||
"[[ a]]b = a]]b ]] && scan_probe",
|
||||
'{ export X="a"{}# ; scan_probe; }',
|
||||
"{ export X=a}# ; scan_probe; }",
|
||||
"{ case esac in (esac) scan_probe;; esac\n}",
|
||||
"unset a[b\nscan_probe\necho ]+=1",
|
||||
"export a[b; scan_probe; echo ]=1",
|
||||
"a[b; scan_probe; echo ]=1",
|
||||
"a[0 #]\n]=1; scan_probe",
|
||||
"for i in 1; { scan_probe; }; while false; do export X=1; done",
|
||||
"for ((i=0; i<1; i++)); { scan_probe; }; while false; do export X=1; done",
|
||||
"for i in 1; scan_probe",
|
||||
"((( echo '\"' ); scan_probe; ( echo '\"' )))",
|
||||
"set=1; export X=${set:-${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$$$'\\'; scan_probe # '",
|
||||
'export X="`export Y=\\"\'\\" ; scan_probe; export Z=\\"\'\\"`"',
|
||||
'export X="`echo \\"(\\"; scan_probe; echo \\")\\"`"',
|
||||
": <<-export\n\tex\\\n\tport\n$(scan_probe)\nexport",
|
||||
'export X="${\\\n scan_probe; }"',
|
||||
'export X="${\n scan_probe; }"',
|
||||
'export X="${|\\\n REPLY=$(scan_probe); }"',
|
||||
"!(scan_probe; true)",
|
||||
"if !(scan_probe); then :; fi",
|
||||
"f+() { scan_probe; }; f+",
|
||||
': <<$"export"\n$export\nscan_probe\nexport',
|
||||
': <<$"export"\nexport\nscan_probe\n$export',
|
||||
"export X=$[1; scan_probe; : ]",
|
||||
"X=$[1 scan_probe ]",
|
||||
'export X=$["]"]; scan_probe # ]',
|
||||
"export X=${#}# ; scan_probe",
|
||||
"export X=$#a# ; scan_probe",
|
||||
"export X=~+# ; scan_probe",
|
||||
"{ export X=1; }# ; scan_probe; }",
|
||||
"if true; then export X=1; fi# ; scan_probe; fi",
|
||||
"for i in 1; do export X=1; done# ; scan_probe; done",
|
||||
"case esac# in x) export X=1;; esac#) scan_probe;; esac",
|
||||
": <<A <<B\nA\n$(scan_probe)\nB",
|
||||
'export X="$(cat <<EOF\n)\nEOF\nscan_probe)"',
|
||||
"cat <<EOF | scan_probe\nhello\nEOF",
|
||||
"cat <<EOF && scan_probe\nhello\nEOF",
|
||||
"cat <<'E'\"O\"F\n# $(scan_probe)\nEOF\nscan_probe",
|
||||
"cat <<E\\\nOF\n$(scan_probe)\nEOF",
|
||||
"cat <<-EOF\n\t EOF\n$(scan_probe)\n\tEOF",
|
||||
"cat <<export\nex\\\nport\n# $(scan_probe)\nexport",
|
||||
"cat <<< 'a'\"$(scan_probe)\"",
|
||||
'export X=${x:-"}"}; scan_probe',
|
||||
"export X=${x:-'}'}; scan_probe",
|
||||
'export X="${x:-"}"}"; scan_probe',
|
||||
'export X="${x:-"$(scan_probe)"}"',
|
||||
"export X=${x//\\}/}; scan_probe; : }",
|
||||
'export X="${x//\\}/}"; scan_probe; : "}"',
|
||||
"export X=${x#${y}}; scan_probe",
|
||||
'export X="${x#${y:-"}"}}"; scan_probe',
|
||||
"export X=${!prefix*}; scan_probe",
|
||||
"export X=${x@Q}; scan_probe",
|
||||
"export X=\"${unset:+${x['\"']}}\"; scan_probe; : '\"}]}'",
|
||||
'export X="${unset:+${x[\'}}\"; scan_probe; : \"\']}}"',
|
||||
'echo $(case x in x) echo ")" ;; esac; scan_probe)',
|
||||
"echo \"$(case x in (x) echo ')' ;; esac; scan_probe)\"",
|
||||
'echo "`echo \\"$(scan_probe)\\"`"',
|
||||
"echo `echo \\`scan_probe\\``",
|
||||
"echo $( (echo a); scan_probe )",
|
||||
"echo $((echo a); scan_probe)",
|
||||
"if true; then scan_probe; elif true; then :; else :; fi",
|
||||
"for i in in do done esac; do scan_probe; done",
|
||||
"for do in 1; do scan_probe; done",
|
||||
"for in in 1; do scan_probe; done",
|
||||
"for x\nin 1; do scan_probe; done",
|
||||
"set -- 1; for x\ndo scan_probe; done",
|
||||
"set -- 1; for x do scan_probe; done",
|
||||
"case in in in) scan_probe;; esac",
|
||||
"case esac in a|esac) scan_probe;; esac",
|
||||
"case y in x) ;; y) scan_probe;; esac",
|
||||
"case x in x) scan_probe ;& y) : ;;& z) : ;; esac",
|
||||
"case x in x) scan_probe ;| y) : ;; esac",
|
||||
"case [ in [) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"case x in (x|[) scan_probe & ( scan_probe q ]) ;; esac",
|
||||
"while false; do :; done & scan_probe",
|
||||
"{ scan_probe & }",
|
||||
"( scan_probe & )",
|
||||
"[[ b =~ b ]] && scan_probe",
|
||||
"[[ ( a == a ) && ( b == b ) ]] && scan_probe",
|
||||
'[[ "$(scan_probe)" == "]]" ]]',
|
||||
"(( 1 + $(scan_probe) ))",
|
||||
"(( a = 1 )) && scan_probe",
|
||||
"(( (1) + (2) )); scan_probe",
|
||||
"(( (echo a); scan_probe ))",
|
||||
">/dev/null scan_probe",
|
||||
"2>&1 scan_probe",
|
||||
"A=1 >/dev/null B=2 scan_probe",
|
||||
'export A=1 B="$(scan_probe)"',
|
||||
'declare -a arr=(1 "$(scan_probe)")',
|
||||
"export X=$'a'\\\n; scan_probe",
|
||||
"case x in \\\nx) \\\nscan_probe;; \\\nesac",
|
||||
"if true; then \\\nscan_probe; fi",
|
||||
'() { :; } "$(scan_probe)"',
|
||||
"function f() ( scan_probe ); f",
|
||||
"{ export X=1 } && scan_probe",
|
||||
"{ export X=1 } ; scan_probe ; }",
|
||||
|
||||
// Dialects disagree about reserved words after redirects and operators split by line continuations.
|
||||
"if true; then >/dev/null fi; scan_probe; fi",
|
||||
"if true; then >/dev/null fi; scan_probe",
|
||||
"case x in x) >/dev/null esac; scan_probe; esac",
|
||||
"true &>/dev/null scan_probe",
|
||||
"true &>>/dev/null scan_probe",
|
||||
"true &\\\n>/dev/null scan_probe",
|
||||
"cat <<\\\n-EOF\nEOF\nscan_probe h1\n-EOF",
|
||||
"cat <<\\\n-EOF\n-EOF\nscan_probe z\nEOF",
|
||||
"cat <\\\n(scan_probe i)",
|
||||
"[[ -n <\\\n(scan_probe c1) ]]",
|
||||
"a=(<\\\n(scan_probe a1))",
|
||||
"cat <<E\n$\\\n(scan_probe h)\nE",
|
||||
"echo ${x:-$\\\n(scan_probe p)}",
|
||||
"(( $\\\n(scan_probe a) ))",
|
||||
"[[ $\\\n(scan_probe c) ]]",
|
||||
"a=($\\\n(scan_probe arr))",
|
||||
|
||||
// Dash splits assignment subscripts at blanks.
|
||||
"scan_probe[x y]=1",
|
||||
"a[x '$(scan_probe)']=1",
|
||||
"a[1 + $(scan_probe)]=1",
|
||||
|
||||
// Expansions inside parameter words, arithmetic, and subscripts.
|
||||
"echo \"${x:-$'$(scan_probe q1)'}\"",
|
||||
"echo ${x:-<(scan_probe p1)}",
|
||||
"echo ${x:->(scan_probe g)}",
|
||||
"x=${y:-<(scan_probe p4)}",
|
||||
"[[ x == ${y:-<(scan_probe p5)} ]]",
|
||||
"echo $(( $'$(scan_probe a)' ))",
|
||||
"(( x = $'$(scan_probe b)' ))",
|
||||
"echo $(( ${x:-'$(scan_probe a)'} ))",
|
||||
"(( ${x:-'$(scan_probe b)'} ))",
|
||||
"a[${x:-'$(scan_probe c)'}]=1",
|
||||
"echo $[ ${x:-'$(scan_probe d)'} ]",
|
||||
"echo $[ $'$(scan_probe h)' ]",
|
||||
"echo ${a[${x:-'$(scan_probe e)'}]}",
|
||||
"echo \"${a[${x:-'$(scan_probe f)'}]}\"",
|
||||
"cat <<E\n${x:-'$(scan_probe h)'}\nE",
|
||||
|
||||
// Zsh glob qualifiers and extglob groups run code in globbed words.
|
||||
"echo @(<(scan_probe e1))",
|
||||
"printf '%s' @(one|$(scan_probe))",
|
||||
"echo *(e:'scan_probe q1':)",
|
||||
"echo *(+scan_probe)",
|
||||
"a=(*(e:'scan_probe g':))",
|
||||
"declare -a a=(*(e:'scan_probe g':))",
|
||||
"export a=(*(e:'scan_probe h':))",
|
||||
"for f in *(e:'scan_probe h':); do :; done",
|
||||
"echo ${x:-target(e:'scan_probe p2':)}",
|
||||
"echo ${x:-*(e:'scan_probe j':)}",
|
||||
"echo a=(e:'scan_probe p3':)",
|
||||
"echo >*(e:'scan_probe f':)",
|
||||
"cat <*(e:'scan_probe g':)",
|
||||
"echo $x*(e:'scan_probe h':)",
|
||||
"echo \"\"*(e:'scan_probe i':)",
|
||||
"echo {a,*(e:'scan_probe m':)}",
|
||||
|
||||
// Builtins and arithmetic evaluate subscripts in decoded literal text.
|
||||
"declare -i x='a[$(scan_probe)]'",
|
||||
"declare 'a[$(scan_probe)]=1'",
|
||||
"declare -a 'a=([$(scan_probe)]=1)'",
|
||||
"a=(1); unset 'a[$(scan_probe)]'",
|
||||
'a=(1); unset "a[\\$(scan_probe)]"',
|
||||
"[[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"[[ -v 'a[$(scan_probe)]' ]]",
|
||||
"read 'a[$(scan_probe)]' </dev/null",
|
||||
"printf -v 'a[$(scan_probe)]' x",
|
||||
"x='a[$(scan_probe)]'; echo $((x))",
|
||||
"s=abc; x='a[$(scan_probe)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(scan_probe)0]'; printf '%s' \"${!ref}\"",
|
||||
"declare ${x:-'a[$(scan_probe)]=1'}",
|
||||
'declare "${x:-a[\\$(scan_probe)]=1}"',
|
||||
"read ${x:-'a[$(scan_probe)]'} </dev/null",
|
||||
"declare \"$(echo 'a[$(scan_probe)]=1')\"",
|
||||
"declare \"${ echo a; }\"'[$(scan_probe)]=1'",
|
||||
"n=a; declare \"$n\"'[$(scan_probe)]=1'",
|
||||
"declare \"$(echo a)\"'[$(scan_probe)]=1'",
|
||||
"builtin declare 'a[$(scan_probe)]=1'",
|
||||
"printf -v'a[$(scan_probe)]' x",
|
||||
"set -- 'a[$(scan_probe)]'; echo $(($1))",
|
||||
"for x in 'a[$(scan_probe)]'; do echo $((x)); done",
|
||||
"a=(1 'a[$(scan_probe)]'); echo $((a[1]))",
|
||||
"x='a[$(scan_probe)]' eval 'echo $((x))'",
|
||||
"a=(1); echo $(( a[\\$(scan_probe)] ))",
|
||||
"a=(1); (( a[\\$(scan_probe)] ))",
|
||||
|
||||
// Explicit evaluation operators.
|
||||
"x='$(scan_probe)'; echo ${x@P}",
|
||||
"x='$(scan_probe)'; echo \"${x@P}\"",
|
||||
"x='$(scan_probe)'; echo ${(e)x}",
|
||||
"x='$(scan_probe)'; echo ${(ee)x}",
|
||||
"echo ${(e):-'$(scan_probe)'}",
|
||||
"x='*(e:scan_probe:)'; echo ${~x}",
|
||||
"x='*(e:scan_probe:)'; echo $~x",
|
||||
|
||||
// Precommand modifiers, bundled/ordered binding options, repeat, compgen, and setopt.
|
||||
"noglob typeset 'a[$(scan_probe)]=1'",
|
||||
"nocorrect typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); noglob unset 'a[$(scan_probe)]'",
|
||||
"noglob read 'a[$(scan_probe)]' <<< x",
|
||||
'a=(1); noglob let "a[\\$(scan_probe)]"',
|
||||
"print -r -v 'a[$(scan_probe)]' x",
|
||||
"print -rv 'a[$(scan_probe)]' x",
|
||||
"a=(1); print -r -v x 'a[$(scan_probe)]'; echo $((x))",
|
||||
"a=(1); print -rv x 'a[$(scan_probe)]'; echo $((x))",
|
||||
": & wait -np 'a[$(scan_probe)]'",
|
||||
": & wait -n -p'a[$(scan_probe)]'",
|
||||
": & wait -fp 'a[$(scan_probe)]' $!",
|
||||
"time declare 'a[$(scan_probe)]=1'",
|
||||
"coproc declare 'a[$(scan_probe)]=1'",
|
||||
"a=(1); time unset 'a[$(scan_probe)]'",
|
||||
"a=(1); time let 'a[$(scan_probe)]'",
|
||||
"a=(1); time [[ -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); time [[ 'a[$(scan_probe)]' -eq 1 ]]",
|
||||
"a=(1); coproc [[ -v 'a[$(scan_probe)]' ]]",
|
||||
'export X="${ time ! { :; }; scan_probe; }"',
|
||||
'export X="${ time\\\n { :; }; scan_probe; }"',
|
||||
'export X="${ time -- { :; }; scan_probe; }"',
|
||||
"repeat 1 scan_probe",
|
||||
"a=(1); repeat 'a[$(scan_probe)]' :",
|
||||
"setopt globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set -o globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"compgen -C scan_probe",
|
||||
"compgen -C 'scan_probe'",
|
||||
|
||||
// Combined literal/output subscripts, deferred bindings, continuations, Zsh flags, and ANSI-C escapes.
|
||||
"declare \"a[$(echo '$(scan_probe)')]=1\"",
|
||||
"declare \"$(echo 'a[')\"'$(scan_probe)]=1'",
|
||||
"a=(1); unset \"a[$(echo '$(scan_probe)')]\"",
|
||||
"a=(1); echo $(( a[$(echo '$(scan_probe)')] ))",
|
||||
"x='$(scan_probe)'; declare \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; a=(1); unset \"a[$x]\"",
|
||||
"x='$(scan_probe)'; let \"a[$x]=1\"",
|
||||
"x='$(scan_probe)'; read \"a[$x]\" <<< 1",
|
||||
"x='$(scan_probe)'; printf -v \"a[$x]\" 1",
|
||||
"a=(1); x='$(scan_probe)'; echo $((a[$x]))",
|
||||
"a=(1); x='$(scan_probe)'; [[ -v \"a[$x]\" ]]",
|
||||
"a=(1); x='$(scan_probe)'; [[ \"a[$x]\" -eq 1 ]]",
|
||||
"a=(1); echo ${a\\\n['$(scan_probe)']}",
|
||||
"a=(1); s=abc; echo ${s\\\n:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${a[0]\\\n:'a[$(scan_probe)0]'}",
|
||||
"x='$(scan_probe)'; echo ${x@\\\nP}",
|
||||
"x='$(scan_probe)'; echo \"${x@\\\nP}\"",
|
||||
"x='$(scan_probe)'; echo ${x@P\\\n}",
|
||||
"x='*(e:scan_probe:)'; echo $\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo $^\\\n~x",
|
||||
"x='*(e:scan_probe:)'; echo ${^\\\n~x}",
|
||||
"x='*(e:scan_probe:)'; echo ${(f)\\\n~x}",
|
||||
"a=(1); echo ${(f)a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${=a['a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${^a['a[$(scan_probe)]']}",
|
||||
"a=(1); s=abc; echo ${(f)s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${=s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); s=abc; echo ${^s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != *(a)]] && -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x != (a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"shopt -s extglob\na=(1); [[ x != *(a)]] && 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); echo $(( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); (( $'a\\x5b\\x24\\x28scan_probe\\x29\\x5d' ))",
|
||||
"a=(1); echo ${a[$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d']}",
|
||||
"a=(1); declare \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d=1'}\"",
|
||||
"a=(1); unset \"${x:-$'a\\x5b\\x24\\x28scan_probe\\x29\\x5d'}\"",
|
||||
"(( $'\\' )) # ' )); scan_probe",
|
||||
"declare -A a; a[$'\\']=1 # ']=1; scan_probe",
|
||||
"a=(1); echo ${a[$'\\']} # ']} $(scan_probe)",
|
||||
": <<$'export'\n$export\nscan_probe\nexport",
|
||||
": <<$'EOF'\n$EOF\nscan_probe\nEOF",
|
||||
"echo $'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\'; scan_probe; : ' # '",
|
||||
"export X=$'\\c\\\\'; scan_probe # '",
|
||||
"export X=$'\\c'; scan_probe; : ' # '",
|
||||
"$'scan_probe\\0x'",
|
||||
"$'scan_probe\\x00x'",
|
||||
"$'scan_probe\\u0000x'",
|
||||
"$'scan_probe\\c@x'",
|
||||
"$'declare\\0x' 'a[$(scan_probe)]=1'",
|
||||
"a=(1); $'unset\\0x' 'a[$(scan_probe)]'",
|
||||
"$'let\\0x' 'a[$(scan_probe)]'",
|
||||
"x='x y'; scan_probe[$x]=1",
|
||||
|
||||
// Parameter default bindings, Zsh subscripts, precommands, namerefs, ANSI-C escapes, and Zsh builtins.
|
||||
"a=(1); : ${x:='a[$(scan_probe)]'}; echo $((x))",
|
||||
"a=(1); : ${x:='$(scan_probe)'}; echo $((a[$x]))",
|
||||
"a=(1); : ${x='$(scan_probe)'}; unset \"a[$x]\"",
|
||||
'a=(1); : "${x:="a[\\$(scan_probe)]"}"; echo $((x))',
|
||||
"a=(); : ${a[0]:='a[$(scan_probe)]'}; echo $((a[0]))",
|
||||
"declare -i a x; : ${x:='a[$(scan_probe)]'}",
|
||||
"a=(1); echo ${@[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${*[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${1[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo ${a[1]['$(scan_probe)']}",
|
||||
"a=(1); echo ${s[1][1]:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${${a}['$(scan_probe)']}",
|
||||
"a=(1); echo ${${s}:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo $a['$(scan_probe)']",
|
||||
'a=(1); echo "$a[a[\\$(scan_probe)]]"',
|
||||
"a=(1); x='$(scan_probe)'; echo $a[a[$x]]",
|
||||
"a=(1); x='$(scan_probe)'; echo \"$a[a[$x]]\"",
|
||||
"a=(1); echo $@[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $^a['$(scan_probe)']",
|
||||
"exec typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); - typeset 'a[$(scan_probe)]=1'",
|
||||
"a=(1); exec unset 'a[$(scan_probe)]'",
|
||||
"a=(1); - unset 'a[$(scan_probe)]'",
|
||||
"a=(1); exec -a foo unset 'a[$(scan_probe)]'",
|
||||
"a=(1); exec let 'a[$(scan_probe)]'",
|
||||
"a=(1); - let 'a[$(scan_probe)]'",
|
||||
"unset a=(e:'scan_probe':)",
|
||||
"unsetenv a=(e:'scan_probe':)",
|
||||
"a=(1); r='a[$(scan_probe)]'; declare -n r; r=1",
|
||||
"a=(1); r='a[$(scan_probe)]'; declare -n r; echo $r",
|
||||
"a=(1); r='a[$(scan_probe)]'; typeset -n r; r=1",
|
||||
"a=(1); x='a[$(scan_probe)]'; (( $'\\170' ))",
|
||||
"a=(1); x='a[$(scan_probe)]'; echo ${a[$'\\170']}",
|
||||
"a=(1); x='a[$(scan_probe)]'; declare a[x]=1",
|
||||
"a=(1); x='a[$(scan_probe)]'; declare 'a[x]=1'",
|
||||
"a=(1); x='a[$(scan_probe)]'; unset 'a[x]'",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -v a[x] ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; read 'a[x]' <<< 1",
|
||||
"a=(1); x='a[$(scan_probe)]'; printf -v 'a[x]' 1",
|
||||
"a=(1); x='a[$(scan_probe)]'; test -v 'a[x]'",
|
||||
"a=(1); echo ${(s:):)a['a[$(scan_probe)]']}",
|
||||
"a=(1); s=abc; echo ${(s:):)s:'a[$(scan_probe)0]'}",
|
||||
"a=(1); [[ !(-v 'a[$(scan_probe)]') ]]",
|
||||
"echo $'a\\'; scan_probe; ' \\'",
|
||||
"echo $'\\'$(scan_probe)' \\'",
|
||||
": <<$'a\\'; scan_probe; '\\'\n$a\\\n\na'; scan_probe; '",
|
||||
"export X=$'\\c\\''; scan_probe # '",
|
||||
"a=(1); declare $'a[\\0$(scan_probe)]=1'",
|
||||
"a=(1); unset $'a[\\0$(scan_probe)]'",
|
||||
"a=(1); declare $'a[\\u0000$(scan_probe)]=1'",
|
||||
": <<$'\\u0041'\n\\u0041\nscan_probe\nA",
|
||||
": <<$'\\U00000041'\n\\U00000041\nscan_probe\nA",
|
||||
"$'s\\can_probe'",
|
||||
"$'de\\clare' 'a[$(scan_probe)]=1'",
|
||||
"$'\\scan_probe'",
|
||||
"a=(1); typeset $'a\\[\\$\\(scan_probe\\)\\]=1'",
|
||||
"$'scan_probe\\xz'",
|
||||
"$'scan_probe\\uz'",
|
||||
"$'scan_probe\\Uz'",
|
||||
"$'scan_probe\\C-@x'",
|
||||
"$'scan_probe\\C@x'",
|
||||
"a=(1); set -A 'a[$(scan_probe)]' 1",
|
||||
"a=(1); set +A 'a[$(scan_probe)]' 1",
|
||||
"unsetopt noglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set +o noglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set -xo globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate zsh -o globsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"NULLCMD=scan_probe; >/dev/null",
|
||||
"READNULLCMD=scan_probe; </dev/null",
|
||||
"NULLCMD=scan_probe; <<< x",
|
||||
"setopt promptsubst; x='$(scan_probe)'; print -P $x",
|
||||
"setopt promptvars; print -P '$(scan_probe)'",
|
||||
"a=(1); exit 'a[$(scan_probe)]'",
|
||||
"f() { return 'a[$(scan_probe)]'; }; a=(1); f",
|
||||
"a=(1); shift 'a[$(scan_probe)]'",
|
||||
"a=(1); for x in 1; do break 'a[$(scan_probe)]'; done",
|
||||
"a=(1); for x in 1; do continue 'a[$(scan_probe)]'; done",
|
||||
"a=(1); bye 'a[$(scan_probe)]'",
|
||||
"a=(1); logout 'a[$(scan_probe)]'",
|
||||
"a=(1); getln 'a[$(scan_probe)]'",
|
||||
"a=(1); vared 'a[$(scan_probe)]'",
|
||||
"a=(1); zformat -f 'a[$(scan_probe)]' x",
|
||||
"a=(1); zstyle -s : x 'a[$(scan_probe)]'",
|
||||
"zmodload zsh/stat; a=(1); zstat -A 'a[$(scan_probe)]' .",
|
||||
"a=(1); compgen -V x -W 'a[$(scan_probe)]'; echo $((x[0]))",
|
||||
|
||||
// Special/positional subscripts, escape decoders, split expansions, float/array declarations, and continuations.
|
||||
"a=(1); set -- 1 2 3 4 5 6 7 8 9 abc; echo ${1\\\n0:'a[$(scan_probe)0]'}",
|
||||
"a=(1); echo ${1\\\n0[1,'a[$(scan_probe)]']}",
|
||||
"a=(1); echo $0[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $?[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $#[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $-[1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $![1,'a[$(scan_probe)]']",
|
||||
"a=(1); echo $$[1,'a[$(scan_probe)]']",
|
||||
'a=(1); echo "$?[1,a[\\$(scan_probe)]]"',
|
||||
'a=(1); echo "$$[1,a[\\$(scan_probe)]]"',
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; unset \"${x@E}\"",
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; declare \"${x@E}=1\"",
|
||||
"a=(1); x='a[\\x24(scan_probe)]'; y=${x@E}; echo $((y))",
|
||||
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; unset "${(Q)x}"',
|
||||
'a=(1); x="$\'a[\\\\x24(scan_probe)]\'"; typeset "${(Q)x}=1"',
|
||||
"a=(1); x=\"$'a[\\\\x24(scan_probe)]'\"; y=${(Q)x}; echo $((y))",
|
||||
"a=(1); x='a[$'; x+='(scan_probe)]'; unset \"$x\"",
|
||||
'a=(1); d=\'$\'; x="a[${d}(scan_probe)]"; unset "$x"',
|
||||
"a=(1); x='a[$_1(scan_probe)]'; unset \"${x/_1/}\"",
|
||||
"a=(1); x='a[$_1(scan_probe)]'; echo $(( ${x/_1/} ))",
|
||||
"a=(1); x='a[$'; y='(scan_probe)]'; echo $(( $x$y ))",
|
||||
"a=(1); read x <<< 'a[$\\(scan_probe)]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\x24(scan_probe)]'; echo $((x))",
|
||||
"a=(1); print -v x 'a[\\x24(scan_probe)]'; echo $((x))",
|
||||
"a=(1); typeset -E x; x='a[$(scan_probe)]'",
|
||||
"a=(1); typeset -F x; x='a[$(scan_probe)]'",
|
||||
"a=(1); y='a[$(scan_probe)]'; typeset -E x=y",
|
||||
"a=(1); y='a[$(scan_probe)]'; typeset -F x=y",
|
||||
"a=(); declare a='([$(scan_probe)]=1)'",
|
||||
"a=(); typeset a='([$(scan_probe)]=1)'",
|
||||
"a=(); declare a+='([$(scan_probe)]=1)'",
|
||||
"f() { local -a a; local a='([$(scan_probe)]=1)'; }; f",
|
||||
"declare -a a; x='([$(scan_probe)]=1)'; declare a=$x",
|
||||
"x=${NULLCMD::=scan_probe}; >/dev/null",
|
||||
"x=${READNULLCMD::=scan_probe}; </dev/null",
|
||||
": a${NULLCMD::=scan_probe}; >/dev/null",
|
||||
": ${^NULLCMD::=scan_probe}; >/dev/null",
|
||||
": ${(f)NULLCMD::=scan_probe}; >/dev/null",
|
||||
"print -vNULLCMD scan_probe; >/dev/null",
|
||||
"(( ${NULLCMD::=scan_probe} )); >/dev/null",
|
||||
"[[ -n ${NULLCMD::=scan_probe} ]]; >/dev/null",
|
||||
"a=(${NULLCMD::=scan_probe}); >/dev/null",
|
||||
": <<EOF\n${NULLCMD::=scan_probe}\nEOF\n>/dev/null",
|
||||
"PS4='$(scan_probe)'; set -x; x=1",
|
||||
"PS4='$(scan_probe)'; set -o xtrace; x=1",
|
||||
"export PS4='$(scan_probe)'; set -x; x=1",
|
||||
"setopt -m 'glob*'; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt -m '*subst*'; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate sh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
|
||||
"emulate ksh; unsetopt shglob; setopt bareglobqual; x='*(e:scan_probe:)'; echo $x",
|
||||
"a=(1); zregexparse 'a[$(scan_probe)]' 'a[$(scan_probe)]' x /x/",
|
||||
"a=(1); private 'a[$(scan_probe)]=1'",
|
||||
"a=(1); [[ x =~ (a )(b)]] || 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ x =~ (a )(b)]] || -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [[ -t 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -t x ]]",
|
||||
"a=(1); x='a[$(scan_probe)]'; [[ -t $x ]]",
|
||||
"a=(1); test -t 'a[$(scan_probe)]'",
|
||||
"a=(1); [ -t 'a[$(scan_probe)]' ]",
|
||||
"a=(1); x='a[$(scan_probe)]'; test -t x",
|
||||
"a=(1); x='a[$(scan_probe)]'; [ -t x ]",
|
||||
"echo $\\\n'a\\''; scan_probe; : \\'",
|
||||
": <<$\\\n'EOF'\nEOF\nscan_probe\n$EOF",
|
||||
': <<$\\\n"EOF"\nEOF\nscan_probe\n$EOF',
|
||||
"$\\\n'scan_probe'",
|
||||
'$\\\n"scan_probe"',
|
||||
"a=(1); unset $\\\n'a[\\x24(scan_probe)]'",
|
||||
"echo \"${x:-$\\\n'\\x24(scan_probe)'}\"",
|
||||
"[[ -n x ]\\\n] && scan_probe && [[ -n y ]]",
|
||||
"a=(1); (\\\n( 'a[$(scan_probe)]' ))",
|
||||
"a=(1); (\\\n( 1 + 'a[$(scan_probe)]' ))",
|
||||
"a=(1); echo $(\\\n( 'a[$(scan_probe)]' ))",
|
||||
"a=(1); x='a[$(scan_probe)]'; echo $(( x )\\\n)",
|
||||
"a=(1); [\\\n[ -v 'a[$(scan_probe)]' ]]",
|
||||
"a=(1); [\\\n[ 0 -eq 'a[$(scan_probe)]' ]]",
|
||||
"a[0]\\\n=1 scan_probe",
|
||||
"a[0]+\\\n=1 scan_probe",
|
||||
"a=(); a[0]\\\n='a[$(scan_probe)]'; echo $((a[0]))",
|
||||
"x='x y'; scan_probe[$x]\\\n=1",
|
||||
"a=(1); x='a[$_1]'; y='(scan_probe)'; unset \"${x/_1/$y}\"",
|
||||
"a=(1); x='a[$_1]'; y='(scan_probe)'; echo $(( ${x/_1/$y} ))",
|
||||
"a=(1); x='a[$1'; y='(scan_probe)]'; echo $(( ${x%1}$y ))",
|
||||
"a=(1); x='a[$1\n(scan_probe)]'; unset \"${x/1?/}\"",
|
||||
"printf -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
|
||||
"printf -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\u0024(scan_probe)]'; echo $((x))",
|
||||
"a=(1); printf -v x 'a[\\U00000024(scan_probe)]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\x24\\x28scan_probe\\x29]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\044\\050scan_probe\\051]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\u0024\\u0028scan_probe\\u0029]'; echo $((x))",
|
||||
"typeset -a a; print -v x 'a[\\U00000024\\U00000028scan_probe\\U00000029]'; echo $((x))",
|
||||
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "${x:Q}"',
|
||||
'a=(1); x="$\'a[\\\\x24\\\\x28scan_probe\\\\x29]\'"; unset "$x:Q"',
|
||||
"a=(1); x=\"$'a[\\\\x24\\\\x28scan_probe\\\\x29]'\"; y=${x:Q}; echo $((y))",
|
||||
"a=(1); read x <<'EOF'\na[$(scan_probe)]\nEOF\necho $((x))",
|
||||
"a=(1); read x <<EOF\na[\\$(scan_probe)]\nEOF\necho $((x))",
|
||||
"mapfile -t a <<'EOF'\nb[$(scan_probe)]\nEOF\necho $((a[0]))",
|
||||
"a=(); declare a=\"$(echo '([$(scan_probe)]=1)')\"",
|
||||
"a=(); declare a=\"`echo '([$(scan_probe)]=1)'`\"",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$1",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$@",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=$*",
|
||||
"a=(); set -- '([$(scan_probe)]=1)'; declare a=\"$1\"",
|
||||
"x==(scan_probe)",
|
||||
"x+==(scan_probe)",
|
||||
"a=(1); a[1]==(scan_probe)",
|
||||
"a=(1); a[1]+==(scan_probe)",
|
||||
"export x==(scan_probe)",
|
||||
"typeset x==(scan_probe)",
|
||||
"f() { local x==(scan_probe); }; f",
|
||||
"x==\\\n(scan_probe)",
|
||||
"a=(1); ref='a[$(scan_probe)]'; echo ${\\\n!ref}",
|
||||
"a=(1); ref='a[$(scan_probe)]'; echo \"${\\\n!ref}\"",
|
||||
"a=(1); set -k; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
|
||||
"a=(1); set -o keyword; f() { echo $((x)); }; f x='a[$(scan_probe)]'",
|
||||
"a=(1); set -k; test -v 'a[x]' x='a[$(scan_probe)]'",
|
||||
"a=(1); set -o posix -k; : x='a[$(scan_probe)]'; echo $((x))",
|
||||
"set -oglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"set +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt -oglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"setopt +onoglobsubst; x='*(e:scan_probe:)'; echo $x",
|
||||
"printf -vPS4 '\\x24(scan_probe)'; set -x; x=1",
|
||||
"printf -vPS4 '\\044(scan_probe)'; set -x; x=1",
|
||||
"functions[zshexit]=scan_probe",
|
||||
"functions[TRAPEXIT]=scan_probe",
|
||||
"functions[TRAPDEBUG]=scan_probe; x=1",
|
||||
"functions=(zshexit scan_probe)",
|
||||
": ${functions[zshexit]::=scan_probe}",
|
||||
"functions[echo]=scan_probe; echo hi",
|
||||
"options[globsubst]=on; x='*(e:scan_probe:)'; echo $x",
|
||||
"options=(globsubst on); x='*(e:scan_probe:)'; echo $x",
|
||||
": ${options[globsubst]::=on}; x='*(e:scan_probe:)'; echo $x",
|
||||
"aliases[echo]=scan_probe; options[aliases]=on; : `echo`",
|
||||
"galiases[echo]=scan_probe; options[aliases]=on; : `echo`",
|
||||
"saliases[x]=scan_probe; options[aliases]=on; : `a.x`",
|
||||
"commands[ls]=$(command -v scan_probe); ls",
|
||||
"a=(1); [[ x == \"\" || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( -v 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x != '' && ( -t 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ -n == && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x == != || ( -t 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ x == == || ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"a=(1); [[ ( -n == ) && ( 0 -eq 'a[$(scan_probe)]' ) ]]",
|
||||
"[[ -n $$'\\'' ]]; scan_probe # ' ]]",
|
||||
"a=($$'\\''); scan_probe # ')",
|
||||
"echo ${x:-$$'\\''}; scan_probe # '}",
|
||||
"export X=$$\\\n'\\''; scan_probe # '",
|
||||
"export X=$'\\'; scan_probe; : '; cat <<E\n'\nE",
|
||||
"export X=$\\\n$'\\'; scan_probe; : ' # '",
|
||||
'a=(1); echo "$a[(r)\\],a[\\$(scan_probe)]]"',
|
||||
'a=(1); echo "$?[1\\],a[\\$(scan_probe)]]"',
|
||||
'export X="${unset:+${a[0}}"; scan_probe; : "]}}"',
|
||||
"export X=${unset:+${a[0}}; scan_probe; : ]}}",
|
||||
'export X=${unset:+${a["0}"}}; scan_probe; : ]}}',
|
||||
'cat <<E; export X="${unset:+$(( 0 + "0 ))}" && scan_probe\n))}"\nE',
|
||||
'false && export X="${unset:+$(( 0 }"; scan_probe; : "))}"',
|
||||
'false && export X="${unset:+$(echo a}"; scan_probe; : ")}"',
|
||||
'false && export X="${unset:+$(echo {a}"; scan_probe; : ")}"',
|
||||
'false && export X="${unset:+$(echo a})}"; scan_probe; : "}"',
|
||||
"export X=$$'\\' # '; scan_probe",
|
||||
"export X=$$'\\' #\nexport Y='; scan_probe # '",
|
||||
"export X=$^$'\\' # '; scan_probe",
|
||||
"export X=$#$'\\' # '; scan_probe",
|
||||
"export X=$'\\'\nscan_probe\n'",
|
||||
] as const
|
||||
|
||||
// These also run inside every wrapper below.
|
||||
const nestedFixtures = [
|
||||
"[[ a]]# ]] && scan_probe",
|
||||
"{ export X={}# ; scan_probe; }",
|
||||
"unset a[b; scan_probe; echo ]=1",
|
||||
"case esac in (esac) scan_probe;; esac",
|
||||
"case esac in (a|esac) scan_probe;; esac",
|
||||
"for i in 1; { if true; then scan_probe; fi }; while false; do export X=1; done",
|
||||
"(( echo '\"' ); scan_probe; ( echo '\"' ))",
|
||||
"export X=${unset:+${x['0\"0']}}; scan_probe; : '\"]}}' # '",
|
||||
"export X=$$'\\'; scan_probe # '",
|
||||
": <<-export\n\tex\\\n\tport\n# $(scan_probe)\nexport",
|
||||
"!(scan_probe)",
|
||||
"f+() case x in x) scan_probe;; esac; f+",
|
||||
"f@g() case x in x) scan_probe;; esac; f@g",
|
||||
"f@g() for i in 1; do scan_probe; done; f@g",
|
||||
] as const
|
||||
|
||||
const wrappers: Array<[name: string, wrap: (inner: string) => string]> = [
|
||||
["$(...)", (inner) => `export OUTER=$( ${inner}\n)`],
|
||||
['"$(...)"', (inner) => `export OUTER="$( ${inner}\n)"`],
|
||||
["backticks", (inner) => `export OUTER=\` ${inner}\n\``],
|
||||
["heredoc", (inner) => `: <<EOF\n$( ${inner}\n)\nEOF`],
|
||||
["case arm", (inner) => `case x in x) ${inner}\n;; esac`],
|
||||
["for loop", (inner) => `for k in 1; do ${inner}\ndone`],
|
||||
["function", (inner) => `wrap_fn() {\n${inner}\n}; wrap_fn`],
|
||||
["brace group", (inner) => `{ ${inner}\n}`],
|
||||
]
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell soundness oracle", () => {
|
||||
test("discovers at least bash on PATH", () => {
|
||||
expect(shells.some((item) => item.endsWith("/bash"))).toBe(true)
|
||||
})
|
||||
|
||||
test.each([...fixtures, ...nestedFixtures])("reports or rejects real-shell execution: %j", (source) => {
|
||||
expectProbesReported(source)
|
||||
})
|
||||
|
||||
test.each(wrappers.flatMap(([name, wrap]) => nestedFixtures.map((source) => [name, source, wrap(source)])))(
|
||||
"reports or rejects in %s: %j",
|
||||
(_, __, source) => {
|
||||
expectProbesReported(source)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe.skipIf(process.platform === "win32")("real-shell directory oracle", () => {
|
||||
test.each(["cd >/dev/null TARGET", "cd 2>/dev/null TARGET", "cd $'TARGET'", 'cd "TARGET"'])(
|
||||
"reports the directory a real shell changes to: %s",
|
||||
async (template) => {
|
||||
const source = template.replace("TARGET", target)
|
||||
const targets = [target, fs.realpathSync(target)]
|
||||
const changed = shells.filter((executable) =>
|
||||
targets.includes(observe(executable, `${source}\npwd >> "$SCAN_PROBE_LOG"`).at(-1) ?? ""),
|
||||
)
|
||||
expect(changed.length, `Fixture never changed directory in any real shell: ${source}`).toBeGreaterThan(0)
|
||||
for (const executable of changed) {
|
||||
const parsed = await Effect.runPromiseExit(ShellParse.scanPortable(source, executable, root))
|
||||
if (Exit.isSuccess(parsed)) expect(parsed.value.directories, `${executable} in: ${source}`).toContain(target)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -38,11 +38,6 @@ describe("Bash redirect resource oracle", () => {
|
||||
`${redirect} FOO=bar git status 3>tail`,
|
||||
`npm run ${redirect} test`,
|
||||
]) {
|
||||
// Dash reads `&>` as `&` and `>`, so words after its target start another command there.
|
||||
if (redirect.startsWith("&") && !command.endsWith(redirect)) {
|
||||
expect(ShellScan.scan(command).kind).toBe("opaque")
|
||||
continue
|
||||
}
|
||||
await parity(command)
|
||||
for (const separator of separators) {
|
||||
await parity(`printf ok${separator}${command}`)
|
||||
@@ -85,6 +80,7 @@ describe("Bash redirect resource oracle", () => {
|
||||
"pwd | cat 2\\>out",
|
||||
"if true; then printf ok && cat >$(printf path); fi",
|
||||
"if true; then printf ok && git >out status; else cat >log; fi",
|
||||
"pwd && cd >out /outside",
|
||||
"time git status",
|
||||
"time -p git status",
|
||||
"coproc git status",
|
||||
@@ -155,12 +151,4 @@ describe("Bash redirect resource oracle", () => {
|
||||
{ resource: "FOO=bar >output git status", save: "git status *" },
|
||||
])
|
||||
})
|
||||
|
||||
test("known gap: redirect before cd operand retains the target directory natively", async () => {
|
||||
const source = "pwd && cd >out /outside"
|
||||
const legacy = await Effect.runPromise(ShellParse.scan(source, "/bin/bash", "/workspace"))
|
||||
const native = await Effect.runPromise(ShellParse.scanPortable(source, "/bin/bash", "/workspace"))
|
||||
expect(legacy).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: [] })
|
||||
expect(native).toEqual({ commands: [{ resource: "pwd", save: "pwd *" }], directories: ["/outside"] })
|
||||
})
|
||||
})
|
||||
@@ -217,8 +217,6 @@ describe("ShellScan", () => {
|
||||
["CDPATH=/usr # comment\ncd bin; rm victim", ["cd", "rm"]],
|
||||
["HOME=/etc # comment\ncd; rm victim", ["cd", "rm"]],
|
||||
["VALUE=$(printf 2); echo $((VALUE + 1))", ["printf", "echo"]],
|
||||
["MSG='Use ${FOO} here'; some_cmd || exit $?", ["some_cmd", "exit"]],
|
||||
["f() { local REGEX='[0-9]+${FOO}'; return $?; }", ["local", "return"]],
|
||||
] as const)("scans assignment-only boundaries without evaluating their effects: %s", (command, names) => {
|
||||
const result = ShellScan.scan(command)
|
||||
expect(result.kind).toBe("scanned")
|
||||
@@ -236,6 +234,8 @@ describe("ShellScan", () => {
|
||||
"{fd}>/tmp/log touch /tmp/victim",
|
||||
"time touch /tmp/victim",
|
||||
"printf '%s' \"$(printf safe ${x%)}; touch /tmp/victim)\"",
|
||||
"s=abc; x='a[$(touch /tmp/victim)0]'; printf '%s' \"${s:x}\"",
|
||||
"ref='x[$(touch /tmp/victim)0]'; printf '%s' \"${!ref}\"",
|
||||
"if true; then echo safe; fi > /tmp/victim",
|
||||
"if true; then :; 'if' victim; fi",
|
||||
])("scans Bash lexical forms without interpreting shell values: %s", (command) => {
|
||||
|
||||
@@ -127,6 +127,219 @@ describe("Snapshot", () => {
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("recovers from a corrupt index and ignores a stale index lock", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "one\n")
|
||||
await initGit(project, true)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
const storage = yield* snapshotDirectory(tmp.path)
|
||||
yield* Effect.promise(async () => {
|
||||
// A process killed mid-write in older releases left a zeroed index and a lock behind.
|
||||
await fs.writeFile(path.join(storage, "index"), new Uint8Array(512))
|
||||
await fs.writeFile(path.join(storage, "index.lock"), "")
|
||||
await fs.writeFile(path.join(project, "tracked.txt"), "two\n")
|
||||
})
|
||||
const after = yield* snapshot.capture()
|
||||
expect(after).toBeDefined()
|
||||
if (!before || !after) return
|
||||
expect(yield* snapshot.files({ from: before, to: after })).toEqual([RelativePath.make("tracked.txt")])
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("captures concurrently from independent processes", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await Promise.all(
|
||||
Array.from({ length: 200 }, (_, index) =>
|
||||
fs.writeFile(path.join(project, `f${index}.txt`), `${index}\n`),
|
||||
),
|
||||
)
|
||||
await initGit(project, true)
|
||||
})
|
||||
// Each layer owns its own Git service, so their in-process locks do not coordinate.
|
||||
const writer = (id: number) =>
|
||||
Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
return yield* Effect.forEach(
|
||||
Array.from({ length: 8 }, (_, index) => index),
|
||||
(index) =>
|
||||
Effect.promise(() => fs.writeFile(path.join(project, `writer-${id}.txt`), `${index}\n`)).pipe(
|
||||
Effect.andThen(snapshot.capture()),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
const results = yield* Effect.all([writer(0), writer(1), writer(2)], { concurrency: "unbounded" })
|
||||
expect(results.flat().every((tree) => tree !== undefined)).toBe(true)
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("captures a Location in a directory whose name starts with two dots", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const location = path.join(project, "..scope")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(location, { recursive: true })
|
||||
await fs.writeFile(path.join(location, "tracked.txt"), "one\n")
|
||||
await initGit(project)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const before = yield* snapshot.capture()
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(location, "tracked.txt"), "two\n"))
|
||||
const after = yield* snapshot.capture()
|
||||
expect(before).toBeDefined()
|
||||
expect(after).toBeDefined()
|
||||
if (!before || !after) return
|
||||
expect(yield* snapshot.files({ from: before, to: after })).toEqual([
|
||||
RelativePath.make("..scope/tracked.txt"),
|
||||
])
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, location)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("restores many files from several trees and removes paths absent from them", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "a.txt"), "a1\n")
|
||||
await fs.writeFile(path.join(project, "b[1].txt"), "b1\n")
|
||||
await initGit(project, true)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const first = yield* snapshot.capture()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(project, "a.txt"), "a2\n")
|
||||
await fs.writeFile(path.join(project, "c.txt"), "c2\n")
|
||||
})
|
||||
const second = yield* snapshot.capture()
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(project, "a.txt"), "a3\n")
|
||||
await fs.writeFile(path.join(project, "b[1].txt"), "b3\n")
|
||||
await fs.writeFile(path.join(project, "c.txt"), "c3\n")
|
||||
await fs.writeFile(path.join(project, "d.txt"), "d3\n")
|
||||
})
|
||||
if (!first || !second) throw new globalThis.Error("capture failed")
|
||||
yield* snapshot.restore({
|
||||
files: new Map([
|
||||
[RelativePath.make("a.txt"), second],
|
||||
[RelativePath.make("b[1].txt"), first],
|
||||
[RelativePath.make("c.txt"), first],
|
||||
[RelativePath.make("d.txt"), second],
|
||||
]),
|
||||
})
|
||||
expect(yield* read(path.join(project, "a.txt"))).toBe("a2\n")
|
||||
expect(yield* read(path.join(project, "b[1].txt"))).toBe("b1\n")
|
||||
expect(yield* exists(path.join(project, "c.txt"))).toBe(false)
|
||||
expect(yield* exists(path.join(project, "d.txt"))).toBe(false)
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("restores and diffs a selection too long for one command line", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
const names = Array.from({ length: 300 }, (_, index) => `deep/${"n".repeat(90)}-${index}.txt`)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(path.join(project, "deep"), { recursive: true })
|
||||
await Promise.all(names.map((name) => fs.writeFile(path.join(project, name), "one\n")))
|
||||
await initGit(project, true)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const first = yield* snapshot.capture()
|
||||
if (!first) throw new globalThis.Error("capture failed")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(names.map((name) => fs.writeFile(path.join(project, name), "two\n"))),
|
||||
)
|
||||
const second = yield* snapshot.capture()
|
||||
if (!second) throw new globalThis.Error("capture failed")
|
||||
const diffs = yield* snapshot.diff({
|
||||
from: first,
|
||||
to: second,
|
||||
paths: names.map((name) => RelativePath.make(name)),
|
||||
})
|
||||
expect(diffs.map((diff) => diff.file).toSorted()).toEqual(names.toSorted())
|
||||
expect(
|
||||
diffs.every((diff) => diff.additions === 1 && diff.deletions === 1 && diff.patch.includes("+two")),
|
||||
).toBe(true)
|
||||
yield* snapshot.restore({ files: new Map(names.map((name) => [RelativePath.make(name), first])) })
|
||||
const contents = yield* Effect.forEach(names, (name) => read(path.join(project, name)))
|
||||
expect(new Set(contents)).toEqual(new Set(["one\n"]))
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("restores the other files when removing one path fails", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const project = path.join(tmp.path, "project")
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(project)
|
||||
await fs.writeFile(path.join(project, "c.txt"), "old\n")
|
||||
await initGit(project, true)
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const first = yield* snapshot.capture()
|
||||
if (!first) throw new globalThis.Error("capture failed")
|
||||
// Removing `a/b` fails on POSIX because `a` is now a file.
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.writeFile(path.join(project, "c.txt"), "changed\n")
|
||||
await fs.writeFile(path.join(project, "a"), "file\n")
|
||||
})
|
||||
yield* snapshot
|
||||
.restore({
|
||||
files: new Map([
|
||||
[RelativePath.make("a/b"), first],
|
||||
[RelativePath.make("c.txt"), first],
|
||||
]),
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(yield* read(path.join(project, "c.txt"))).toBe("old\n")
|
||||
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
|
||||
}),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).live("applies availability transforms", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -219,6 +432,23 @@ function snapshotLayer(data: string, directory: string) {
|
||||
])
|
||||
}
|
||||
|
||||
function snapshotDirectory(data: string) {
|
||||
return Effect.promise(async () => {
|
||||
const projects = await fs.readdir(path.join(data, "snapshot"))
|
||||
const project = path.join(data, "snapshot", projects[0]!)
|
||||
return path.join(project, (await fs.readdir(project))[0]!)
|
||||
})
|
||||
}
|
||||
|
||||
function exists(file: string) {
|
||||
return Effect.promise(() =>
|
||||
fs.stat(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function read(file: string) {
|
||||
return Effect.promise(() => fs.readFile(file, "utf8")).pipe(Effect.map((content) => content.replaceAll("\r\n", "\n")))
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,16 +0,0 @@
|
||||
# `@opencode/plugin`
|
||||
|
||||
Authoring interfaces and runtime loader support for OpenCode V2 plugins:
|
||||
|
||||
- `@opencode/plugin` — [Promise plugin API](./src/README.md)
|
||||
- `@opencode/plugin/effect` — [Effect plugin API](./src/effect/README.md)
|
||||
- `@opencode/plugin/rpc` — portable RPC contract definitions
|
||||
- `@opencode/plugin/tui` — terminal UI plugin API
|
||||
|
||||
## Packaging And Runtime `effect`
|
||||
|
||||
When the OpenCode CLI loads server or TUI plugins, it resolves imports of `effect`, exported `effect/*` subpaths, and `@opencode/plugin` entrypoints (including imports from dependencies inside a plugin's `node_modules`) to the host's runtime module instances so fibers, loggers, and `Schema` parsers share one copy.
|
||||
|
||||
- Declare `effect` as a `peerDependency` (and `devDependency` for local type-checking and testing) rather than a bundled runtime dependency.
|
||||
- Do not bundle `effect` into published plugin files; if you build with a bundler, keep `effect` and `effect/*` external. Two copies of `effect` do not share fiber, logger, or `Schema` internals.
|
||||
- Plugins and their `node_modules` dependencies always receive OpenCode's host `effect` instance. Use `effect` APIs and module paths compatible with the OpenCode release you target; dependencies built on another `effect` major (such as Effect 3) are not supported.
|
||||
@@ -7,11 +7,6 @@ import { localSource } from "./source.js"
|
||||
import { missingPackageTarget } from "./source.package.js"
|
||||
|
||||
let generation = Date.now()
|
||||
const runtimeModulesKey = Symbol.for("opencode.plugin.runtime-modules")
|
||||
|
||||
type GlobalState = typeof globalThis & {
|
||||
[runtimeModulesKey]?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export async function prepareSource(entrypoint: string, track: (file: string, directory?: boolean) => void) {
|
||||
const root = fileURLToPath(entrypoint)
|
||||
@@ -44,7 +39,6 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
|
||||
? new URL(item.path, pathToFileURL(file))
|
||||
: localSource(item.path, path.dirname(file))
|
||||
if (!local) {
|
||||
if ((globalThis as GlobalState)[runtimeModulesKey]?.[item.path]) continue
|
||||
try {
|
||||
Bun.resolveSync(item.path, path.dirname(file))
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, it } from "node:test"
|
||||
@@ -11,7 +11,7 @@ const source = 'throw new Error("Plugin code must not run during resolution")'
|
||||
const name = "@fixture/plugin"
|
||||
|
||||
async function fixture(files: Record<string, string>, installed = false) {
|
||||
const root = await realpath(await mkdtemp(path.join(tmpdir(), "opencode-host-")))
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-host-"))
|
||||
const directory = installed ? path.join(root, "node_modules", name) : root
|
||||
await Promise.all(
|
||||
Object.entries(files).map(async ([file, content]) => {
|
||||
|
||||
@@ -1094,6 +1094,7 @@ body {
|
||||
[data-page="stats"] [data-component="top-models-chart"] {
|
||||
--top-models-bar-gap: 12px;
|
||||
--top-models-dot-size: 6px;
|
||||
--top-models-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 34px minmax(0, 1fr);
|
||||
@@ -1223,31 +1224,38 @@ body {
|
||||
padding-inline: calc(var(--top-models-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--top-models-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--top-models-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--top-models-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--top-models-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--top-models-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--top-models-dot-size);
|
||||
-webkit-mask-size: var(--top-models-dot-size) var(--top-models-dot-size);
|
||||
}
|
||||
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="top-models-bar"]::before {
|
||||
width: round(down, calc(100% - var(--top-models-bar-gap)), var(--top-models-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="top-models-stack"] {
|
||||
position: absolute;
|
||||
right: var(--top-models-bar-inset);
|
||||
right: calc(var(--top-models-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--top-models-bar-inset);
|
||||
left: calc(var(--top-models-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
height: var(--top-models-bar-height);
|
||||
@@ -4537,6 +4545,7 @@ body {
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
--model-usage-bar-gap: 6px;
|
||||
--model-usage-dot-size: 6px;
|
||||
--model-usage-dot-offset: 2px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-rows: 40px minmax(0, 1fr);
|
||||
@@ -4594,31 +4603,38 @@ body {
|
||||
padding-inline: calc(var(--model-usage-bar-gap) / 2);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
/* Resolved by absolutely positioned children against this slot's width. Three 2px dot columns span the bar, so this inset makes the gap between bars equal the gap between dot columns. */
|
||||
--model-usage-bar-inset: calc((100% - 6px) / 6);
|
||||
}
|
||||
|
||||
/* Three dot columns aligned to the bar's left edge, center, and right edge, so they read as rising out of the bar. The SVG has no viewBox, so the 2px dot keeps its size while the cell stretches. */
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
position: absolute;
|
||||
inset: 0 var(--model-usage-bar-inset);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--model-usage-bar-gap));
|
||||
transform: translateX(-50%);
|
||||
content: "";
|
||||
background: var(--stats-dot);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
mask-position: left top;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
mask-position: var(--model-usage-dot-offset) top;
|
||||
mask-repeat: repeat;
|
||||
mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='2' height='2'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: left top;
|
||||
mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
|
||||
-webkit-mask-position: var(--model-usage-dot-offset) top;
|
||||
-webkit-mask-repeat: repeat;
|
||||
-webkit-mask-size: calc((100% - 2px) / 2) var(--model-usage-dot-size);
|
||||
-webkit-mask-size: var(--model-usage-dot-size) var(--model-usage-dot-size);
|
||||
}
|
||||
|
||||
@supports (width: round(down, 100%, 1px)) {
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
width: round(down, calc(100% - var(--model-usage-bar-gap)), var(--model-usage-dot-size));
|
||||
}
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
position: absolute;
|
||||
right: var(--model-usage-bar-inset);
|
||||
right: calc(var(--model-usage-bar-gap) / 2);
|
||||
bottom: 0;
|
||||
left: var(--model-usage-bar-inset);
|
||||
left: calc(var(--model-usage-bar-gap) / 2);
|
||||
z-index: 1;
|
||||
height: var(--model-usage-fill);
|
||||
min-height: 0;
|
||||
@@ -8180,8 +8196,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="model-usage-chart"] {
|
||||
/* 18px puts three 2px dot columns 6px apart, matching the 6px bar gap. */
|
||||
--model-usage-mobile-bar-width: 18px;
|
||||
--model-usage-mobile-bar-width: 16px;
|
||||
--model-usage-mobile-edge-space: 48px;
|
||||
--model-usage-mobile-track-width: calc(
|
||||
var(--model-usage-count) * (var(--model-usage-mobile-bar-width) + var(--model-usage-bar-gap)) +
|
||||
@@ -8222,7 +8237,7 @@ body {
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-column"]::before {
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="model-usage-bar"] {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Plugin, PluginContextProvider, usePlugin } from "@opencode/plugin/tui"
|
||||
import { ensureRuntimePluginSupport } from "@opentui/solid/runtime-plugin-support/configure"
|
||||
import { ensurePluginRuntime } from "../../../cli/src/plugin-runtime"
|
||||
|
||||
ensurePluginRuntime()
|
||||
ensureRuntimePluginSupport({
|
||||
additional: {
|
||||
"@opencode/plugin/tui": { Plugin, PluginContextProvider, usePlugin },
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRoute, useRouteData } from "../../../context/route"
|
||||
import { useData } from "../../../context/data"
|
||||
import { useClient } from "../../../context/client"
|
||||
import { useTheme } from "../../../context/theme"
|
||||
import { useStorage } from "../../../context/storage"
|
||||
import { Locale } from "../../../util/locale"
|
||||
import { Keymap } from "../../../context/keymap"
|
||||
import { useComposerTab } from "./context"
|
||||
@@ -32,9 +31,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
|
||||
const session = createMemo(() => data.session.get(props.sessionID))
|
||||
const [store, setStore] = createStore({ selected: 0 })
|
||||
// The session route remounts on navigation, so the filter lives in TUI memory and only ctrl+a changes it.
|
||||
const [filter, updateFilter] = useStorage().memory("subagents-filter", { initial: { active: true } })
|
||||
const [store, setStore] = createStore({ selected: 0, active: true })
|
||||
|
||||
const entries = createMemo<SubagentEntry[]>(() => {
|
||||
const current = session()
|
||||
@@ -59,7 +56,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
},
|
||||
)
|
||||
|
||||
return result.filter((entry) => (filter.active ? entry.status === "running" : entry.status !== "running"))
|
||||
return result.filter((entry) => (store.active ? entry.status === "running" : entry.status !== "running"))
|
||||
})
|
||||
|
||||
let selectedSessionID = ""
|
||||
@@ -73,7 +70,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
if (!active) {
|
||||
if (wasActive) {
|
||||
selectedSessionID = ""
|
||||
setStore("selected", 0)
|
||||
setStore({ selected: 0, active: true })
|
||||
}
|
||||
wasActive = false
|
||||
return
|
||||
@@ -124,7 +121,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
? [{ label: "interrupt", shortcut: shortcuts.get("composer.subagent.interrupt") ?? "" }]
|
||||
: []),
|
||||
{
|
||||
label: `show ${filter.active ? "inactive" : "active"}`,
|
||||
label: `show ${store.active ? "inactive" : "active"}`,
|
||||
shortcut: shortcuts.get("composer.subagent.toggle-activity") ?? "",
|
||||
},
|
||||
]
|
||||
@@ -175,8 +172,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
group: "Composer",
|
||||
bind: "ctrl+a",
|
||||
run() {
|
||||
updateFilter((draft) => (draft.active = !draft.active))
|
||||
setStore("selected", 0)
|
||||
setStore({ selected: 0, active: !store.active })
|
||||
scroll?.scrollTo(0)
|
||||
},
|
||||
},
|
||||
@@ -198,7 +194,7 @@ export function SubagentsTab(props: { sessionID: string }) {
|
||||
<scrollbox scrollbarOptions={{ visible: false }} maxHeight={5} ref={(r: ScrollBoxRenderable) => (scroll = r)}>
|
||||
<Show
|
||||
when={entries().length > 0}
|
||||
fallback={<text fg={theme.text.muted}> No {filter.active ? "active" : "inactive"} subagents</text>}
|
||||
fallback={<text fg={theme.text.muted}> No {store.active ? "active" : "inactive"} subagents</text>}
|
||||
>
|
||||
<For each={entries()}>
|
||||
{(entry, index) => {
|
||||
|
||||
@@ -126,6 +126,7 @@ function GroupContent(props: GroupProps) {
|
||||
)
|
||||
return `${completed() ? "Explored" : "Exploring"}: ${names.join(", ")}`
|
||||
})
|
||||
const failed = () => tools().some((part) => part.state.status === "error")
|
||||
const toggle = disclosure.toggle
|
||||
const children = (mode: "normal" | "thought" | "tool") => (
|
||||
<Children {...props} nodes={props.node.children} mode={mode} />
|
||||
@@ -142,7 +143,7 @@ function GroupContent(props: GroupProps) {
|
||||
<Show when={grouped()} fallback={children("normal")}>
|
||||
<Show when={tools().length > 0}>
|
||||
<InlineToolRow
|
||||
icon={completed() ? "→" : "✱"}
|
||||
icon={failed() ? "✗" : completed() ? "→" : "✱"}
|
||||
color={hover() ? theme.text.base : theme.text.muted}
|
||||
complete={completed()}
|
||||
pending={label()}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { afterAll, expect, test } from "bun:test"
|
||||
import { onMount, Show } from "solid-js"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onMount } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import type { TuiKeybind } from "../../../src/config/keybind"
|
||||
import { ClientProvider } from "../../../src/context/client"
|
||||
@@ -9,13 +9,10 @@ import { DataProvider, useData } from "../../../src/context/data"
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider } from "../../../src/context/location"
|
||||
import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { StorageProvider } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
@@ -24,12 +21,8 @@ const sessions = {
|
||||
parent: session("parent", "Parent"),
|
||||
"child-a": session("child-a", "First", "parent"),
|
||||
"child-b": session("child-b", "Second", "parent"),
|
||||
"child-c": session("child-c", "Third", "parent"),
|
||||
}
|
||||
|
||||
const state = await tmpdir()
|
||||
afterAll(() => state[Symbol.asyncDispose]())
|
||||
|
||||
const shells = [shell("sh-a", "bun test"), shell("sh-b", "bun dev"), shell("sh-c", "python3 - <<'PY'\nimport json")]
|
||||
|
||||
async function renderComposer(
|
||||
@@ -85,7 +78,6 @@ async function renderComposer(
|
||||
data.session.sync("parent"),
|
||||
data.session.sync("child-a"),
|
||||
data.session.sync("child-b"),
|
||||
data.session.sync("child-c"),
|
||||
data.shell.sync(),
|
||||
])
|
||||
.then(() => wait(() => data.session.status("child-a") === "running"))
|
||||
@@ -94,12 +86,7 @@ async function renderComposer(
|
||||
return (
|
||||
<>
|
||||
{focusedTextarea && <textarea focused={true} initialValue="draft" />}
|
||||
{/* Mirrors the app, which keys the session route by sessionID and remounts it on navigation. */}
|
||||
<Show when={route.data.type === "session" ? route.data.sessionID : undefined} keyed>
|
||||
{(sessionID) => (
|
||||
<Composer sessionID={sessionID} open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
)}
|
||||
</Show>
|
||||
<Composer sessionID="parent" open={true} defaultTab={defaultTab} onClose={() => closed++} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -115,31 +102,27 @@ async function renderComposer(
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={directory} paths={{ state: state.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
<TestTuiContexts directory={directory}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ keybinds })}>
|
||||
<Keymap.Provider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<DataProvider directory={process.cwd()}>
|
||||
<LocationProvider>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "parent" }}>
|
||||
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Content />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</RouteProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
<AppExit />
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width: 100, height: 20, kittyKeyboard: true },
|
||||
@@ -182,33 +165,6 @@ test("disabled subagent bindings have no component fallbacks", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("the inactive subagent filter only changes with its toggle", async () => {
|
||||
const composer = await renderComposer("subagents", {})
|
||||
try {
|
||||
expect(composer.app.captureCharFrame()).toContain("First")
|
||||
expect(composer.app.captureCharFrame()).not.toContain("Third")
|
||||
|
||||
composer.app.mockInput.pressKey("a", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("Third")
|
||||
expect(composer.app.captureCharFrame()).not.toContain("First")
|
||||
|
||||
composer.app.mockInput.pressEnter()
|
||||
expect(composer.route()).toMatchObject({ type: "session", sessionID: "child-c" })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("Third")
|
||||
expect(composer.app.captureCharFrame()).toContain("show active")
|
||||
expect(composer.app.captureCharFrame()).not.toContain("First")
|
||||
|
||||
composer.app.mockInput.pressKey("a", { ctrl: true })
|
||||
await composer.app.renderOnce()
|
||||
expect(composer.app.captureCharFrame()).toContain("First")
|
||||
expect(composer.app.captureCharFrame()).not.toContain("Third")
|
||||
} finally {
|
||||
composer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("disabled shell bindings have no component fallbacks", async () => {
|
||||
const composer = await renderComposer("shell", {
|
||||
"composer.shell.up": "none",
|
||||
|
||||
@@ -12,8 +12,6 @@ import { DataProvider as DataProviderBase, useData } from "../../../src/context/
|
||||
import { Keymap } from "../../../src/context/keymap"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { RouteProvider } from "../../../src/context/route"
|
||||
import { TuiAppProvider } from "../../../src/context/runtime"
|
||||
import { StorageProvider } from "../../../src/context/storage"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
@@ -22,7 +20,7 @@ import { createSessionRows, type SessionRow } from "../../../src/routes/session/
|
||||
import { groupRefs } from "../../../src/routes/session/grouping/session"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
@@ -2232,7 +2230,6 @@ test("keeps shell state scoped to location", async () => {
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
await using state = await tmpdir()
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
@@ -2252,18 +2249,14 @@ test("keeps shell state scoped to location", async () => {
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts paths={{ state: state.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
app.renderer.start()
|
||||
|
||||
@@ -9,8 +9,6 @@ import { ClientProvider } from "../../src/context/client"
|
||||
import { DataProvider, useData } from "../../src/context/data"
|
||||
import { Keymap } from "../../src/context/keymap"
|
||||
import { RouteProvider } from "../../src/context/route"
|
||||
import { TuiAppProvider } from "../../src/context/runtime"
|
||||
import { StorageProvider } from "../../src/context/storage"
|
||||
import { ThemeProvider } from "../../src/context/theme"
|
||||
import { Composer } from "../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../src/ui/dialog"
|
||||
@@ -68,27 +66,23 @@ async function setup(width: number, output = "") {
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts directory={temporary.path} paths={{ state: temporary.path }}>
|
||||
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
|
||||
<StorageProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Shells />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</StorageProvider>
|
||||
</TuiAppProvider>
|
||||
<ConfigProvider config={createTuiResolvedConfig()}>
|
||||
<RouteProvider initialRoute={{ type: "session", sessionID: "ses_fixture" }}>
|
||||
<ClientProvider api={api}>
|
||||
<DataProvider directory={temporary.path}>
|
||||
<ThemeProvider mode={width === 40 ? "light" : "dark"} source={emptyThemeSource}>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<DialogProvider>
|
||||
<Shells />
|
||||
</DialogProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ThemeProvider>
|
||||
</DataProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ConfigProvider>
|
||||
</TestTuiContexts>
|
||||
),
|
||||
{ width, height: 30, kittyKeyboard: true },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createRequire } from "node:module"
|
||||
import path from "node:path"
|
||||
import { mkdir, rename, symlink } from "node:fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
@@ -8,7 +7,6 @@ import "../src/plugin/runtime-plugin-support.bun"
|
||||
import { createPluginSources } from "../src/plugin/source"
|
||||
import { createSourceWatcher } from "../src/plugin/watch"
|
||||
import { createSignal } from "solid-js"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Plugin } from "@opencode/plugin/tui"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -140,136 +138,6 @@ test("shared runtime and ordinary package identities survive plugin generations"
|
||||
}
|
||||
})
|
||||
|
||||
test("TUI plugins importing @opencode/plugin/tui and solid-js alongside effect resolve effect and effect/* to the host copy without rewriting host Effect or hijacking plugin dependencies", async () => {
|
||||
const effectPlugin = await import("@opencode/plugin/effect")
|
||||
const req = createRequire(import.meta.url)
|
||||
expect(req("effect").Effect).toBe(Effect)
|
||||
expect(req("effect/Option").some).toBe(Option.some)
|
||||
expect(req("@opencode/plugin/effect").Plugin).toBe(effectPlugin.Plugin)
|
||||
expect(JSON.stringify(Schema.Option(Schema.String).ast)).not.toContain("opentui:runtime-module:")
|
||||
|
||||
const standalone = Bun.spawnSync(
|
||||
[
|
||||
process.execPath,
|
||||
"-e",
|
||||
[
|
||||
`import ${JSON.stringify(fileURLToPath(new URL("../src/plugin/runtime-plugin-support.bun.ts", import.meta.url)))}`,
|
||||
'import assert from "node:assert/strict"',
|
||||
'import { createRequire } from "node:module"',
|
||||
"const req = createRequire(import.meta.url)",
|
||||
'const eff = req("effect")',
|
||||
'const opt = req("effect/Option")',
|
||||
'const plug = req("@opencode/plugin/effect")',
|
||||
'const { Effect } = await import("effect")',
|
||||
'const { some } = await import("effect/Option")',
|
||||
'const { Plugin } = await import("@opencode/plugin/effect")',
|
||||
"assert.equal(eff.Effect, Effect)",
|
||||
"assert.equal(opt.some, some)",
|
||||
"assert.equal(plug.Plugin, Plugin)",
|
||||
].join("\n"),
|
||||
],
|
||||
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
|
||||
)
|
||||
expect({ stderr: standalone.stderr.toString(), exit: standalone.exitCode }).toEqual({ stderr: "", exit: 0 })
|
||||
|
||||
const watched: string[] = []
|
||||
await using sources = await fixture(async (file) => {
|
||||
watched.push(file)
|
||||
})
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
const badEntry = new URL("bad-tui.ts", sources.url)
|
||||
const badCacheEntry = new URL("bad-cache-tui.ts", sources.url)
|
||||
const badHostEntry = new URL("bad-host.js", sources.url)
|
||||
const absExtra = new URL("abs-extra.ts", sources.url)
|
||||
const cacheIndex = new URL("cache/effect@9.9.9@@@1/dist/index.js", sources.url)
|
||||
const cacheOption = new URL("cache/effect@9.9.9@@@1/dist/Option.js", sources.url)
|
||||
const cacheRemoved = new URL("cache/effect@9.9.9@@@1/dist/RemovedSubpath.js", sources.url)
|
||||
const nmOptionQuery = `${fileURLToPath(new URL("node_modules/effect/dist/esm/Option.js", sources.url))}?v=1#tag`
|
||||
await Promise.all(
|
||||
Object.entries({
|
||||
"node_modules/effect/package.json":
|
||||
'{"name":"effect","version":"3.19.19","type":"module","exports":{".":{"import":"./dist/esm/index.js"},"./Option":{"import":"./dist/esm/Option.js"},"./RemovedSubpath":{"import":"./dist/esm/RemovedSubpath.js"},"./package.json":"./package.json"}}',
|
||||
"node_modules/effect/dist/esm/index.js":
|
||||
"export const Effect = { foreign: true }; export const Schema = { foreign: true }",
|
||||
"node_modules/effect/dist/esm/Option.js": "export const some = () => null",
|
||||
"node_modules/effect/dist/esm/RemovedSubpath.js": "export const removed = true",
|
||||
"cache/effect@9.9.9@@@1/package.json":
|
||||
'{"name":"effect","version":"9.9.9","type":"module","exports":{".":"./dist/index.js","./Option":"./dist/Option.js","./RemovedSubpath":"./dist/RemovedSubpath.js"}}',
|
||||
"cache/effect@9.9.9@@@1/dist/index.js": "export const Effect = { cachedForeign: true }",
|
||||
"cache/effect@9.9.9@@@1/dist/Option.js": "export const some = () => 'cached-foreign'",
|
||||
"cache/effect@9.9.9@@@1/dist/RemovedSubpath.js": "export const removed = 'cached-removed'",
|
||||
"node_modules/effect-helper/package.json":
|
||||
'{"name":"effect-helper","type":"module","exports":{".":"./index.js"}}',
|
||||
"node_modules/effect-helper/index.js": [
|
||||
'import { Effect, Schema }',
|
||||
'from "effect"; import { some }',
|
||||
'from "effect/Option"; export const helper = { Effect, Schema, some }',
|
||||
].join(" "),
|
||||
"node_modules/zod/package.json": '{"name":"zod","type":"module","exports":{".":"./index.js"}}',
|
||||
"node_modules/zod/index.js": "export const fromPluginZod = true",
|
||||
"helper.ts": ['import { fromPluginZod }', 'from "zod"; export { fromPluginZod }'].join(" "),
|
||||
"abs-extra.ts": 'export const absValue = "own-abs-file"',
|
||||
"bad-host.js": ['import', '"file://non-local-host/share/missing.js"'].join(" "),
|
||||
"tui.ts": [
|
||||
'import { createSignal }',
|
||||
'from "solid-js"\nimport { Plugin }',
|
||||
'from "@opencode/plugin/tui"\nimport { Plugin as HostEffectPlugin }',
|
||||
'from "@opencode/plugin/effect"\nimport { Effect, Schema }',
|
||||
'from "effect"\nimport { some }',
|
||||
'from "effect/Option"\nimport { some as cachedSome }',
|
||||
`from ${JSON.stringify(cacheOption.href)}\nimport pkg`,
|
||||
'from "effect/package.json" with { type: "json" }\nimport { helper }',
|
||||
'from "effect-helper"\nconst { fromPluginZod } = await',
|
||||
'import("./helper.ts")\nconst dynOption = await',
|
||||
'import("effect/Option")\nconst dynCachedEffect = await',
|
||||
`import(${JSON.stringify(fileURLToPath(cacheIndex))})\nconst queryOption = await`,
|
||||
`import(${JSON.stringify(nmOptionQuery)})\nconst dynEffectPlugin = await`,
|
||||
`import("@opencode/plugin/effect")\nconst { absValue } = await import(${JSON.stringify(fileURLToPath(absExtra))})`,
|
||||
"\nexport const plugin = { createSignal, Plugin, HostEffectPlugin, Effect, Schema, some, cachedSome, dynCachedEffect: dynCachedEffect.Effect, querySome: queryOption.some, dynSome: dynOption.some, dynEffectPlugin: dynEffectPlugin.Plugin, absValue, pkgName: pkg.name, fromPluginZod }\nexport { helper }",
|
||||
].join(" "),
|
||||
"bad-tui.ts": [
|
||||
'import { Plugin }',
|
||||
'from "@opencode/plugin/tui"; import { removed }',
|
||||
'from "effect/RemovedSubpath"; export default { Plugin, removed }',
|
||||
].join(" "),
|
||||
"bad-cache-tui.ts": [
|
||||
'import { removed }',
|
||||
`from ${JSON.stringify(cacheRemoved.href)}; export default { removed }`,
|
||||
].join(" "),
|
||||
}).map(([file, text]) => Bun.write(new URL(file, sources.url), text)),
|
||||
)
|
||||
const loaded = (await sources.read(entry.href)).module as {
|
||||
plugin: Record<string, unknown>
|
||||
helper: Record<string, unknown>
|
||||
}
|
||||
expect(loaded.plugin.createSignal).toBe(createSignal)
|
||||
expect(loaded.plugin.Plugin).toBe(Plugin)
|
||||
expect(loaded.plugin.HostEffectPlugin).toBe(effectPlugin.Plugin)
|
||||
expect(loaded.plugin.Effect).toBe(Effect)
|
||||
expect(loaded.plugin.Schema).toBe(Schema)
|
||||
expect(loaded.plugin.some).toBe(Option.some)
|
||||
expect(loaded.plugin.cachedSome).toBe(Option.some)
|
||||
expect(loaded.plugin.dynCachedEffect).toBe(Effect)
|
||||
expect(loaded.plugin.querySome).toBe(Option.some)
|
||||
expect(loaded.plugin.dynSome).toBe(Option.some)
|
||||
expect(loaded.plugin.dynEffectPlugin).toBe(effectPlugin.Plugin)
|
||||
expect(loaded.plugin.absValue).toBe("own-abs-file")
|
||||
expect(loaded.plugin.pkgName).toBe("effect")
|
||||
expect(loaded.plugin.fromPluginZod).toBe(true)
|
||||
expect(loaded.helper.Effect).toBe(Effect)
|
||||
expect(loaded.helper.Schema).toBe(Schema)
|
||||
expect(loaded.helper.some).toBe(Option.some)
|
||||
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/effect"))).toBe(false)
|
||||
expect(watched.some((item) => item.replaceAll("\\", "/").endsWith("/node_modules/@opencode/plugin"))).toBe(false)
|
||||
await expect(sources.read(badEntry.href)).rejects.toThrow("effect/dist/esm/RemovedSubpath.js")
|
||||
await expect(sources.read(badCacheEntry.href)).rejects.toThrow("effect/dist/RemovedSubpath.js")
|
||||
const badHostStack = await import(fileURLToPath(badHostEntry)).then(
|
||||
() => "",
|
||||
(err) => String(err?.stack ?? err),
|
||||
)
|
||||
expect(badHostStack).not.toContain("plugin-runtime.ts")
|
||||
})
|
||||
|
||||
test("helper import.meta stays anchored to its source, including assets and resolution", async () => {
|
||||
await using sources = await fixture()
|
||||
const entry = new URL("tui.ts", sources.url)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Glob } from "./glob.js"
|
||||
import { serviceUse } from "./effect/service-use.js"
|
||||
import { makeGlobalNode } from "./effect/app-node.js"
|
||||
import { filesystem } from "./effect/app-node-platform.js"
|
||||
import { sameDirectory } from "./path.js"
|
||||
|
||||
export namespace FSUtil {
|
||||
export class FileSystemError extends Schema.TaggedError<FileSystemError>()("FileSystemError", {
|
||||
@@ -179,7 +178,7 @@ export namespace FSUtil {
|
||||
if (options.mode === "first") return result
|
||||
}
|
||||
}
|
||||
if (options.stop && sameDirectory(options.stop, current)) break
|
||||
if (options.stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
@@ -199,7 +198,7 @@ export namespace FSUtil {
|
||||
Effect.orElseSucceed(() => [] as string[]),
|
||||
)
|
||||
result.push(...matches)
|
||||
if (stop && sameDirectory(stop, current)) break
|
||||
if (stop === current) break
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
|
||||
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user