mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-09 18:36:22 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95503c1773 | ||
|
|
ba1448325a | ||
|
|
be2582f316 | ||
|
|
c0cb1c7a91 | ||
|
|
e10408b219 | ||
|
|
bdc143c1d5 | ||
|
|
d52380024d | ||
|
|
f1eed8bf11 | ||
|
|
4ea368e09e | ||
|
|
c6977a836f | ||
|
|
1dcc6551d9 | ||
|
|
9c8fb89979 | ||
|
|
51c926c3ac | ||
|
|
65152b7936 | ||
|
|
3c4c7b41be | ||
|
|
d461154a8d | ||
|
|
cebd25022f | ||
|
|
9128e847bd | ||
|
|
8b92833624 | ||
|
|
f4dd76913f | ||
|
|
ef88566d61 | ||
|
|
b3f765c17d | ||
|
|
ab2366de2e |
@@ -153,6 +153,15 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(observation, "retry-full")
|
||||
if (rejection === "websocket_connection_limit_reached") return rejected(observation, "rotate-and-retry-full")
|
||||
// Only the continuation distinguishes an incremental send from a full one, so an unclassified
|
||||
// invalid request there is retried full; Codex reports a stale previous_response_id that way, with
|
||||
// no code. Classified failures such as context overflow keep their runner-owned recovery.
|
||||
if (
|
||||
create.mode === "incremental" &&
|
||||
observation.error.reason._tag === "InvalidRequest" &&
|
||||
observation.error.reason.classification === undefined
|
||||
)
|
||||
return rejected(observation, "retry-full")
|
||||
}
|
||||
if (observation.type !== "completed") return observation
|
||||
// A trigger installs a different context window. Clear the append baseline, retaining the socket.
|
||||
|
||||
@@ -115,8 +115,11 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
}
|
||||
const onAbort = () => {
|
||||
cleanup()
|
||||
if (ws.readyState !== globalThis.WebSocket.CLOSED && ws.readyState !== globalThis.WebSocket.CLOSING)
|
||||
ws.close(1000)
|
||||
if (ws.readyState === globalThis.WebSocket.CLOSED || ws.readyState === globalThis.WebSocket.CLOSING) return
|
||||
// Node's ws reports an aborted handshake as an error event on the next tick; with no listener left
|
||||
// after cleanup, EventEmitter would throw it as an uncaught exception.
|
||||
ws.addEventListener("error", () => {}, { once: true })
|
||||
ws.close(1000)
|
||||
}
|
||||
const onOpen = () => {
|
||||
cleanup()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
@@ -30,6 +30,7 @@ import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenResponsesContinuation } from "../../src/protocols/open-responses-continuation.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
@@ -69,14 +70,34 @@ const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
},
|
||||
})
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
/** Classifies error frames the way the production channel does, so recovery can read the canonical reason. */
|
||||
const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
const base = baseChannelDriver(message)
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenResponses.protocol.stream.event)
|
||||
return {
|
||||
...base,
|
||||
observe: (create, frame) =>
|
||||
base.observe(create, frame).pipe(
|
||||
Effect.map((observation) =>
|
||||
observation.type === "provider-failure"
|
||||
? {
|
||||
...observation,
|
||||
error: OpenResponses.providerFailure(decodeEvent(frame), "stream error", frame),
|
||||
}
|
||||
: observation,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
message,
|
||||
base: baseChannelDriver(message),
|
||||
base: base(message),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -852,6 +873,53 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retries an incremental send in full when the provider rejects it without a code", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest, classifyingChannelDriver)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
yield* first.create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver(
|
||||
{
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
},
|
||||
classifyingChannelDriver,
|
||||
)
|
||||
// Codex reports a stale previous_response_id as a plain invalid_request_error.
|
||||
const stale = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", message: "Invalid `previous_response_id`." },
|
||||
})
|
||||
const incremental = yield* second.create(saved)
|
||||
expect(incremental.mode).toBe("incremental")
|
||||
expect(yield* second.observe(incremental, stale)).toMatchObject({ type: "rejected", recovery: "retry-full" })
|
||||
|
||||
// A full send has no continuation to blame, so the same error stays a provider failure.
|
||||
const full = yield* second.create(undefined)
|
||||
expect(yield* second.observe(full, stale)).toMatchObject({ type: "provider-failure" })
|
||||
|
||||
// A classified failure keeps its runner-owned recovery instead of resending the whole context.
|
||||
const overflow = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "invalid_request_error", code: "context_length_exceeded", message: "Too long" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), overflow)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
|
||||
Effect.gen(function* () {
|
||||
const attempts = yield* Ref.make(0)
|
||||
|
||||
@@ -39,7 +39,7 @@ for (const width of [1400, 390]) {
|
||||
reducedMotion: true,
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
await page.getByRole("button", { name: "1 used Patch", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Patch", exact: true }).click()
|
||||
const patch = page.locator('[data-component="apply-patch-tool"]')
|
||||
const trigger = patch.getByRole("button", { name: /patch-border.ts/ })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test, type Locator } from "@playwright/test"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -85,6 +85,38 @@ for (const width of [1000, 1440]) {
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(closed)
|
||||
})
|
||||
|
||||
test(`keeps moving header content out of the toggle area (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Review toggle position")
|
||||
await page.locator("html").evaluate((element, dir) => element.setAttribute("dir", dir), direction)
|
||||
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||
for (const opened of [true, false]) {
|
||||
// Pause in the same task as the click so even the first painted state can be inspected.
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", String(!opened))
|
||||
for (const progress of [0.08, 0.16, 0.25, 0.5, 0.8, 0.96]) {
|
||||
await expectHeaderClearOfToggle(page, toggle, progress)
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.finish())
|
||||
})
|
||||
}
|
||||
await expect(page.locator("#review-panel")).toBeHidden()
|
||||
})
|
||||
|
||||
test(`keeps terminal controls clear of the review toggle (${width}px, ${direction})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const ptys: { id: string; title: string }[] = []
|
||||
@@ -166,10 +198,84 @@ for (const width of [1000, 1440]) {
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect.poll(() => toggle.boundingBox()).toEqual(position)
|
||||
await expectTerminalControlsAligned(terminal, toggle)
|
||||
|
||||
// Closing the terminal clears the region's animation flag while retaining the review contents.
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator('[data-slot="session-chat-panel"]')
|
||||
.evaluate((element) => element.getAnimations().every((animation) => animation.playState === "finished")),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(page.locator('[data-slot="session-review-content"]')).toHaveCSS("opacity", "0")
|
||||
await toggle.evaluate((element) => {
|
||||
;(element as HTMLButtonElement).click()
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
.forEach((animation) => animation.pause())
|
||||
})
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expectHeaderClearOfToggle(page, toggle, 0.25)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHeaderClearOfToggle(page: Page, toggle: Locator, progress: number) {
|
||||
const geometry = await page.locator('[data-slot="session-chat-panel"]').evaluate((chat, progress) => {
|
||||
const row = chat.parentElement!
|
||||
const animations = row
|
||||
.getAnimations({ subtree: true })
|
||||
.filter((animation) => animation.timeline instanceof DocumentTimeline)
|
||||
const width = animations.find(
|
||||
(animation) => animation instanceof CSSTransition && animation.transitionProperty === "width",
|
||||
)!
|
||||
animations.forEach((animation) => {
|
||||
animation.pause()
|
||||
animation.currentTime = Number(width.effect!.getTiming().duration) * progress
|
||||
})
|
||||
const chatBounds = chat.getBoundingClientRect()
|
||||
const panelBounds = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
const summaryBounds = document
|
||||
.querySelector('[data-session-title] button[aria-label="Session details"]')!
|
||||
.getBoundingClientRect()
|
||||
return {
|
||||
row: row.getBoundingClientRect().width,
|
||||
panelWidth: panelBounds.width,
|
||||
timelineControlInset:
|
||||
getComputedStyle(row).direction === "rtl"
|
||||
? summaryBounds.left - chatBounds.left
|
||||
: chatBounds.right - summaryBounds.right,
|
||||
gap:
|
||||
getComputedStyle(row).direction === "rtl"
|
||||
? chatBounds.left - panelBounds.right
|
||||
: panelBounds.left - chatBounds.right,
|
||||
contentOpacity: Number(getComputedStyle(document.querySelector('[data-slot="session-review-content"]')!).opacity),
|
||||
panels: chatBounds.width + panelBounds.width + parseFloat(getComputedStyle(row).columnGap),
|
||||
}
|
||||
}, progress)
|
||||
expect(geometry.gap).toBeCloseTo(8, 1)
|
||||
// Reserve the fixed toggle's 28px width, the 8px control gap, and the 12px header inset.
|
||||
expect(geometry.timelineControlInset).toBeCloseTo(48, 1)
|
||||
if (geometry.panelWidth > 0) expect(Math.abs(geometry.row - geometry.panels)).toBeLessThanOrEqual(1)
|
||||
if (progress === 0.25) {
|
||||
expect(geometry.contentOpacity).toBeGreaterThan(0)
|
||||
expect(geometry.contentOpacity).toBeLessThan(1)
|
||||
}
|
||||
|
||||
const clip = await toggle.boundingBox()
|
||||
if (!clip) throw new Error("Review toggle bounds are unavailable")
|
||||
// Header contents must make no difference to the pixels behind the fixed toggle.
|
||||
expect(await page.screenshot({ clip })).toEqual(
|
||||
await page.screenshot({
|
||||
clip,
|
||||
style: ".session-review-v2-tabs-bar { visibility: hidden !important; }",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { setupTimeline } from "../performance/timeline-stability/fixture"
|
||||
|
||||
for (const reducedMotion of [false, true]) {
|
||||
test(`suppresses the scrollbar from toggle press until timeline interaction (reduced motion: ${reducedMotion})`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await setupTimeline(page, { seedHistory: true, reducedMotion })
|
||||
const chat = page.locator('[data-slot="session-chat-panel"]')
|
||||
const scroll = page.locator('[data-slot="session-timeline-scroll"]')
|
||||
const viewport = scroll.locator(".scroll-view__viewport")
|
||||
const thumb = scroll.locator('.scroll-view__thumb[data-orientation="vertical"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
await expect(thumb).toHaveCount(1)
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
|
||||
for (const opened of [true, false]) {
|
||||
await toggle.hover()
|
||||
await page.mouse.down()
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await page.mouse.up()
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", String(opened))
|
||||
await chat.evaluate(async (element) => {
|
||||
await Promise.all(element.getAnimations().map((animation) => animation.finished))
|
||||
})
|
||||
await expect(chat).toHaveAttribute("data-width-animating", "false")
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
// Late scroll anchoring must not bring the thumb back after the panel has settled.
|
||||
await viewport.evaluate(
|
||||
(element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
element.addEventListener("scroll", () => resolve(), { once: true })
|
||||
element.scrollTop += element.scrollTop > 0 ? -1 : 1
|
||||
}),
|
||||
)
|
||||
await expect(thumb).toHaveCSS("visibility", "hidden")
|
||||
await scroll.hover()
|
||||
await expect(thumb).toHaveAttribute("data-visible", "true")
|
||||
await expect(thumb).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -308,7 +308,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
const tools = page.locator('[data-timeline-part-ids="tool_queue_read,tool_queue_grep"]')
|
||||
await expect(tools).toBeVisible()
|
||||
await expect(tools).toHaveText(/^2 used\s*Read, Grep$/)
|
||||
await expect(tools).toHaveText(/^Used\s*2\s*Read, Grep$/)
|
||||
await expect(tools.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Read, Grep")
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toBeVisible()
|
||||
@@ -318,7 +318,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await transcript.screenshot({ path: testInfo.outputPath("pending-steer.png") })
|
||||
|
||||
// Soft assertions let delivery run too, even when the pending ordering regresses.
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^2 used\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect.soft(tools.or(pending)).toHaveText([/^Used\s*2\s*Read, Grep$/, /U2: Also check the retry path\./])
|
||||
await expect
|
||||
.soft(transcript.locator('[data-timeline-row="AssistantPart"]').filter({ has: tools }))
|
||||
.toHaveAttribute("data-message-id", userID)
|
||||
@@ -350,7 +350,7 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(tools.or(pending).or(response)).toHaveText([
|
||||
/^2 used\s*Read, Grep$/,
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/A3: Now checking the retry path for U2\./,
|
||||
])
|
||||
|
||||
@@ -25,7 +25,7 @@ test("space activates a focused timeline button instead of scrolling", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const trigger = page.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
const trigger = page.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
|
||||
.toBeGreaterThan(300)
|
||||
|
||||
@@ -93,8 +93,8 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: /^\d+ used Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("1 used Patch")
|
||||
const summary = group.getByRole("button", { name: /^Used \d+ Patch$/ })
|
||||
await expect(summary).toHaveAccessibleName("Used 1 Patch")
|
||||
await summary.click()
|
||||
await group.locator(`[data-timeline-part-id="${editPartID}"]`).evaluate((element) => {
|
||||
element.setAttribute("data-disclosure-probe", "existing")
|
||||
@@ -110,7 +110,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
if (count === 3) await trigger.click()
|
||||
const id = `prt_patch_${count}`
|
||||
events.push(...toolEvents({ ...part, id, callID: id }))
|
||||
await expect(summary).toHaveAccessibleName(`${count} used Patch`)
|
||||
await expect(summary).toHaveAccessibleName(`Used ${count} Patch`)
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch")
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", new RegExp(`${id}$`))
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(count === 2))
|
||||
|
||||
@@ -55,7 +55,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
const context = page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first()
|
||||
await expectAppVisible(context)
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
|
||||
const contextSelector = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
@@ -88,7 +88,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("4 used Read, Glob, Grep, List")
|
||||
await expect(context.getByRole("button")).toHaveAccessibleName("Used 4 Read, Glob, Grep, List")
|
||||
await page.waitForTimeout(700)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
const labels = trace.samples
|
||||
@@ -107,7 +107,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
expect(labels).toEqual(["4 used Read, Glob, Grep, List"])
|
||||
expect(labels).toEqual(["Used 4 Read, Glob, Grep, List"])
|
||||
expect(issues, JSON.stringify(trace.samples, null, 2)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,7 +17,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
|
||||
const group = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const names = locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob"
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`2 used ${names}`)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(`Used 2 ${names}`)
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText(names)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", locale)
|
||||
})
|
||||
|
||||
@@ -386,7 +386,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const used = page
|
||||
.locator('[data-timeline-part-ids="call_backgrounded,call_shell_backgrounded,call_blocking"]')
|
||||
.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(used).toHaveText(/^3 used\s*Agent, Shell$/)
|
||||
await expect(used).toHaveText(/^Used\s*3\s*Agent, Shell$/)
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
@@ -46,7 +46,7 @@ test("changes timeline presets and saves custom thinking details", async ({ page
|
||||
.toEqual({ placement: "grouped", details: "collapsed" })
|
||||
await settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await page.getByRole("button", { name: "1 used Thought", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Thought", exact: true }).click()
|
||||
await expect(part.getByRole("button")).toHaveAttribute("aria-expanded", "false")
|
||||
await part.getByRole("button").click()
|
||||
await expect(part.getByText("The selected mode controls these details.", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -45,7 +45,7 @@ test("expands a mixed collapsed tool stack without expanding its individual call
|
||||
const group = page.locator(
|
||||
'[data-timeline-part-ids="prt_stack_shell_1,prt_stack_explore,prt_stack_patch,prt_stack_shell_2"]',
|
||||
)
|
||||
const summary = group.getByRole("button", { name: "4 used Shell, Agent, Patch", exact: true })
|
||||
const summary = group.getByRole("button", { name: "Used 4 Shell, Agent, Patch", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toHaveCSS("height", "28px")
|
||||
await expect(summary.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Agent, Patch")
|
||||
@@ -75,7 +75,7 @@ test("leaves tools expanded by settings outside the collapsed stack", async ({ p
|
||||
|
||||
await expect(page.locator('[data-timeline-part-id="prt_expanded_shell"]')).toBeVisible()
|
||||
const group = page.locator('[data-timeline-part-ids="prt_collapsed_patch,prt_collapsed_read"]')
|
||||
await expect(group.getByRole("button", { name: "2 used Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.getByRole("button", { name: "Used 2 Patch, Read", exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Patch, Read")
|
||||
await expect(page.locator('[data-timeline-spacing="tool"]')).toHaveCSS("padding-top", "8px")
|
||||
})
|
||||
@@ -114,7 +114,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
],
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "2 used Shell, Patch", exact: true }).click()
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
@@ -129,7 +129,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
),
|
||||
),
|
||||
)
|
||||
await expect(group.getByRole("button", { name: "3 used Shell, Patch", exact: true })).toHaveAttribute(
|
||||
await expect(group.getByRole("button", { name: "Used 3 Shell, Patch", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
@@ -162,7 +162,7 @@ test("keeps failed search calls and their error cards inside the collapsed stack
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
|
||||
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
const summary = group.getByRole("button", { name: "2 used Glob, Grep", exact: true })
|
||||
const summary = group.getByRole("button", { name: "Used 2 Glob, Grep", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(group.locator('[data-kind="tool-error-card"]')).toHaveCount(2)
|
||||
|
||||
@@ -181,7 +181,7 @@ for (const grouped of [false, true]) {
|
||||
await expect(working).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
const trigger = group.getByRole("button", { name: "2 used Shell", exact: true, includeHidden: true })
|
||||
const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(working).toBeVisible()
|
||||
await trigger.click()
|
||||
|
||||
@@ -51,7 +51,7 @@ test("shows parent lineage while the child timeline loads", async ({ page }) =>
|
||||
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await Promise.all([requested.promise, expect(page).toHaveURL(sessionHref(childID))])
|
||||
await Promise.all([
|
||||
@@ -76,7 +76,7 @@ test("keeps the parent visible while the child session resolves", async ({ page
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
|
||||
await requested.promise
|
||||
await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
|
||||
@@ -194,7 +194,7 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function openChildFromParent(page: Page) {
|
||||
await page.goto(sessionHref(parentID))
|
||||
await expectSessionTitle(page, parentTitle)
|
||||
await page.getByRole("button", { name: "1 used Agent", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
|
||||
|
||||
const card = page.locator(`a[href="${sessionHref(childID)}"]`)
|
||||
await expect(card).toBeVisible()
|
||||
|
||||
@@ -38,12 +38,65 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
[data-slot="session-chat-panel"][data-scrollbar-hidden="true"]
|
||||
[data-slot="session-timeline-scroll"]
|
||||
> .scroll-view__thumb {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="true"] {
|
||||
animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
[data-slot="session-side-panel-presence"][data-opened="false"] {
|
||||
animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
|
||||
#review-panel {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* Like the composer toolbar, fade only clipped content without reserving layout space.
|
||||
The fade contracts as overflow clears; the second mask preserves the header divider. */
|
||||
#review-panel .session-review-v2-tabs-bar {
|
||||
--session-review-header-fade: clamp(0px, calc(100% - 100cqi), 24px);
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to right,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
|
||||
&:dir(rtl) {
|
||||
mask-image:
|
||||
linear-gradient(
|
||||
to left,
|
||||
#000 calc(100cqi - 40px - var(--session-review-header-fade)),
|
||||
transparent calc(100cqi - 40px)
|
||||
),
|
||||
linear-gradient(to top, #000 1px, transparent 1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The panel's width animation supplies the slide; only fade its fixed-width contents. */
|
||||
[data-slot="session-side-region-presence"][data-opened] [data-slot="session-review-content"] {
|
||||
transition: opacity 200ms ease-out 40ms;
|
||||
}
|
||||
|
||||
/* Cached contents must stay transparent even after the presence animation finishes. */
|
||||
#review-panel[aria-hidden="true"] > [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="false"] [data-slot="session-review-content"] {
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] [data-slot="session-review-content"] {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="session-side-region-presence"][data-opened="true"] {
|
||||
@@ -82,10 +135,15 @@
|
||||
[data-slot="terminal-panel-presence"],
|
||||
[data-slot="side-terminal-panel-presence"],
|
||||
[data-slot="session-side-panel-presence"],
|
||||
[data-slot="session-review-content"],
|
||||
[data-slot="session-side-region-presence"],
|
||||
[data-component="terminal-panel"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
[data-slot="session-review-content"] {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-panel-presence-in {
|
||||
@@ -125,13 +183,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Presence needs an animation lifetime, but the panel frame must never fade. */
|
||||
@keyframes side-region-presence-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
0.01% {
|
||||
opacity: 0.999999;
|
||||
}
|
||||
from,
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -142,7 +196,7 @@
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0.999999;
|
||||
opacity: 1;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
|
||||
class="min-w-0"
|
||||
>
|
||||
<Menu placement="bottom" gutter={4} onOpenChange={onOpenChange}>
|
||||
<Menu placement="bottom" gutter={4} overflowPadding={24} onOpenChange={onOpenChange}>
|
||||
<Menu.Trigger
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
@@ -148,7 +148,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
overlap
|
||||
overflowPadding={8}
|
||||
overflowPadding={24}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
focusSearch = false
|
||||
@@ -177,7 +177,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
</span>
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[224px] w-[200px] overflow-y-auto">
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<Show when={props.workspaces.length >= 10}>
|
||||
<div class="flex h-7 items-center gap-2 rounded-sm ps-3 pe-2 text-v2-icon-icon-muted">
|
||||
<Icon name="magnifying-glass" size="small" class="shrink-0" />
|
||||
|
||||
@@ -107,6 +107,7 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.browser.open": "Open browser",
|
||||
"command.browser.reload": "Reload browser page",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
@@ -768,7 +769,8 @@ export const dict = {
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments": "+ attachments",
|
||||
"session.queue.attachments.one": "Plus {{count}} attachment",
|
||||
"session.queue.attachments.other": "Plus {{count}} attachments",
|
||||
"session.timeline.working": "Working",
|
||||
"session.timeline.notice.finished": "{{actor}} finished",
|
||||
"session.timeline.notice.failed": "{{actor}} failed",
|
||||
@@ -896,7 +898,7 @@ export const dict = {
|
||||
"session.browser.address": "Browser address",
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter a URL",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
@@ -1124,7 +1126,7 @@ export const dict = {
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.showFileTree.title": "File tree",
|
||||
"settings.general.row.showFileTree.description": "Show the file tree panel in sessions",
|
||||
"settings.general.row.browserPane.title": "Browser pane",
|
||||
"settings.general.row.browserPane.title": "Browser",
|
||||
"settings.general.row.browserPane.description": "Allow agents to open and control an in-app development browser.",
|
||||
"settings.general.row.showNavigation.title": "Navigation controls",
|
||||
"settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
@@ -8,13 +10,16 @@ import { createEffect, For, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
@@ -23,12 +28,28 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
const scheme = () => store.address.match(/^https?:\/\//i)?.[0] ?? ""
|
||||
|
||||
command.register("browser.navigation", () => [
|
||||
{
|
||||
id: "browser.reload",
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !state(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// The native page always paints above the DOM, so hide it while a floating
|
||||
// menu, select, or popover overlaps it. Tooltips are excluded.
|
||||
@@ -86,7 +107,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", state()?.url ?? ""))
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(
|
||||
on(
|
||||
[
|
||||
@@ -124,37 +145,58 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted bg-v2-background-bg-layer-02">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={<Icon name={direction === "back" ? "chevron-left" : "chevron-right"} size="small" />}
|
||||
/>
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()?.[direction === "back" ? "canGoBack" : "canGoForward"]}
|
||||
aria-label={language.t(direction === "back" ? "common.goBack" : "common.goForward")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: direction, tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Icon
|
||||
name={direction === "back" ? "chevron-left" : "chevron-right"}
|
||||
size="small"
|
||||
class="rtl:rotate-180"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</For>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="reset" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}</span>
|
||||
<Show when={!state()?.loading}>
|
||||
<Keybind keys={command.keybindParts("browser.reload")} variant="neutral" />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: tab.loading ? "stop" : "reload", tabID: tab.id })
|
||||
}}
|
||||
icon={
|
||||
<Show when={state()?.loading} fallback={<Icon name="refresh" size="small" />}>
|
||||
<Loader />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
<form
|
||||
class="min-w-0 flex-1"
|
||||
dir="ltr"
|
||||
class="relative min-w-0 flex-1 h-7 rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover text-12-regular"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
@@ -163,15 +205,30 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
}}
|
||||
>
|
||||
<input
|
||||
class="w-full h-7 px-2 rounded-md border border-v2-border-border-muted bg-v2-background-bg-base text-12-regular text-v2-text-text-base outline-none focus:border-v2-border-border-focus"
|
||||
class="w-full h-full px-2 rounded-md border border-transparent bg-transparent text-transparent caret-v2-text-text-base placeholder:text-v2-text-text-faint outline-none focus:border-v2-border-border-focus"
|
||||
spellcheck={false}
|
||||
autocomplete="off"
|
||||
value={store.address}
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: state()?.url ?? "" })}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
}}
|
||||
/>
|
||||
{/* Keep native input editing and selection while coloring the scheme, including during editing. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex items-center px-2 border border-transparent pointer-events-none"
|
||||
>
|
||||
<div ref={addressDisplay} class="w-full overflow-hidden whitespace-pre text-v2-text-text-base">
|
||||
<span class="text-v2-text-text-muted">{scheme()}</span>
|
||||
{store.address.slice(scheme().length)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
|
||||
@@ -23,10 +23,12 @@ export function SessionQueuePanel(props: { queue: SessionQueueView }) {
|
||||
<Show when={count() > 0}>
|
||||
<div
|
||||
data-component="session-queue-panel"
|
||||
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 pb-[18px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
class="relative z-0 -mb-3 rounded-xl bg-v2-background-bg-base px-1.5 pt-1.5 shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)]"
|
||||
// Match the composer overlap so the scroll crop meets the input edge.
|
||||
classList={{ "pb-3": count() > 4, "pb-[18px]": count() <= 4 }}
|
||||
>
|
||||
<Show when={count() > 3}>
|
||||
<div class="px-1.5 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
<div class="px-1.5 pt-1 pb-px text-[11px] font-[530] uppercase leading-[var(--line-height-tight)] tracking-[0.05px] text-v2-text-text-muted [font-variant-numeric:tabular-nums]">
|
||||
{language.plural("session.queue.count", count())}
|
||||
</div>
|
||||
</Show>
|
||||
@@ -58,10 +60,17 @@ export function SessionQueuePanel(props: { queue: SessionQueueView }) {
|
||||
>
|
||||
{/* Keyed on row IDs so store updates move row elements instead of
|
||||
remounting them, which would kill an in-flight drag. */}
|
||||
{/* Four 32px rows, four 1px gaps, and half a row hint at more queued prompts. */}
|
||||
<div
|
||||
ref={listRef}
|
||||
class="flex flex-col gap-px"
|
||||
classList={{ "max-h-[131px] overflow-y-auto": count() > 3 }}
|
||||
classList={{ "max-h-[148px] overflow-y-auto": count() > 4 }}
|
||||
style={{
|
||||
"mask-image":
|
||||
count() > 4
|
||||
? "linear-gradient(to bottom, transparent, black 8px, black calc(100% - 12px), transparent)"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<For each={props.queue.rows().map((row) => row.id)}>
|
||||
{(id, index) => <SessionQueueRow queue={props.queue} id={id} index={index()} />}
|
||||
@@ -98,7 +107,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
data-component="session-queue-row"
|
||||
class="group/queue-row flex items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
|
||||
class="group/queue-row flex h-8 shrink-0 items-center justify-between gap-2 rounded-md py-1 ps-1 pe-2"
|
||||
classList={{
|
||||
"bg-v2-overlay-simple-overlay-hover": editing(),
|
||||
"opacity-60": sortable.isDragSource(),
|
||||
@@ -115,7 +124,7 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
{() => <span class="size-[2px] bg-v2-background-bg-layer-04" />}
|
||||
</For>
|
||||
</button>
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<div class="flex min-w-0 items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
data-action="session-queue-edit"
|
||||
@@ -128,11 +137,12 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
}}
|
||||
onClick={() => props.queue.edit(props.id)}
|
||||
>
|
||||
{entry.text || (entry.attachments ? language.t("session.queue.attachments") : "")}
|
||||
{entry.text ||
|
||||
(entry.attachments ? language.plural("session.queue.attachments", entry.attachments) : "")}
|
||||
</button>
|
||||
<Show when={entry.attachments && entry.text}>
|
||||
<span class="text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{language.t("session.queue.attachments")}
|
||||
<span class="shrink-0 whitespace-nowrap text-[13px] font-[440] leading-[var(--line-height-compact)] text-v2-text-text-muted">
|
||||
{language.plural("session.queue.attachments", entry.attachments)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -24,20 +24,20 @@ const queued = [
|
||||
describe("queuedPromptRows", () => {
|
||||
test("keeps the edited prompt to one row while its replacement is admitted", () => {
|
||||
expect(queuedPromptRows(queued, { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps the original visible until its replacement appears", () => {
|
||||
expect(queuedPromptRows([queued[0]], { original: "msg_original", replacement: "msg_replacement" })).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
{ id: "msg_original", text: "original", attachments: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test("retains unrelated queue entries", () => {
|
||||
expect(queuedPromptRows(queued)).toEqual([
|
||||
{ id: "msg_original", text: "original", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
{ id: "msg_original", text: "original", attachments: 0 },
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -47,8 +47,8 @@ describe("queuedPromptRows", () => {
|
||||
expect(
|
||||
queuedPromptRows([queued[0], other, queued[1]], { original: "msg_original", replacement: "msg_replacement" }),
|
||||
).toEqual([
|
||||
{ id: "msg_other", text: "other", attachments: false },
|
||||
{ id: "msg_replacement", text: "edited", attachments: false },
|
||||
{ id: "msg_other", text: "other", attachments: 0 },
|
||||
{ id: "msg_replacement", text: "edited", attachments: 0 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) > 0,
|
||||
attachments: item.payload.files?.length ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,11 @@ export function SessionSidePanel(props: {
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={visible()}>
|
||||
<div class="size-full flex">
|
||||
<div
|
||||
data-slot="session-review-content"
|
||||
class="h-full flex shrink-0"
|
||||
style={{ width: "var(--session-side-content-width, 100%)" }}
|
||||
>
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-v2-background-bg-base">
|
||||
<div class="size-full min-w-0 h-full bg-v2-background-bg-base">
|
||||
@@ -392,9 +396,11 @@ export function SessionSidePanel(props: {
|
||||
ariaControls={activeTab() === tab ? browserTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<Icon name="globe" size="small" />
|
||||
<span class="max-w-40 truncate">
|
||||
{item().title || language.t("session.tab.browser")}
|
||||
{!item().url || item().url === "about:blank"
|
||||
? language.t("session.tab.browser")
|
||||
: item().title || item().url}
|
||||
</span>
|
||||
</div>
|
||||
</SortableTab>
|
||||
@@ -504,7 +510,7 @@ export function SessionSidePanel(props: {
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="open-file" size="small" />
|
||||
<Icon name="file-tree" size="small" />
|
||||
<span>{language.t("command.file.open")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
@@ -518,7 +524,7 @@ export function SessionSidePanel(props: {
|
||||
}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="window-cursor" size="small" />
|
||||
<Icon name="globe" size="small" />
|
||||
<span>{language.t("session.tab.browser")}</span>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
@@ -537,7 +543,7 @@ export function SessionSidePanel(props: {
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<OpenInAppButton directory={projectDirectory} />
|
||||
<Show when={reviewOpen()}>
|
||||
<Show when={reviewVisible()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -55,6 +55,11 @@ export function SessionHeaderActions(props: { state: SessionHeaderActionsState }
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
class="shrink-0"
|
||||
style={{
|
||||
// This fixed control sits above moving panel contents.
|
||||
"--v2-overlay-simple-overlay-hover": "var(--v2-background-bg-layer-01)",
|
||||
"--v2-overlay-simple-overlay-pressed": "var(--v2-background-bg-layer-02)",
|
||||
}}
|
||||
state={props.state.reviewOpened ? "pressed" : undefined}
|
||||
onClick={props.state.onReviewToggle}
|
||||
aria-label={props.state.reviewLabel}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Show } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { StatusPopover } from "@/shell/status/status-popover"
|
||||
import { TitlebarRight } from "@/shell/titlebar/right-slot"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
@@ -10,7 +9,6 @@ import { Tooltip } from "@opencode/ui/tooltip"
|
||||
export function SessionHeader() {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const { view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -23,7 +21,8 @@ export function SessionHeader() {
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</TitlebarRight>
|
||||
<Show when={isDesktop() && !view().reviewPanel.opened()}>
|
||||
{/* Keep the fixed toggle's slot mounted throughout panel motion. */}
|
||||
<Show when={isDesktop()}>
|
||||
<div class="size-7 shrink-0" aria-hidden />
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -61,6 +61,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
const [store, setStore] = createStore({
|
||||
deferRender: false,
|
||||
bottomTerminalCached: false,
|
||||
sideWidthMotion: false,
|
||||
timelineScrollbarHidden: false,
|
||||
sideHeightMotion: false,
|
||||
sideRegionPresent: false,
|
||||
sideReviewPresent: false,
|
||||
@@ -109,6 +111,16 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
sideMotion().animateRegion ||
|
||||
sideMotion().animateTerminal ||
|
||||
bottomTerminalPresence.animate()
|
||||
const trackSideWidthMotion = (event: TransitionEvent) => {
|
||||
if (event.currentTarget !== event.target || event.propertyName !== "width") return
|
||||
setStore("sideWidthMotion", event.type === "transitionrun")
|
||||
}
|
||||
const hideTimelineScrollbar = () => setStore("timelineScrollbarHidden", true)
|
||||
const revealTimelineScrollbar = (event: Event) => {
|
||||
if (!store.timelineScrollbarHidden || store.sideWidthMotion) return
|
||||
if (!(event.target instanceof Element) || !event.target.closest('[data-slot="session-timeline-scroll"]')) return
|
||||
setStore("timelineScrollbarHidden", false)
|
||||
}
|
||||
createEffect(() => {
|
||||
if (sideTerminalVisible()) setStore("sideTerminalPresent", true)
|
||||
if (bottomTerminalVisible()) setStore("bottomTerminalCached", true)
|
||||
@@ -296,6 +308,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
class="absolute end-3 top-0 z-30 flex items-center"
|
||||
classList={{ "h-[51px]": sideTerminalVisible(), "h-12": !sideTerminalVisible() }}
|
||||
data-slot="session-review-toggle"
|
||||
onPointerDown={hideTimelineScrollbar}
|
||||
onClick={hideTimelineScrollbar}
|
||||
>
|
||||
<SessionReviewToggle />
|
||||
</div>
|
||||
@@ -303,11 +317,20 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
<div
|
||||
classList={{
|
||||
"@container relative z-10 min-w-0 shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
"duration-[240ms] ease-[cubic-bezier(0.4,0,0.2,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!screen.size.active() && sidePresence.animate(),
|
||||
"transition-none": screen.size.active() || !sidePresence.animate(),
|
||||
}}
|
||||
data-slot="session-chat-panel"
|
||||
data-width-animating={store.sideWidthMotion}
|
||||
data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion}
|
||||
onPointerMove={revealTimelineScrollbar}
|
||||
onPointerDown={revealTimelineScrollbar}
|
||||
onWheel={revealTimelineScrollbar}
|
||||
onKeyDown={revealTimelineScrollbar}
|
||||
onTransitionRun={trackSideWidthMotion}
|
||||
onTransitionEnd={trackSideWidthMotion}
|
||||
onTransitionCancel={trackSideWidthMotion}
|
||||
style={{
|
||||
width: screen.panel.width(),
|
||||
}}
|
||||
@@ -342,7 +365,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
data-opened={sidePresence.animate() ? sidePresence.show() : undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return
|
||||
if (event.animationName !== "side-region-presence-in" || !sideVisible()) return
|
||||
setStore("sideHeightMotion", true)
|
||||
}}
|
||||
classList={{
|
||||
@@ -353,8 +376,8 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-panel-content"
|
||||
class="absolute inset-y-0 start-0 h-full"
|
||||
style={{ width: screen.side.contentWidth() }}
|
||||
class="absolute inset-y-0 start-0 size-full"
|
||||
style={{ "--session-side-content-width": screen.side.contentWidth() }}
|
||||
>
|
||||
<div
|
||||
data-slot="session-side-region"
|
||||
@@ -363,7 +386,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
"will-change-[height]": !screen.size.active() && store.sideHeightMotion && paneAnimating(),
|
||||
"transition-none": screen.size.active() || !store.sideHeightMotion || !paneAnimating(),
|
||||
}}
|
||||
style={{ height: screen.side.region.height() }}
|
||||
style={{ height: sideVisible() ? screen.side.region.height() : "100%" }}
|
||||
>
|
||||
<Show when={store.sideRegionPresent}>
|
||||
<div
|
||||
@@ -383,7 +406,7 @@ export function SessionScreen(props: { session: SessionModel }) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="absolute inset-x-0 bottom-0 flex flex-col">
|
||||
<div class="absolute start-0 bottom-0 flex flex-col" style={{ width: screen.side.contentWidth() }}>
|
||||
<div
|
||||
data-slot="session-side-panel-gap"
|
||||
classList={{
|
||||
|
||||
@@ -80,6 +80,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
<Menu
|
||||
placement={props.placement ?? "bottom-end"}
|
||||
gutter={props.gutter ?? 4}
|
||||
overflowPadding={24}
|
||||
modal={false}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
@@ -101,13 +102,13 @@ export function SessionWorkspaceMenu(props: {
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={24}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="outline-worktree" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
<Menu.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
|
||||
<Menu.SubContent class="max-h-[66.667dvh] w-[200px] overflow-y-auto !pb-0 [&>[data-component=menu-v2-item]:last-child]:mb-0.5 [@media(max-height:600px)]:max-h-[calc(100dvh-48px)]">
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
|
||||
@@ -536,6 +536,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
</button>
|
||||
</div>
|
||||
<ScrollView
|
||||
data-slot="session-timeline-scroll"
|
||||
viewportRef={bindListRoot}
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
|
||||
@@ -31,22 +31,6 @@ export const SettingsExperimental: Component = () => {
|
||||
<div class="settings-tab-body">
|
||||
<div class="settings-section">
|
||||
<SettingsList>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.tabs.title")}
|
||||
description={language.t("settings.appearance.row.tabs.description")}
|
||||
@@ -65,6 +49,22 @@ export const SettingsExperimental: Component = () => {
|
||||
onSelect={(option) => option && settings.appearance.setTabLayout(option)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<Show when={platform.browserPane}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.browserPane.title")}
|
||||
description={language.t("settings.general.row.browserPane.description")}
|
||||
>
|
||||
<div data-action="settings-experimental-browser">
|
||||
<Switch
|
||||
checked={settings.general.experimentalBrowser()}
|
||||
onChange={settings.general.setExperimentalBrowser}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.browserPane.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
<SettingsRow
|
||||
title={language.t("settings.appearance.row.projectName.title")}
|
||||
description={language.t("settings.appearance.row.projectName.description")}
|
||||
|
||||
@@ -15,7 +15,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
|
||||
const PALETTE_ID = "command.palette"
|
||||
export const DEFAULT_PALETTE_KEYBIND = "mod+k,mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach", "browser.reload"])
|
||||
|
||||
type KeyLabel =
|
||||
| "common.key.ctrl"
|
||||
|
||||
@@ -100,7 +100,11 @@ function packageNames() {
|
||||
function copyBinary(source) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||
if (fs.existsSync(targetBinary)) {
|
||||
try {
|
||||
fs.unlinkSync(targetBinary)
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
fs.linkSync(source, targetBinary)
|
||||
} catch {
|
||||
|
||||
@@ -167,6 +167,11 @@ const make = Effect.gen(function* () {
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const temporaryDirectory = (prefix: string) =>
|
||||
Effect.acquireRelease(fs.makeTempDirectory({ directory: global.cache, prefix }), (directory) =>
|
||||
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
@@ -192,12 +197,12 @@ const make = Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const cache = yield* temporaryDirectory("update-")
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const directory = yield* temporaryDirectory("update-")
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { Effect, FileSystem, PlatformError, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
@@ -18,6 +18,7 @@ function fixture(
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
name = "@opencode/cli",
|
||||
failCleanup = false,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
@@ -57,6 +58,17 @@ function fixture(
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
remove: (target, options) =>
|
||||
failCleanup && target.startsWith(global.cache)
|
||||
? Effect.fail(
|
||||
PlatformError.systemError({
|
||||
_tag: "PermissionDenied",
|
||||
module: "FileSystem",
|
||||
method: "remove",
|
||||
pathOrDescriptor: target,
|
||||
}),
|
||||
)
|
||||
: fs.remove(target, options),
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
@@ -125,6 +137,14 @@ installs.forEach(({ method, command }) => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("bun ignores install cache cleanup failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(() => ({}), "@opencode/cli", true)
|
||||
yield* test.updater.upgrade("bun", "v2.3.4-beta.1")
|
||||
expect(test.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
;["success", "download", "install"].forEach((failure) => {
|
||||
it.live(`curl uses the V2 installer and cleans its directory: ${failure}`, () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -986,6 +986,15 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1000,6 +1009,15 @@ export type SessionLogOutput =
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -523,6 +523,8 @@ export type SessionMessageCompactionFailed = {
|
||||
status: "failed"
|
||||
reason: "auto" | "manual"
|
||||
error: SessionStructuredError
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
@@ -808,7 +810,14 @@ export type SessionCompactionFailed = {
|
||||
type: "session.compaction.failed"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "auto" | "manual"; error: SessionStructuredError; inputID?: string }
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
error: SessionStructuredError
|
||||
inputID?: string
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionRevertCleared = {
|
||||
@@ -1360,6 +1369,7 @@ export type ProviderInfo = {
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -1738,6 +1748,8 @@ export type SessionMessageCompactionCompleted = {
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
@@ -1755,6 +1767,8 @@ export type SessionCompactionEnded = {
|
||||
providerContext?: SessionProviderContext
|
||||
text: string
|
||||
recent: string
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1836,6 +1850,7 @@ export type ModelInfo = {
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
@@ -2012,6 +2027,7 @@ export type ConfigEntry =
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
@@ -2022,6 +2038,7 @@ export type ConfigEntry =
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
websocket?: boolean
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
@@ -3110,6 +3127,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3119,6 +3143,13 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -3401,6 +3432,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3410,6 +3448,13 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
@@ -3692,6 +3737,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3701,6 +3753,13 @@ export type SessionImportInput = {
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
readonly output: number
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
}
|
||||
)
|
||||
>
|
||||
|
||||
@@ -1073,8 +1073,11 @@ export function createData(config: CreateDataInput) {
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
providerContext: event.data.providerContext,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1085,8 +1088,11 @@ export function createData(config: CreateDataInput) {
|
||||
reason: event.data.reason,
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
providerContext: event.data.providerContext,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
@@ -1106,6 +1112,8 @@ export function createData(config: CreateDataInput) {
|
||||
message: "Compaction failed before recording an error",
|
||||
},
|
||||
metadata: current?.type === "compaction" ? current.metadata : event.metadata,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: current?.type === "compaction" ? current.time : { created: event.created },
|
||||
}
|
||||
if (current?.type === "compaction") {
|
||||
|
||||
@@ -100,13 +100,46 @@ test.each(["started", "cancelled", "failed"])(
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }])
|
||||
const model = { providerID: "demo", id: "model" }
|
||||
const providerState = { responseId: "summary-response" }
|
||||
const tokens = { input: 10, output: 4, reasoning: 0, cache: { read: 3, write: 0 } }
|
||||
const providerContext = {
|
||||
version: 1 as const,
|
||||
provenance: {
|
||||
providerID: "demo",
|
||||
provider: "demo",
|
||||
modelID: "model",
|
||||
route: "demo-responses",
|
||||
protocol: "demo",
|
||||
endpoint: "digest",
|
||||
},
|
||||
messages: [],
|
||||
}
|
||||
fixture.emit({
|
||||
...event,
|
||||
type: "session.compaction.ended",
|
||||
data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" },
|
||||
data: {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
model,
|
||||
providerState,
|
||||
providerContext,
|
||||
text: "Summary",
|
||||
recent: "Recent",
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
},
|
||||
})
|
||||
// The live fold carries the provider window and request usage so the label matches a reloaded session.
|
||||
expect(fixture.data.session.message.list(sessionID)).toMatchObject([
|
||||
{ type: "compaction", status: "completed", summary: "Summary", model, providerState },
|
||||
{
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "Summary",
|
||||
model,
|
||||
providerState,
|
||||
providerContext,
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
},
|
||||
])
|
||||
}
|
||||
},
|
||||
|
||||
@@ -210,6 +210,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
const reasoning = isRecord(settings.reasoningConfig) ? settings.reasoningConfig : undefined
|
||||
const anthropic = input.modelID.includes("anthropic")
|
||||
const openai = input.modelID.includes("openai.")
|
||||
// Converse passes OpenAI fields through verbatim. gpt-oss (Harmony) takes the
|
||||
// flat chat-completions `reasoning_effort`; GPT-5.6+ reject it and take the
|
||||
// Responses-style `reasoning.effort` instead.
|
||||
const harmony = input.modelID.includes("openai.gpt-oss")
|
||||
const effort = typeof reasoning?.maxReasoningEffort === "string" ? reasoning.maxReasoningEffort : undefined
|
||||
const type = typeof reasoning?.type === "string" ? reasoning.type : undefined
|
||||
const budget = typeof reasoning?.budgetTokens === "number" ? reasoning.budgetTokens : undefined
|
||||
@@ -236,7 +240,10 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(!anthropic && openai && effort !== undefined ? { reasoning_effort: effort } : {}),
|
||||
...(!anthropic && openai && harmony && effort !== undefined ? { reasoning_effort: effort } : {}),
|
||||
...(!anthropic && openai && !harmony && effort !== undefined
|
||||
? { reasoning: { ...(isRecord(additional.reasoning) ? additional.reasoning : {}), effort } }
|
||||
: {}),
|
||||
...(!anthropic && !openai && effort !== undefined
|
||||
? {
|
||||
reasoningConfig: {
|
||||
|
||||
@@ -77,6 +77,7 @@ const layer = Layer.effect(
|
||||
...(provider.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider.package,
|
||||
compaction: model.compaction ?? provider.compaction,
|
||||
websocket: model.websocket ?? provider.websocket,
|
||||
settings: Provider.mergeOverlay(provider.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider.body, model.body),
|
||||
|
||||
@@ -184,6 +184,15 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
.replace(/\.md$/, "")
|
||||
const body = markdown.content.trim()
|
||||
const legacy = Object.keys(markdown.data).some((key) => !agentKeys.has(key))
|
||||
// Join legacy model + variant without sending native request/permissions through migration.
|
||||
// Embedded and structured native selections, and a variant without a model, stay unchanged.
|
||||
const data =
|
||||
typeof markdown.data.model === "string" &&
|
||||
!markdown.data.model.includes("#") &&
|
||||
typeof markdown.data.variant === "string" &&
|
||||
/^[^#]+$/.test(markdown.data.variant)
|
||||
? { ...markdown.data, model: `${markdown.data.model}#${markdown.data.variant}` }
|
||||
: markdown.data
|
||||
const agent = legacy
|
||||
? Option.getOrUndefined(
|
||||
Option.map(
|
||||
@@ -191,9 +200,7 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
),
|
||||
)
|
||||
: Option.getOrUndefined(
|
||||
decodeAgent({ ...markdown.data, system: body }, { errors: "all", propertyOrder: "original" }),
|
||||
)
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
|
||||
@@ -206,7 +206,7 @@ function evaluateTemplate(
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", input)
|
||||
const withArguments = expanded.replaceAll("$ARGUMENTS", () => input)
|
||||
const text =
|
||||
placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()
|
||||
? `${withArguments}\n\n${input}`.trim()
|
||||
|
||||
@@ -58,6 +58,7 @@ export const Plugin = define({
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
|
||||
if (item.websocket !== undefined) provider.websocket = item.websocket
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
@@ -78,6 +79,7 @@ export const Plugin = define({
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
|
||||
if (config.websocket !== undefined) model.websocket = config.websocket
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface Resolved {
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
readonly compaction?: Info["compaction"]
|
||||
/** Whether the session WebSocket may carry this model's requests when the route supports it. */
|
||||
readonly websocket: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -321,6 +323,7 @@ export const layer = Layer.effect(
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: selected.compaction,
|
||||
websocket: selected.websocket ?? true,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
|
||||
@@ -5,6 +5,24 @@ import { Effect, Stream } from "effect"
|
||||
import { Bus } from "../bus.js"
|
||||
import { ModelsDev } from "../models-dev.js"
|
||||
|
||||
// These catalog entries require inference profiles on Bedrock Runtime.
|
||||
// Opus/Sonnet 4.6 support in-region calls in eu-west-2 and must remain available.
|
||||
const BEDROCK_PROFILE_ONLY_IDS = [
|
||||
"amazon.nova-2-lite-v1:0",
|
||||
"anthropic.claude-fable-5",
|
||||
"anthropic.claude-fable-5-1",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-5",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-sonnet-5",
|
||||
"deepseek.r1-v1:0",
|
||||
"mistral.pixtral-large-2502-v1:0",
|
||||
]
|
||||
|
||||
export const ModelsDevPlugin = define({
|
||||
id: "opencode.models.dev",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -39,6 +57,11 @@ export const ModelsDevPlugin = define({
|
||||
})
|
||||
for (const model of provider.models) {
|
||||
if (model.status === "deprecated") continue
|
||||
if (
|
||||
provider.info.id === Provider.ID.amazonBedrock &&
|
||||
BEDROCK_PROFILE_ONLY_IDS.includes(model.modelID ?? model.id)
|
||||
)
|
||||
continue
|
||||
catalog.model.update(provider.info.id, model.id, (draft) => Object.assign(draft, copy(model)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,32 +18,6 @@ const isBedrock = (item: { readonly package: string }) => {
|
||||
return name.startsWith("@ai-sdk/amazon-bedrock") || name.startsWith("@opencode/ai/providers/amazon-bedrock")
|
||||
}
|
||||
|
||||
// Bare Bedrock model IDs that AWS rejects unless sent as an inference-profile
|
||||
// ID (`us.`/`eu.`/`global.`/...). Verified via on-demand foundation-model
|
||||
// listings across six regions plus live Converse probes, all returning "with
|
||||
// on-demand throughput isn't supported. Retry ... with an inference profile".
|
||||
// V1 rewrites these to profiles at request time so they must stay in
|
||||
// models.dev; V2 sends IDs verbatim, so listing them only produces errors.
|
||||
// Interim until per-entry source-region metadata lands; region-aware
|
||||
// filtering will subsume this list then.
|
||||
export const PROFILE_ONLY_BARE_IDS = [
|
||||
"amazon.nova-2-lite-v1:0",
|
||||
"anthropic.claude-fable-5",
|
||||
"anthropic.claude-fable-5-1",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
"anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"anthropic.claude-opus-4-6-v1",
|
||||
"anthropic.claude-opus-4-7",
|
||||
"anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-5",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
"anthropic.claude-sonnet-5",
|
||||
"deepseek.r1-v1:0",
|
||||
"mistral.pixtral-large-2502-v1:0",
|
||||
]
|
||||
|
||||
export const AmazonBedrockPlugin = define({
|
||||
id: "opencode.provider.amazon.bedrock",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
@@ -79,12 +53,6 @@ export const AmazonBedrockPlugin = define({
|
||||
}
|
||||
delete provider.settings.endpoint
|
||||
})
|
||||
for (const modelID of PROFILE_ONLY_BARE_IDS) {
|
||||
if (!evt.model.get(item.provider.id, modelID)) continue
|
||||
evt.model.update(item.provider.id, modelID, (model) => {
|
||||
model.enabled = false
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -20,7 +20,8 @@ const pollingSafetyMargin = 3000
|
||||
const codexBaseURL = "https://chatgpt.com/backend-api/codex"
|
||||
const browserMethodID = Integration.MethodID.make("chatgpt-browser")
|
||||
const headlessMethodID = Integration.MethodID.make("chatgpt-headless")
|
||||
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark", "gpt-5.4", "gpt-5.4-mini"])
|
||||
// ChatGPT accounts lost gpt-5.4 and gpt-5.4-mini in Codex on 2026-08-31 (replacements: gpt-5.6-terra, gpt-5.6-luna).
|
||||
const codexAllowed = new Set(["gpt-5.5", "gpt-5.3-codex-spark"])
|
||||
const codexDisallowed = new Set(["gpt-5.5-pro", "gpt-5.6"])
|
||||
|
||||
type Pkce = {
|
||||
|
||||
@@ -31,6 +31,7 @@ const Token = Schema.Struct({
|
||||
access_token: Schema.String,
|
||||
refresh_token: Schema.String,
|
||||
expires_in: Schema.Number,
|
||||
org_id: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
const TokenPending = Schema.Struct({ error: Schema.String })
|
||||
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||
@@ -48,7 +49,12 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
authorize: (answer) =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* normalizeServer(answer.server ?? defaultServer)
|
||||
const device = yield* post(http, `${server}/auth/device/code`, { client_id: clientID }, Device)
|
||||
const device = yield* post(
|
||||
http,
|
||||
`${server}/auth/device/code`,
|
||||
{ client_id: clientID, supports_org_scope: true },
|
||||
Device,
|
||||
)
|
||||
const verification = yield* Effect.try({
|
||||
try: () => {
|
||||
const url = new URL(device.verification_uri_complete, `${server}/`)
|
||||
@@ -73,11 +79,23 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||
Token,
|
||||
)
|
||||
// Persist rotated tokens without depending on discovery requests.
|
||||
return {
|
||||
...credential,
|
||||
access: token.access_token,
|
||||
refresh: token.refresh_token,
|
||||
expires: Date.now() + token.expires_in * 1000,
|
||||
metadata:
|
||||
token.org_id == null
|
||||
? credential.metadata
|
||||
: {
|
||||
...credential.metadata,
|
||||
orgID: token.org_id,
|
||||
orgName:
|
||||
credential.metadata?.orgID === token.org_id && typeof credential.metadata.orgName === "string"
|
||||
? credential.metadata.orgName
|
||||
: token.org_id,
|
||||
},
|
||||
}
|
||||
}),
|
||||
label: (credential) => (typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined),
|
||||
@@ -369,7 +387,13 @@ function credential(http: HttpClient.HttpClient, server: string, token: typeof T
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
const org =
|
||||
token.org_id == null
|
||||
? orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0]
|
||||
: orgs.find((org) => org.id === token.org_id)
|
||||
if (token.org_id != null && !org) {
|
||||
return yield* Effect.fail(new Error(`OpenCode organization not found: ${token.org_id}`))
|
||||
}
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth" as const,
|
||||
methodID,
|
||||
@@ -396,13 +420,13 @@ function get<S extends Schema.Top>(http: HttpClient.HttpClient, url: string, tok
|
||||
function post<S extends Schema.Top>(
|
||||
http: HttpClient.HttpClient,
|
||||
url: string,
|
||||
body: Record<string, string>,
|
||||
body: Record<string, string | boolean>,
|
||||
schema: S,
|
||||
statusOk = true,
|
||||
) {
|
||||
return HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.String))(body),
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
|
||||
Effect.flatMap((request) => http.execute(request)),
|
||||
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
||||
|
||||
@@ -173,6 +173,8 @@ const layer = Layer.effect(
|
||||
...(input.hidden ? ["--hidden"] : []),
|
||||
...(input.follow ? ["--follow"] : []),
|
||||
`--glob=${input.pattern}`,
|
||||
// Positive globs override rg's hidden-file filter; exclude before applying the result limit.
|
||||
...(input.hidden ? [] : ["--glob=!**/.*"]),
|
||||
"--glob=!**/.git/**",
|
||||
".",
|
||||
],
|
||||
|
||||
@@ -390,12 +390,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly reason: SessionMessage.Compaction["reason"]
|
||||
readonly error: SessionError.Error
|
||||
readonly inputID?: SessionMessage.ID
|
||||
}) {
|
||||
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, input)
|
||||
return { status: "failed" as const, error: input.error }
|
||||
})
|
||||
@@ -511,11 +506,12 @@ export const layer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (result.usage)
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
sessionID: context.session.id,
|
||||
source: "compaction" as const,
|
||||
...SessionUsage.record(result.usage, context.model.cost),
|
||||
...usage,
|
||||
})
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID: context.session.id,
|
||||
@@ -524,6 +520,7 @@ export const layer = Layer.effect(
|
||||
text: "",
|
||||
recent: "",
|
||||
providerContext: SessionProviderContext.encode(provenance, result.replacement),
|
||||
...usage,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
}),
|
||||
@@ -671,6 +668,7 @@ export const layer = Layer.effect(
|
||||
reason: input.reason,
|
||||
error,
|
||||
inputID: input.inputID,
|
||||
...usage,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(SessionEvent.Compaction.Ended, {
|
||||
@@ -680,6 +678,7 @@ export const layer = Layer.effect(
|
||||
providerState,
|
||||
text: summary,
|
||||
recent: history.recent,
|
||||
...usage,
|
||||
})
|
||||
return { status: "completed" as const }
|
||||
})
|
||||
|
||||
@@ -415,6 +415,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -430,6 +432,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: { created },
|
||||
}),
|
||||
)
|
||||
@@ -444,6 +448,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
metadata: current?.metadata ?? event.metadata,
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
time: current?.time ?? { created },
|
||||
})
|
||||
if (current?.status === "running") return yield* adapter.updateCompaction(failed)
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { SessionRequestKind } from "@opencode/plugin/effect/session"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { Cause, Config, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { Cause, Context, Effect, Layer, Result, Stream } from "effect"
|
||||
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
@@ -27,9 +27,6 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
const IMAGE_REMOVED =
|
||||
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||
|
||||
const responsesWebSocketFlag = (providerID: string) =>
|
||||
`OPENCODE_EXPERIMENTAL_${providerID.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase()}_RESPONSES_WEBSOCKET`
|
||||
|
||||
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
|
||||
@@ -364,13 +361,6 @@ export const layer = Layer.effect(
|
||||
const hasHttpHooks =
|
||||
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
const webSocket =
|
||||
resolved.capabilities.responsesWebsockets === true
|
||||
? yield* Config.boolean(responsesWebSocketFlag(resolved.ref.providerID)).pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
: false
|
||||
const http = hasHttpHooks
|
||||
? httpMiddleware(hooks, {
|
||||
sessionID: session.id,
|
||||
@@ -379,9 +369,13 @@ export const layer = Layer.effect(
|
||||
kind: input.kind,
|
||||
})
|
||||
: undefined
|
||||
// HTTP hooks must observe every request, so they keep the provider on HTTP.
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(input.webSocket === "session" && webSocket && !hasHttpHooks
|
||||
...(input.webSocket === "session" &&
|
||||
!hasHttpHooks &&
|
||||
resolved.capabilities.responsesWebsockets === true &&
|
||||
resolved.websocket
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
|
||||
const ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
const INBOUND_CAPACITY = 128
|
||||
const CONNECT_TIMEOUT = "10 seconds"
|
||||
const IDLE_TIMEOUT = "5 minutes"
|
||||
const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
description: "Session WebSocket lifecycle events",
|
||||
@@ -145,6 +146,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
yield* Effect.logDebug("session websocket poisoned", {
|
||||
sessionTransport: "websocket",
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
active: channel.active !== undefined,
|
||||
})
|
||||
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
|
||||
yield* metric(
|
||||
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
|
||||
@@ -162,7 +168,20 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* restore(
|
||||
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
|
||||
connector.open(exchange.connect).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: CONNECT_TIMEOUT,
|
||||
orElse: () =>
|
||||
transportError("Timed out opening the Session WebSocket", {
|
||||
url: exchange.connect.url,
|
||||
operation: "request",
|
||||
code: "connect-timeout",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
}),
|
||||
Effect.withSpan("SessionModelTransport.connect"),
|
||||
),
|
||||
)
|
||||
if (owner.closed) {
|
||||
yield* connection.close
|
||||
@@ -289,20 +308,22 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
const channel = owner.channel
|
||||
? owner.channel
|
||||
: yield* open(owner, exchange, key).pipe(
|
||||
Effect.catch((error) =>
|
||||
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
|
||||
? Effect.fail(error)
|
||||
: Effect.logWarning("session websocket connect failed; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("connect_failure")),
|
||||
Effect.andThen(metric("fallback")),
|
||||
Effect.as(undefined),
|
||||
),
|
||||
),
|
||||
Effect.catch((error) => {
|
||||
if (error.reason._tag === "Transport" && error.reason.code === "owner-closed") return Effect.fail(error)
|
||||
// Any connect failure, transient or not, pins the Session to HTTP until restart or move:
|
||||
// a network that refuses the upgrade would otherwise charge every step for a failed connect.
|
||||
owner.httpFallback = true
|
||||
return Effect.logWarning("session websocket connect failed; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("connect_failure")),
|
||||
Effect.andThen(metric("fallback")),
|
||||
Effect.as(undefined),
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
|
||||
@@ -316,6 +337,11 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
yield* Effect.logDebug("session websocket sending", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "send",
|
||||
mode: create.mode,
|
||||
})
|
||||
const active: Active = {
|
||||
queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY),
|
||||
delivery: "send-attempted",
|
||||
@@ -383,8 +409,10 @@ export const makeLayer = (connector: WebSocketConnector) =>
|
||||
if (terminal && pending === 0) {
|
||||
yield* metric("terminal", { type: terminal.type })
|
||||
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
|
||||
if (terminal.type === "rejected" && terminal.recovery === "rotate-and-retry-full")
|
||||
yield* closeChannel(owner, channel)
|
||||
// The Codex backend stops serving a connection after any error frame: the next request is
|
||||
// never answered and the socket dies with 1006. api.openai.com keeps it open, so reconnecting
|
||||
// costs one handshake there. Drop the socket after every error so retries never race that.
|
||||
if (terminal.type !== "completed" && terminal.type !== "incomplete") yield* closeChannel(owner, channel)
|
||||
return
|
||||
}
|
||||
yield* metric("cancellation")
|
||||
|
||||
@@ -60,6 +60,7 @@ export const resolved = (
|
||||
readonly cost: Model.Info["cost"]
|
||||
readonly limit: Model.Info["limit"]
|
||||
readonly compaction?: Provider.Compaction
|
||||
readonly websocket?: boolean
|
||||
},
|
||||
): Resolved => ({
|
||||
model,
|
||||
@@ -72,6 +73,7 @@ export const resolved = (
|
||||
cost: options.cost,
|
||||
limit: options.limit,
|
||||
compaction: options.compaction,
|
||||
websocket: options.websocket ?? true,
|
||||
})
|
||||
|
||||
const layer = Layer.effect(
|
||||
|
||||
@@ -35,8 +35,10 @@ export function isRetryable(error: AIError) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
// HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected
|
||||
// requests as final; not-sent and ambiguous (no frame observed) are still pre-output.
|
||||
case "Transport":
|
||||
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||
return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected"
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
// Unrecognized failures retry: classification records affirmative
|
||||
|
||||
@@ -217,13 +217,20 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
}
|
||||
if (code.inline) {
|
||||
const fence = "`".repeat(Math.max(1, backticks + 1))
|
||||
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
|
||||
flushSpace()
|
||||
prefixQuote()
|
||||
const wrapper = encoder.encode(`${fence}${padding}${padding}${fence}`).byteLength
|
||||
appendRaw(
|
||||
`${fence}${padding}${sliceBytes(code.text, Math.max(0, CONTENT_BYTES - outputBytes - wrapper))}${padding}${fence}`,
|
||||
)
|
||||
const available = Math.max(0, CONTENT_BYTES - outputBytes - fence.length * 2)
|
||||
let payload = sliceBytes(code.text, available)
|
||||
while (payload) {
|
||||
const padding = /^[ `]|[ `]$/.test(payload) && !/^ +$/.test(payload) ? " " : ""
|
||||
const bytes = encoder.encode(payload).byteLength
|
||||
if (bytes + padding.length * 2 <= available) {
|
||||
appendRaw(`${fence}${padding}${payload}${padding}${fence}`)
|
||||
return
|
||||
}
|
||||
// Padding costs at most two bytes, so at most two whole-code-point trims are needed.
|
||||
payload = sliceBytes(payload, bytes - 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeCell) {
|
||||
@@ -246,6 +253,8 @@ export function convertHTMLToMarkdown(html: string) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
// No amount of trimming can make the empty fenced block fit.
|
||||
if (!payload) return
|
||||
const excess = valueBytes - Math.max(0, CONTENT_BYTES - outputBytes)
|
||||
payload = sliceBytes(payload, Math.max(0, encoder.encode(payload).byteLength - Math.ceil(excess)))
|
||||
}
|
||||
|
||||
@@ -192,7 +192,9 @@ export const toModelContent = (path: string, offset: number | undefined, output:
|
||||
}
|
||||
|
||||
const start = output.type === "text-page" ? output.offset : 1
|
||||
const lines = output.content === "" ? [] : output.content.replace(/\n$/, "").split("\n")
|
||||
// Pages already join selected lines; a trailing newline represents a selected blank line.
|
||||
const text = output.type === "file" ? output.content.replace(/\n$/, "") : output.content
|
||||
const lines = output.content === "" ? [] : text.split("\n")
|
||||
const content = [
|
||||
lines.length === 0 ? `Read file ${path}, 0 lines` : `Read file ${path}, lines ${start}-${start + lines.length - 1}`,
|
||||
]
|
||||
|
||||
@@ -251,11 +251,28 @@ describe("AISDKNative", () => {
|
||||
},
|
||||
})
|
||||
|
||||
for (const modelID of ["openai.gpt-oss-120b-1:0", "global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol"]) {
|
||||
// gpt-oss (Harmony) keeps the flat chat-completions field.
|
||||
expect(
|
||||
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, "openai.gpt-oss-120b-1:0")
|
||||
?.body,
|
||||
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
|
||||
|
||||
// GPT-5.6+ reject `reasoning_effort` and take the Responses-style nested field.
|
||||
for (const modelID of ["global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol", "us.openai.gpt-6-astra"]) {
|
||||
expect(
|
||||
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "high" } }, modelID)?.body,
|
||||
).toEqual({ additionalModelRequestFields: { reasoning_effort: "high" } })
|
||||
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body,
|
||||
).toEqual({ additionalModelRequestFields: { reasoning: { effort: "none" } } })
|
||||
}
|
||||
expect(
|
||||
map(
|
||||
"@ai-sdk/amazon-bedrock",
|
||||
{
|
||||
reasoningConfig: { maxReasoningEffort: "high" },
|
||||
additionalModelRequestFields: { reasoning: { summary: "auto" } },
|
||||
},
|
||||
"us.openai.gpt-5.6-sol",
|
||||
)?.body,
|
||||
).toEqual({ additionalModelRequestFields: { reasoning: { summary: "auto", effort: "high" } } })
|
||||
})
|
||||
|
||||
test("maps Bedrock Mantle models to their supported native APIs", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { Directory, Document, Event, Info } from "@opencode/schema/config"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
@@ -17,7 +18,7 @@ import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { ConfigMigrateV1 } from "@opencode/core/v1/config/migrate"
|
||||
import { ConfigAgentV1 } from "@opencode/core/v1/config/agent"
|
||||
import { advance, drain } from "../lib/clock"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "../fixture/tmpdir"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { agentHost, host } from "../plugin/host"
|
||||
|
||||
@@ -60,6 +61,76 @@ test("keeps schema fields and name out of legacy agent options", () => {
|
||||
})
|
||||
|
||||
describe("ConfigAgentPlugin.Plugin", () => {
|
||||
for (const item of [
|
||||
{ name: "separate legacy variant", frontmatter: "model: example/chat\nvariant: high", model: "example/chat#high" },
|
||||
{ name: "unqualified model", frontmatter: "model: example/chat", model: "example/chat" },
|
||||
{ name: "embedded native variant", frontmatter: "model: example/chat#high", model: "example/chat#high" },
|
||||
{
|
||||
name: "structured native variant",
|
||||
frontmatter: "model:\n providerID: example\n model: chat\n variant: high",
|
||||
model: "example/chat#high",
|
||||
},
|
||||
{
|
||||
name: "structured unqualified model",
|
||||
frontmatter: "model:\n providerID: example\n model: chat",
|
||||
model: "example/chat",
|
||||
},
|
||||
{ name: "standalone variant", frontmatter: "variant: high", model: undefined },
|
||||
{
|
||||
name: "embedded native variant with an ignored separate variant",
|
||||
frontmatter: "model: example/chat#high\nvariant: low",
|
||||
model: "example/chat#high",
|
||||
},
|
||||
{
|
||||
name: "structured native variant with an ignored separate variant",
|
||||
frontmatter: "model:\n providerID: example\n model: chat\n variant: high\nvariant: low",
|
||||
model: "example/chat#high",
|
||||
},
|
||||
]) {
|
||||
for (const native of [false, true]) {
|
||||
it.live(`loads Markdown ${item.name}${native ? " with native request and permissions" : ""}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* loadMarkdownAgent(
|
||||
native
|
||||
? `${item.frontmatter}
|
||||
request:
|
||||
headers:
|
||||
x-agent: native
|
||||
body:
|
||||
effort: high
|
||||
permissions:
|
||||
- action: edit
|
||||
resource: "*"
|
||||
effect: deny`
|
||||
: item.frontmatter,
|
||||
)
|
||||
expect(agent.model).toEqual(item.model === undefined ? undefined : Model.Ref.parse(item.model))
|
||||
expect(agent.request).toEqual({
|
||||
settings: {},
|
||||
headers: native ? { "x-agent": "native" } : {},
|
||||
body: native ? { effort: "high" } : {},
|
||||
})
|
||||
if (native) {
|
||||
expect(agent.permissions).toContainEqual({ action: "edit", resource: "*", effect: "deny" })
|
||||
expect(Permission.evaluate("edit", "example.txt", agent.permissions).effect).toBe("deny")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const variant of [undefined, "high"]) {
|
||||
it.live(`loads Markdown legacy temperature ${variant ? "with" : "without"} a separate variant`, () =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* loadMarkdownAgent(
|
||||
`model: example/chat\ntemperature: 0.5${variant ? `\nvariant: ${variant}` : ""}`,
|
||||
)
|
||||
expect(agent.model).toEqual(Model.Ref.parse(variant ? "example/chat#high" : "example/chat"))
|
||||
expect(agent.request).toEqual({ settings: {}, headers: {}, body: { temperature: 0.5 } })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("matches POSIX paths against home-relative permissions", () =>
|
||||
Effect.gen(function* () {
|
||||
const permissions = yield* loadHomePermissions("/home/test")
|
||||
@@ -560,6 +631,26 @@ Use native v2 fields.`,
|
||||
)
|
||||
})
|
||||
|
||||
function loadMarkdownAgent(frontmatter: string) {
|
||||
return Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const fs = yield* FSUtil.Service
|
||||
yield* fs.makeDirectory(path.join(tmp.path, "agents"))
|
||||
yield* fs.writeFileString(
|
||||
path.join(tmp.path, "agents", "reviewer.md"),
|
||||
`---\n${frontmatter}\n---\nReview carefully.`,
|
||||
)
|
||||
const agents = yield* Agent.Service
|
||||
yield* ConfigAgentPlugin.Plugin.effect(host({ agent: agentHost(agents) })).pipe(
|
||||
Effect.provide(Config.testLayer([directoryEntry(tmp.path)])),
|
||||
)
|
||||
const agent = yield* agents.get(Agent.ID.make("reviewer"))
|
||||
if (!agent) throw new Error("expected configured Markdown agent")
|
||||
expect(agent.system).toBe("Review carefully.")
|
||||
return agent
|
||||
})
|
||||
}
|
||||
|
||||
function directoryEntry(directory: string) {
|
||||
return new Directory({ type: "directory", path: AbsolutePath.make(directory) })
|
||||
}
|
||||
|
||||
@@ -71,6 +71,73 @@ const it = testEffect(
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
describe("ConfigCommandPlugin.Plugin", () => {
|
||||
for (const item of [
|
||||
...["$&", "$$", "$`", "$'"].flatMap((input) => [
|
||||
{ template: "Explain $ARGUMENTS.", input, expected: `Explain ${input}.` },
|
||||
{ template: "Explain $1.", input: `"${input}"`, expected: `Explain ${input}.` },
|
||||
{ template: "Explain.", input, expected: `Explain.\n\n${input}` },
|
||||
]),
|
||||
...["abc", "", "alpha beta", '"alpha beta"', "$1", "$<name>"].map((input) => ({
|
||||
template: "Explain $ARGUMENTS.",
|
||||
input,
|
||||
expected: `Explain ${input}.`,
|
||||
})),
|
||||
{
|
||||
template: "First $1. Rest $2.",
|
||||
input: '"alpha beta" gamma delta',
|
||||
expected: "First alpha beta. Rest gamma delta.",
|
||||
},
|
||||
{ template: "$ARGUMENTS / $ARGUMENTS", input: "$& $$", expected: "$& $$ / $& $$" },
|
||||
]) {
|
||||
it.live(`interpolates ${JSON.stringify(item.template)} with literal input ${JSON.stringify(item.input)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const command = yield* Command.Service
|
||||
const prompts: { text: string; delivery?: string }[] = []
|
||||
yield* ConfigCommandPlugin.Plugin.effect(
|
||||
host({
|
||||
command: {
|
||||
list: () => Effect.die(new Error("unused command.list")),
|
||||
transform: command.transform,
|
||||
reload: command.reload,
|
||||
},
|
||||
session: {
|
||||
prompt: (input) =>
|
||||
Effect.sync(() => {
|
||||
prompts.push({ text: input.text, delivery: input.delivery })
|
||||
return SessionInbox.User.make({
|
||||
id: SessionMessage.ID.make("msg_test"),
|
||||
sessionID: input.sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "user",
|
||||
payload: { text: input.text },
|
||||
delivery: input.delivery ?? "steer",
|
||||
})
|
||||
}),
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
Config.testLayer([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({ commands: { explain: { template: item.template } } }),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
yield* command.execute({
|
||||
name: "explain",
|
||||
invocation: {
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
prompt: { text: item.input },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
expect(prompts).toEqual([{ text: item.expected, delivery: "queue" }])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.live("loads inline and file-based commands in config order", () =>
|
||||
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
|
||||
@@ -80,6 +80,33 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("inherits the provider websocket policy with model overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "@opencode/ai/providers/openai/responses",
|
||||
websocket: false,
|
||||
models: { inherited: {}, override: { websocket: true } },
|
||||
},
|
||||
default: { package: "@opencode/ai/providers/openai/responses", models: { untouched: {} } },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
const inherited = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("inherited")))
|
||||
const override = required(yield* catalog.model.get(Provider.ID.make("custom"), Model.ID.make("override")))
|
||||
const untouched = required(yield* catalog.model.get(Provider.ID.make("default"), Model.ID.make("untouched")))
|
||||
expect(inherited.websocket).toBe(false)
|
||||
expect(override.websocket).toBe(true)
|
||||
expect(untouched.websocket).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds key auth for custom providers without env credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { writeSync } from "node:fs"
|
||||
import { convertHTMLToMarkdown } from "../../src/tool/html-markdown"
|
||||
|
||||
const html = await Bun.stdin.text()
|
||||
// Flush readiness before entering a conversion that may block the child event loop.
|
||||
writeSync(1, "ready\n")
|
||||
await Bun.write(Bun.stdout, convertHTMLToMarkdown(html))
|
||||
@@ -94,6 +94,7 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
websocket: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -267,8 +267,8 @@
|
||||
"env": ["AWS_ACCESS_KEY_ID"],
|
||||
"npm": "@ai-sdk/amazon-bedrock",
|
||||
"models": {
|
||||
"amazon.nova-2-lite-v1:0": {
|
||||
"id": "amazon.nova-2-lite-v1:0",
|
||||
"us.amazon.nova-2-lite-v1:0": {
|
||||
"id": "us.amazon.nova-2-lite-v1:0",
|
||||
"name": "Nova 2 Lite",
|
||||
"release_date": "2026-01-01",
|
||||
"attachment": false,
|
||||
|
||||
@@ -1079,7 +1079,7 @@ describe("ModelsDevPlugin", () => {
|
||||
|
||||
const bedrock = yield* catalog.model.get(
|
||||
Provider.ID.make("amazon-bedrock"),
|
||||
Model.ID.make("amazon.nova-2-lite-v1:0"),
|
||||
Model.ID.make("us.amazon.nova-2-lite-v1:0"),
|
||||
)
|
||||
expect(bedrock?.variants).toEqual([
|
||||
{
|
||||
|
||||
@@ -4,8 +4,7 @@ import { Catalog } from "@opencode/core/catalog"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { AmazonBedrockPlugin, PROFILE_ONLY_BARE_IDS } from "@opencode/core/plugin/provider/amazon-bedrock"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { AmazonBedrockPlugin } from "@opencode/core/plugin/provider/amazon-bedrock"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -223,45 +222,4 @@ describe("AmazonBedrockPlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("disables profile-only bare IDs while keeping working IDs", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
const controls = [
|
||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"amazon.nova-micro-v1:0",
|
||||
"openai.gpt-6-astra",
|
||||
]
|
||||
yield* catalog.transform((catalog) => {
|
||||
for (const id of [...PROFILE_ONLY_BARE_IDS, ...controls]) {
|
||||
catalog.model.update(Provider.ID.amazonBedrock, Model.ID.make(id), () => {})
|
||||
}
|
||||
})
|
||||
yield* addPlugin()
|
||||
for (const id of PROFILE_ONLY_BARE_IDS) {
|
||||
expect(required(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).enabled).toBe(
|
||||
false,
|
||||
)
|
||||
}
|
||||
for (const id of controls) {
|
||||
expect(required(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).enabled).toBe(
|
||||
true,
|
||||
)
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not create catalog entries for absent profile-only IDs", () =>
|
||||
withEnv(noAmbientAWS, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seedBedrock()
|
||||
yield* addPlugin()
|
||||
for (const id of PROFILE_ONLY_BARE_IDS) {
|
||||
expect(yield* catalog.model.get(Provider.ID.amazonBedrock, Model.ID.make(id))).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -164,11 +164,7 @@ describe("OpenAIPlugin", () => {
|
||||
expect(eligible.enabled).toBe(true)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
|
||||
context: 400_000,
|
||||
input: 272_000,
|
||||
output: 64_000,
|
||||
})
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).enabled).toBe(false)
|
||||
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
|
||||
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
|
||||
expect(gpt56.enabled).toBe(true)
|
||||
@@ -218,7 +214,7 @@ describe("OpenAIPlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects Azure WebSocket from capability and the Azure flag only", () =>
|
||||
it.effect("selects Azure WebSocket from capability unless the policy disables it", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
yield* credentials.create({
|
||||
@@ -239,54 +235,44 @@ describe("OpenAIPlugin", () => {
|
||||
id: "deployment-responses",
|
||||
provider: Provider.ID.azure,
|
||||
})
|
||||
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
})
|
||||
const program = Effect.gen(function* () {
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
kind: "primary",
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agentID,
|
||||
model,
|
||||
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
webSocket: "session",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, transport),
|
||||
)
|
||||
const prepare = (websocket?: boolean) =>
|
||||
Effect.gen(function* () {
|
||||
const model = SessionRunnerModel.resolved(route.model({ id: "gpt-5.5" }), {
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"], responsesWebsockets: true },
|
||||
cost: [],
|
||||
limit: { context: 200_000, output: 32_000 },
|
||||
websocket,
|
||||
})
|
||||
const requests = yield* SessionModelRequest.Service
|
||||
return yield* requests.prepare({
|
||||
kind: "primary",
|
||||
scope: {
|
||||
session: Session.Info.make({
|
||||
id: sessionID,
|
||||
projectID: Project.ID.global,
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
|
||||
}),
|
||||
agentID,
|
||||
model,
|
||||
tools: { definitions: [], execute: () => Effect.die("unused tool execution") },
|
||||
},
|
||||
transcript: { system: [], messages: [] },
|
||||
webSocket: "session",
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(SessionModelRequest.layer),
|
||||
Effect.provideService(SessionModelTransport.Service, transport),
|
||||
)
|
||||
|
||||
const prepared = yield* program.pipe(
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_AZURE_RESPONSES_WEBSOCKET: "true" } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const otherProvider = yield* program.pipe(
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromEnv({ env: { OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET: "true" } }),
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* prepare()
|
||||
const disabled = yield* prepare(false)
|
||||
|
||||
expect(prepared.options.webSocket).toBe(executor)
|
||||
expect(prepared.options.http).toBeUndefined()
|
||||
expect(otherProvider.options.webSocket).toBeUndefined()
|
||||
expect(disabled.options.webSocket).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -28,6 +28,48 @@ const addPlugin = Effect.fn(function* () {
|
||||
yield* OpencodePlugin.effect(host)
|
||||
})
|
||||
|
||||
function consoleServer(orgID: string | null | undefined, unavailable = false) {
|
||||
const config: { authorization: string | null; orgID: string | null }[] = []
|
||||
const requests: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (request) => {
|
||||
const path = new URL(request.url).pathname
|
||||
requests.push(path)
|
||||
if (path === "/auth/device/code") {
|
||||
expect(await request.json()).toEqual({ client_id: "opencode-cli", supports_org_scope: true })
|
||||
return Response.json({
|
||||
device_code: "device",
|
||||
user_code: "user",
|
||||
verification_uri_complete: "/device?user_code=user",
|
||||
expires_in: 60,
|
||||
interval: 0,
|
||||
})
|
||||
}
|
||||
if (path === "/auth/device/token") {
|
||||
return Response.json({ access_token: "access", refresh_token: "refresh", expires_in: 600, org_id: orgID })
|
||||
}
|
||||
if (unavailable && (path === "/api/user" || path === "/api/orgs")) {
|
||||
return new Response("Unavailable", { status: 503 })
|
||||
}
|
||||
if (path === "/api/user") return Response.json({ id: "user", email: "user@example.com" })
|
||||
if (path === "/api/orgs") {
|
||||
return Response.json([
|
||||
{ id: "org-z", name: "Zebra" },
|
||||
{ id: "org-a", name: "Alpha" },
|
||||
])
|
||||
}
|
||||
if (path === "/api/v2/config") {
|
||||
config.push({ authorization: request.headers.get("authorization"), orgID: request.headers.get("x-org-id") })
|
||||
if (orgID === "org-missing") return new Response("Forbidden", { status: 403 })
|
||||
return Response.json({ providers: {} })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
return { server, config, requests }
|
||||
}
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
@@ -164,6 +206,119 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
for (const orgID of ["org-z", undefined, null, "org-missing"]) {
|
||||
it.live(`uses the device token organization during authorization: ${orgID}`, () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(orgID)),
|
||||
({ server, config }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const integrationID = Integration.ID.make("opencode")
|
||||
const attempt = yield* integrations.oauth.connect({
|
||||
integrationID,
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
answer: { server: server.url.origin },
|
||||
})
|
||||
const status = yield* eventually(
|
||||
integrations.oauth.status({ integrationID, attemptID: attempt.attemptID }),
|
||||
(status) => status.status !== "pending",
|
||||
)
|
||||
if (orgID === "org-missing") {
|
||||
expect(status).toMatchObject({
|
||||
status: "failed",
|
||||
message: "OpenCode organization not found: org-missing",
|
||||
})
|
||||
expect(yield* credentials.list(integrationID)).toEqual([])
|
||||
expect(config).toEqual([])
|
||||
return
|
||||
}
|
||||
expect(status.status).toBe("complete")
|
||||
expect((yield* credentials.list(integrationID))[0]).toMatchObject({
|
||||
label: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
value: {
|
||||
type: "oauth",
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
accountID: "user",
|
||||
email: "user@example.com",
|
||||
orgID: orgID ?? "org-a",
|
||||
orgName: orgID === "org-z" ? "Zebra" : "Alpha",
|
||||
},
|
||||
},
|
||||
})
|
||||
yield* eventually(
|
||||
Effect.sync(() => config.length),
|
||||
(count) => count > 0,
|
||||
)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: orgID ?? "org-a" }])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const scenario of [
|
||||
{ orgID: "org-z" },
|
||||
{ orgID: undefined },
|
||||
{ orgID: null },
|
||||
{ orgID: "org-missing" },
|
||||
{ orgID: "org-z", unavailable: true },
|
||||
{ orgID: "org-a", unavailable: true },
|
||||
]) {
|
||||
it.live(
|
||||
`persists rotated credentials for ${scenario.orgID}${scenario.unavailable ? " with discovery unavailable" : ""}`,
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => consoleServer(scenario.orgID, scenario.unavailable)),
|
||||
({ server, config, requests }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const initial = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
label: "Custom label",
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired-access",
|
||||
refresh: "old-refresh",
|
||||
expires: 0,
|
||||
metadata: { server: server.url.origin, orgID: "org-a", orgName: "Alpha", custom: "preserved" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const stored = required(yield* credentials.get(initial.id))
|
||||
expect(stored).toMatchObject({
|
||||
label: "Custom label",
|
||||
value: {
|
||||
access: "access",
|
||||
refresh: "refresh",
|
||||
metadata: {
|
||||
server: server.url.origin,
|
||||
orgID: scenario.orgID ?? "org-a",
|
||||
orgName: scenario.orgID === "org-a" ? "Alpha" : (scenario.orgID ?? "Alpha"),
|
||||
custom: "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
if (stored.value.type !== "oauth") throw new Error("Expected OAuth credential")
|
||||
expect(stored.value.expires).toBeGreaterThan(Date.now())
|
||||
if (scenario.orgID == null) expect(stored.value.metadata).toEqual(initial.value.metadata)
|
||||
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
|
||||
const integrations = yield* Integration.Service
|
||||
expect(
|
||||
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
|
||||
).toEqual(stored.value)
|
||||
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects non-HTTP OpenCode servers", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
|
||||
@@ -6,13 +6,43 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { Ripgrep } from "@opencode/core/ripgrep"
|
||||
import { RelativePath } from "@opencode/core/schema"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { tempLocationLayer } from "./fixture/location"
|
||||
|
||||
const it = testEffect(AppNodeBuilder.build(Ripgrep.node, [Location.node.replace(tempLocationLayer)]))
|
||||
|
||||
describe("Ripgrep", () => {
|
||||
for (const hidden of [undefined, false, true]) {
|
||||
for (const limit of hidden ? [10] : [1, 10]) {
|
||||
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
|
||||
Bun.write(path.join(tmp.path, file), "needle\n"),
|
||||
),
|
||||
),
|
||||
)
|
||||
const ripgrep = yield* Ripgrep.Service
|
||||
const files = yield* ripgrep.glob({
|
||||
cwd: tmp.path,
|
||||
pattern: "**/*.ts",
|
||||
limit,
|
||||
...(hidden === undefined ? {} : { hidden }),
|
||||
})
|
||||
|
||||
expect(files.map((item) => item.path).sort()).toEqual(
|
||||
(hidden ? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"] : ["src/visible.ts"]).map(
|
||||
(file) => RelativePath.make(file),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.live("globs files as an array", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -409,8 +409,16 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Use Effect services and generators.")
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("User shell pwd completed: /project")
|
||||
expect(JSON.stringify(requests[0]?.messages)).not.toContain("display-only-output")
|
||||
// The compaction message carries its own request usage so clients can show what compacting cost.
|
||||
expect(yield* store.context(sessionID)).toMatchObject([
|
||||
{ type: "compaction", reason: "manual", summary: "## Objective\n- manual summary", recent: "" },
|
||||
{
|
||||
type: "compaction",
|
||||
reason: "manual",
|
||||
summary: "## Objective\n- manual summary",
|
||||
recent: "",
|
||||
cost: 0.0000233,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
|
||||
},
|
||||
])
|
||||
expect(yield* store.get(sessionID)).toMatchObject({
|
||||
cost: 0.0000233,
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("toSessionError", () => {
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false, false, false, false, false])
|
||||
})
|
||||
|
||||
test("retries transport failures only when delivery is absent or not sent", () => {
|
||||
test("retries transport failures unless the provider accepted or rejected the request", () => {
|
||||
const retryable = [
|
||||
llm(new TransportError({ message: "http transport", transport: "http", operation: "request" })),
|
||||
llm(
|
||||
@@ -180,8 +180,6 @@ describe("toSessionError", () => {
|
||||
phase: "connect",
|
||||
}),
|
||||
),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(
|
||||
new TransportError({
|
||||
message: "send uncertain",
|
||||
@@ -191,6 +189,8 @@ describe("toSessionError", () => {
|
||||
phase: "send",
|
||||
}),
|
||||
),
|
||||
]
|
||||
const ineligible = [
|
||||
llm(
|
||||
new TransportError({
|
||||
message: "response interrupted",
|
||||
@@ -212,8 +212,8 @@ describe("toSessionError", () => {
|
||||
),
|
||||
]
|
||||
|
||||
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false, false])
|
||||
expect(retryable.map(SessionRunnerRetry.isRetryable)).toEqual([true, true, true])
|
||||
expect(ineligible.map(SessionRunnerRetry.isRetryable)).toEqual([false, false])
|
||||
})
|
||||
|
||||
test("honors provider retry header overrides", () => {
|
||||
|
||||
@@ -225,7 +225,7 @@ describe("SessionModelTransport local WebSocket server", () => {
|
||||
expect(requests).toHaveLength(3)
|
||||
expect(requests[1]).toHaveProperty("previous_response_id", "resp_1")
|
||||
expect(requests[2]).not.toHaveProperty("previous_response_id")
|
||||
expect(server.state.opens).toBe(1)
|
||||
expect(server.state.opens).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AIError, HttpContext, TransportError } from "@opencode/ai"
|
||||
import { AIError, HttpContext, InvalidRequestError, TransportError } from "@opencode/ai"
|
||||
import type {
|
||||
ChannelObservation,
|
||||
WebSocketChannelExchange,
|
||||
@@ -243,7 +243,9 @@ describe("SessionModelTransport", () => {
|
||||
yield* collect(executor, item("retry"))
|
||||
|
||||
expect(checkpoints).toEqual([undefined, candidate, undefined])
|
||||
expect(fixture.connections).toHaveLength(1)
|
||||
// Error frames end the connection on some backends, so the full retry uses a fresh one.
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections[0]?.closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -286,6 +288,35 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("closes the connection after a provider error frame so the next call reconnects", async () => {
|
||||
const fixture = automatic()
|
||||
const failed: WebSocketChannelExchange = {
|
||||
...exchange("failed"),
|
||||
driver: {
|
||||
create: () => Effect.succeed({ message: "failed", mode: "full" }),
|
||||
observe: () =>
|
||||
Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({ reason: new InvalidRequestError({ message: "unsupported model" }) }),
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
await run(
|
||||
fixture.connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const executor = transport.bind(session)
|
||||
const result = yield* Effect.result(collect(executor, failed))
|
||||
expect(result._tag).toBe("Failure")
|
||||
expect(yield* collect(executor, exchange("next"))).toEqual(["completed:next"])
|
||||
|
||||
expect(fixture.connections).toHaveLength(2)
|
||||
expect(fixture.connections[0]?.closed).toBe(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("reuses one physical connection for sequential Session calls", async () => {
|
||||
const fixture = automatic()
|
||||
|
||||
@@ -495,6 +526,23 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("times out a hanging connect and falls back to http", async () => {
|
||||
const connector: WebSocketConnector = { open: () => Effect.never }
|
||||
|
||||
await runWithTestClock(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const running = yield* collect(transport.bind(session), exchange("slow")).pipe(
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 seconds")
|
||||
expect(yield* Fiber.join(running)).toEqual(["fallback:slow"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("times out an idle accepted request and poisons its socket", async () => {
|
||||
const started = Deferred.makeUnsafe<void>()
|
||||
const messages = queue<string | Uint8Array, AIError>()
|
||||
@@ -598,25 +646,31 @@ describe("SessionModelTransport", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("falls back once when connection setup fails before send", async () => {
|
||||
test("falls back when connection setup fails and keeps the Session on HTTP", async () => {
|
||||
let attempts = 0
|
||||
let fallbacks = 0
|
||||
const connector: WebSocketConnector = { open: () => Effect.fail(error("upgrade rejected", "not-sent")) }
|
||||
const connector: WebSocketConnector = {
|
||||
open: () =>
|
||||
Effect.sync(() => attempts++).pipe(Effect.andThen(Effect.fail(error("upgrade rejected", "not-sent")))),
|
||||
}
|
||||
|
||||
await run(
|
||||
connector,
|
||||
Effect.gen(function* () {
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const result = yield* collect(
|
||||
transport.bind(session),
|
||||
exchange("first", {
|
||||
const executor = transport.bind(session)
|
||||
const item = (id: string) =>
|
||||
exchange(id, {
|
||||
fallback: () => {
|
||||
fallbacks++
|
||||
return Stream.make("http")
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(result).toEqual(["http"])
|
||||
expect(fallbacks).toBe(1)
|
||||
})
|
||||
expect(yield* collect(executor, item("first"))).toEqual(["http"])
|
||||
expect(yield* collect(executor, item("second"))).toEqual(["http"])
|
||||
// One failed upgrade per Session, not one per step.
|
||||
expect(attempts).toBe(1)
|
||||
expect(fallbacks).toBe(2)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -240,6 +240,8 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
|
||||
return yield* Effect.die("Missing native checkpoint")
|
||||
expect(last.summary).toBe("")
|
||||
expect(last.recent).toBe("")
|
||||
// Provider compaction has no summary, so the request usage is the only visible cost of the operation.
|
||||
expect(last.tokens).toMatchObject({ input: 20, output: 4 })
|
||||
return last.providerContext
|
||||
})
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { MAX_MARKDOWN_BYTES } from "../src/tool/html-markdown"
|
||||
|
||||
const budget = MAX_MARKDOWN_BYTES - 64 * 1024
|
||||
|
||||
test.each([
|
||||
["exhausted budget", budget, "x", "", false],
|
||||
["one byte short of an empty fence", budget - 13, "x", "", false],
|
||||
["small fitting block", 64, "x", "\n\n```\nx\n```", false],
|
||||
["last payload byte fits", budget - 15, "x", "\n\n```\nx\n```", false],
|
||||
["only an empty fence fits", budget - 14, "x", "\n\n```\n\n```", false],
|
||||
["Unicode payload truncates at a code point", budget - 18, "😀é", "\n\n```\n😀\n```", false],
|
||||
["quoted payload fits", budget - 23, "x", "\n\n> ```\n> x\n> ```", true],
|
||||
["only an empty quoted fence fits", budget - 22, "x", "\n\n> ```\n> \n> ```", true],
|
||||
["one byte short of an empty quoted fence", budget - 21, "x", "", true],
|
||||
] as const)(
|
||||
"finishes bounded code conversion: %s",
|
||||
async (_name, count, payload, suffix, quoted) => {
|
||||
const code = `<pre>${payload}</pre>`
|
||||
const html = `<p>${"x".repeat(count)}</p>${quoted ? `<blockquote>${code}</blockquote>` : code}`
|
||||
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
|
||||
const child = Bun.spawn({
|
||||
cmd: [process.execPath, fileURLToPath(new URL("./fixture/html-markdown.ts", import.meta.url))],
|
||||
stdin: new Blob([html]),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
let ready = false
|
||||
let timeout: "startup" | "conversion" | undefined
|
||||
let timer = setTimeout(() => {
|
||||
timeout = "startup"
|
||||
child.kill("SIGKILL")
|
||||
}, 10_000)
|
||||
const stdout = (async () => {
|
||||
let output = ""
|
||||
for await (const chunk of child.stdout.pipeThrough(new TextDecoderStream())) {
|
||||
output += chunk
|
||||
if (ready || !output.startsWith("ready\n")) continue
|
||||
ready = true
|
||||
clearTimeout(timer)
|
||||
// This watchdog runs outside the possibly stuck synchronous converter.
|
||||
timer = setTimeout(() => {
|
||||
timeout = "conversion"
|
||||
child.kill("SIGKILL")
|
||||
}, 3_000)
|
||||
}
|
||||
return output.slice("ready\n".length)
|
||||
})()
|
||||
const stderr = new Response(child.stderr).text()
|
||||
try {
|
||||
const exitCode = await child.exited
|
||||
const output = await stdout
|
||||
expect({ ready, timeout, exitCode, stderr: await stderr }).toEqual({
|
||||
ready: true,
|
||||
timeout: undefined,
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
})
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(MAX_MARKDOWN_BYTES)
|
||||
expect(output).toBe("x".repeat(Math.min(count, budget - 2)) + suffix)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
|
||||
await child.exited
|
||||
}
|
||||
},
|
||||
15_000,
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Environment } from "@opencode/core/environment/index"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { ReadTool } from "@opencode/core/tool/plugin/read"
|
||||
import { ReadToolFileSystem } from "@opencode/core/tool/read-filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { LayerNodePlatform } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -20,6 +21,96 @@ const fixture = Effect.gen(function* () {
|
||||
})
|
||||
const absolute = (value: string) => AbsolutePath.make(value)
|
||||
|
||||
describe("ReadTool text serialization", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "preserves a selected trailing blank line before continuation",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { offset: 1, limit: 2 },
|
||||
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: true, next: 3 },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: \n[Output truncated. Continue reading with offset: 3]",
|
||||
},
|
||||
{
|
||||
name: "preserves multiple selected trailing blank lines at a noninitial offset",
|
||||
content: "before\nalpha\n\n\nomega\n",
|
||||
page: { offset: 2, limit: 3 },
|
||||
output: { type: "text-page", content: "alpha\n\n", offset: 2, truncated: true, next: 5 },
|
||||
model: "Read file lines.txt, lines 2-4\n2: alpha\n3: \n4: \n[Output truncated. Continue reading with offset: 5]",
|
||||
},
|
||||
{
|
||||
name: "preserves a selected trailing blank line at EOF",
|
||||
content: "alpha\n\n",
|
||||
page: { limit: 2 },
|
||||
output: { type: "text-page", content: "alpha\n", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
|
||||
},
|
||||
{
|
||||
name: "preserves internal blank lines in a page",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { limit: 3 },
|
||||
output: { type: "text-page", content: "alpha\n\nomega", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, lines 1-3\n1: alpha\n2: \n3: omega",
|
||||
},
|
||||
{
|
||||
name: "preserves continuation for a nonblank page",
|
||||
content: "alpha\n\nomega\n",
|
||||
page: { limit: 1 },
|
||||
output: { type: "text-page", content: "alpha", offset: 1, truncated: true, next: 2 },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha\n[Output truncated. Continue reading with offset: 2]",
|
||||
},
|
||||
{
|
||||
name: "strips only the terminal file newline in a whole-file read",
|
||||
content: "alpha\n\n",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha\n\n", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-2\n1: alpha\n2: ",
|
||||
},
|
||||
{
|
||||
name: "does not add a line for a whole-file terminal newline",
|
||||
content: "alpha\n",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha\n", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha",
|
||||
},
|
||||
{
|
||||
name: "preserves a whole-file read without a terminal newline",
|
||||
content: "alpha",
|
||||
page: {},
|
||||
output: { type: "file", content: "alpha", encoding: "utf8" },
|
||||
model: "Read file lines.txt, lines 1-1\n1: alpha",
|
||||
},
|
||||
{
|
||||
name: "preserves empty whole-file output",
|
||||
content: "",
|
||||
page: {},
|
||||
output: { type: "file", content: "", encoding: "utf8" },
|
||||
model: "Read file lines.txt, 0 lines",
|
||||
},
|
||||
{
|
||||
name: "preserves empty-file page output",
|
||||
content: "",
|
||||
page: { limit: 2 },
|
||||
output: { type: "text-page", content: "", offset: 1, truncated: false },
|
||||
model: "Read file lines.txt, 0 lines",
|
||||
},
|
||||
]
|
||||
|
||||
cases.forEach((input) => {
|
||||
it.live(input.name, () =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* fixture
|
||||
const file = absolute(path.join(current.directory, "lines.txt"))
|
||||
yield* current.files.writeFileString(file, input.content)
|
||||
|
||||
const result = yield* ReadToolFileSystem.read(current.environment, file, "lines.txt", input.page)
|
||||
|
||||
expect(result).toMatchObject(input.output)
|
||||
expect(ReadTool.toModelContent("lines.txt", undefined, result)).toBe(input.model)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ReadToolFileSystem", () => {
|
||||
it.effect("preserves the environment not-found error", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -17,7 +17,7 @@ import { GlobTool } from "@opencode/core/tool/plugin/glob"
|
||||
import { GrepTool } from "@opencode/core/tool/plugin/grep"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tmpdir, tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { it } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
@@ -67,6 +67,45 @@ const call = (name: "glob" | "grep", input: unknown) => ({
|
||||
})
|
||||
|
||||
describe("search tools", () => {
|
||||
for (const hidden of [undefined, false, true]) {
|
||||
for (const limit of hidden ? [10] : [1, 10]) {
|
||||
it.live(`glob honors hidden=${hidden} before limit=${limit}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
["src/visible.ts", ".hidden.ts", "src/.hidden.ts", ".hidden/nested.ts", ".git/config.ts"].map((file) =>
|
||||
Bun.write(path.join(tmp.path, file), "needle\n"),
|
||||
),
|
||||
),
|
||||
)
|
||||
yield* withTools(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* executeTool(
|
||||
registry,
|
||||
call("glob", { pattern: "**/*.ts", limit, ...(hidden === undefined ? {} : { hidden }) }),
|
||||
)
|
||||
const expected = hidden
|
||||
? [".hidden.ts", ".hidden/nested.ts", "src/.hidden.ts", "src/visible.ts"]
|
||||
: ["src/visible.ts"]
|
||||
|
||||
expect(result.status).toBe("completed")
|
||||
expect(result.output).toHaveLength(expected.length)
|
||||
expect(result.output).toEqual(
|
||||
expect.arrayContaining(expected.map((file) => ({ path: path.normalize(file), type: "file" }))),
|
||||
)
|
||||
expect(result.metadata).toEqual({ count: expected.length, truncated: false })
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content?.[0]?.type === "text" ? result.content[0].text.split("\n").sort() : []).toEqual(
|
||||
expected.map((file) => path.join(tmp.path, file)).sort(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
it.live("bounds omitted glob and grep limits", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -90,6 +90,53 @@ describe("WebFetchTool helpers", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["`x`", "`` `x` ``"],
|
||||
["`x", "`` `x ``"],
|
||||
["x`", "`` x` ``"],
|
||||
["`", "`` ` ``"],
|
||||
["``", "``` `` ```"],
|
||||
["``x`", "``` ``x` ```"],
|
||||
["say(`x`)", "``say(`x`)``"],
|
||||
["a``b`c", "```a``b`c```"],
|
||||
["x", "`x`"],
|
||||
[" x ", "` x `"],
|
||||
[" x", "` x `"],
|
||||
["x ", "` x `"],
|
||||
[" ", "` `"],
|
||||
[" ` ", "`` ` ``"],
|
||||
])("preserves inline code boundaries for %j", (content, expected) => {
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(`<p>Use <code>${content}</code>.</p>`)).toBe(`Use ${expected}.`)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["discarded trailing backtick after ASCII", "x`", 7, "``x``"],
|
||||
["discarded trailing backtick after Unicode", "😀`", 10, "``😀``"],
|
||||
["discarded trailing backtick with spare room", "x`", 9, "``x``"],
|
||||
["retained trailing backtick", "x`", 10, "`` x` ``"],
|
||||
["new trailing backtick from an internal run", "x`y", 8, "``x``"],
|
||||
["leading backtick without padding room", "`x", 8, ""],
|
||||
["leading backtick alone fits", "`x", 9, "`` ` ``"],
|
||||
["leading backtick with payload fits", "`x", 10, "`` `x ``"],
|
||||
["all backticks truncated", "``", 11, "``` ` ```"],
|
||||
["all backticks fit", "``", 12, "``` `` ```"],
|
||||
["mixed internal runs truncated", "a``b`c", 11, "```a```"],
|
||||
["Unicode code point cannot fit", "😀`", 9, ""],
|
||||
["ordinary payload cannot fit", "x", 3, ""],
|
||||
["spaces cannot fit", " ", 4, ""],
|
||||
["space-only prefix fits", " ", 5, "` `"],
|
||||
["truncated prefix becomes space-only", " x", 6, "` `"],
|
||||
["discarded trailing space", "x ", 5, "`x`"],
|
||||
] as const)("fits inline code to its emitted boundaries: %s", (_name, content, spare, expected) => {
|
||||
const prefix = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 64 * 1024 - spare)
|
||||
const html = `<p>${prefix}<code>${content}</code></p>`
|
||||
expect(Buffer.byteLength(html)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
const output = WebFetchTool.convertHTMLToMarkdown(html)
|
||||
expect(output.slice(0, prefix.length)).toBe(prefix)
|
||||
expect(output.slice(prefix.length)).toBe(expected)
|
||||
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
|
||||
})
|
||||
|
||||
test("keeps nested ordered and unordered lists structurally readable", () => {
|
||||
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
|
||||
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
|
||||
|
||||
@@ -58,7 +58,13 @@ export function createBrowserPage(
|
||||
const contents = view.webContents
|
||||
const detachNetwork = options.network?.attach(contents)
|
||||
contents.on("before-input-event", (event, input) => {
|
||||
if (input.type !== "keyDown" || input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
|
||||
if (input.type !== "keyDown") return
|
||||
if (input.key === "F5" && !input.meta && !input.control && !input.alt && !input.shift) {
|
||||
event.preventDefault()
|
||||
contents.reload()
|
||||
return
|
||||
}
|
||||
if (input.alt || !(process.platform === "darwin" ? input.meta : input.control)) return
|
||||
const step =
|
||||
input.key === "=" || input.key === "+" || input.code === "NumpadAdd"
|
||||
? 0.5
|
||||
|
||||
@@ -487,10 +487,12 @@ export interface UI {
|
||||
readonly attention: boolean
|
||||
readonly unread?: "activity" | "error"
|
||||
}[]
|
||||
/** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */
|
||||
/** Opens a tab for a session without focusing it. Returns false when tabs are disabled. */
|
||||
open(sessionID: string): boolean
|
||||
/** Focuses an already-open tab and returns false when it is not open. */
|
||||
/** Opens a tab when needed, then focuses it. Returns false when tabs are disabled. */
|
||||
focus(sessionID: string): boolean
|
||||
/** Moves an open tab to an index and returns false when it is not open. */
|
||||
move(sessionID: string, index: number): boolean
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
|
||||
|
||||
class Model extends Schema.Class<Model>("Config.Model")({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
websocket: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Use the provider's WebSocket transport for this model. Defaults to the provider policy.",
|
||||
}),
|
||||
modelID: ID.pipe(optional),
|
||||
family: Family.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
@@ -60,6 +63,9 @@ class Model extends Schema.Class<Model>("Config.Model")({
|
||||
|
||||
export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
websocket: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Use the provider's WebSocket transport when the route supports it. Defaults to true.",
|
||||
}),
|
||||
canonical: Provider.ID.pipe(optional),
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
|
||||
@@ -107,6 +107,8 @@ export const Info = Schema.Struct({
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
package: Provider.Package.pipe(optional),
|
||||
compaction: Provider.Compaction.pipe(optional),
|
||||
/** Session WebSocket policy; omitted inherits the provider policy, which defaults to enabled. */
|
||||
websocket: Schema.Boolean.pipe(optional),
|
||||
...Provider.Overlays,
|
||||
capabilities: Capabilities,
|
||||
variants: Schema.Array(Variant),
|
||||
|
||||
@@ -59,6 +59,8 @@ export const Info = Schema.Struct({
|
||||
activation: Activation,
|
||||
package: Package,
|
||||
compaction: Compaction.pipe(optional),
|
||||
/** Session WebSocket policy for routes that support it; omitted means enabled. */
|
||||
websocket: Schema.Boolean.pipe(optional),
|
||||
...Overlays,
|
||||
})
|
||||
.annotate({ identifier: "Provider.Info" })
|
||||
|
||||
@@ -591,6 +591,10 @@ export namespace Compaction {
|
||||
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
// Repeats the internal `session.usage.recorded` figures: that event never reaches clients, and it
|
||||
// stays the accounting source for session totals and stats.
|
||||
cost: SessionMessage.CompactionCompleted.fields.cost,
|
||||
tokens: SessionMessage.CompactionCompleted.fields.tokens,
|
||||
},
|
||||
})
|
||||
export type Ended = typeof Ended.Type
|
||||
@@ -603,6 +607,8 @@ export namespace Compaction {
|
||||
reason: Started.data.fields.reason,
|
||||
error: SessionError.Error,
|
||||
inputID: SessionMessage.ID.pipe(optional),
|
||||
cost: SessionMessage.CompactionFailed.fields.cost,
|
||||
tokens: SessionMessage.CompactionFailed.fields.tokens,
|
||||
},
|
||||
})
|
||||
export type Failed = typeof Failed.Type
|
||||
|
||||
@@ -237,6 +237,12 @@ export const Assistant = Schema.Struct({
|
||||
|
||||
const CompactionBase = { type: Schema.tag("compaction"), ...Base }
|
||||
|
||||
/** Usage of the compaction request itself, not the size of the resulting context. */
|
||||
const CompactionUsage = {
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
}
|
||||
|
||||
export interface CompactionRunning extends Schema.Schema.Type<typeof CompactionRunning> {}
|
||||
export const CompactionRunning = Schema.Struct({
|
||||
...CompactionBase,
|
||||
@@ -256,6 +262,7 @@ export const CompactionCompleted = Schema.Struct({
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
providerContext: SessionProviderContext.Info.pipe(optional),
|
||||
...CompactionUsage,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
|
||||
@@ -264,6 +271,7 @@ export const CompactionFailed = Schema.Struct({
|
||||
status: Schema.tag("failed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
error: SessionError.Error,
|
||||
...CompactionUsage,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Failed" })
|
||||
|
||||
export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted, CompactionFailed]).pipe(
|
||||
|
||||
@@ -7,7 +7,7 @@ for (const theme of ["light", "dark"]) {
|
||||
globals: { theme },
|
||||
})
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]').filter({ hasText: "Patch" })
|
||||
const disclosure = group.getByRole("button", { name: /^\d+ used .*Edit.*Write.*Patch$/ })
|
||||
const disclosure = group.getByRole("button", { name: /^Used \d+ .*Edit.*Write.*Patch$/ })
|
||||
await disclosure.click()
|
||||
for (const name of ["edit", "write", "patch"]) {
|
||||
const tool = group.locator(`[data-timeline-part-id="tool_family_${name}"]`)
|
||||
|
||||
@@ -10,9 +10,9 @@ story("merges follow-up patches into one stack with a distinct file count", asyn
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-expanded", "true")
|
||||
await root.getByRole("button", { name: "Start follow-up patch" }).click()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText("3 used")
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("3")
|
||||
await expect(patches).toHaveCount(1)
|
||||
await expect(patches.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Finish follow-up patch" }).click()
|
||||
|
||||
@@ -9,7 +9,7 @@ const png = Buffer.from(
|
||||
|
||||
story.beforeEach(async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
await expect(root.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const grouped of [true, false]) {
|
||||
|
||||
@@ -17,9 +17,11 @@ for (const tool of ["shell", "execute", "subagent"]) {
|
||||
for (const action of [undefined, "Complete input", "Run command", "Complete command"]) {
|
||||
if (action) await timeline.getByRole("button", { name: action, exact: true }).click()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", "tool_context_lifecycle,tool_shell_lifecycle")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText("2 used")
|
||||
const usage = group.locator(
|
||||
'[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]',
|
||||
)
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText("2")
|
||||
await expect(timeline.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(1)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(open))
|
||||
expect(await original!.evaluate((node) => node.isConnected)).toBe(true)
|
||||
@@ -35,7 +37,7 @@ for (const expanded of [false, true]) {
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { expanded } })
|
||||
const trigger = expanded
|
||||
? timeline.locator('[data-timeline-part-id="tool_shell_lifecycle"] [data-slot="collapsible-trigger"]')
|
||||
: timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
: timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(expanded))
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", String(!expanded))
|
||||
@@ -83,7 +85,7 @@ story("transitions a streaming shell from writing through command execution", as
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
await expect(tool).not.toContainText("Writing command…")
|
||||
await timeline.getByRole("button", { name: "Complete command" }).click()
|
||||
const summary = timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
const summary = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(summary).toHaveAttribute("aria-expanded", "false")
|
||||
await summary.click()
|
||||
await expect(subtitle).toHaveText("printf ready")
|
||||
@@ -130,7 +132,7 @@ for (const open of [false, true]) {
|
||||
await expect(thought).not.toContainText("Inspecting stability")
|
||||
await expect(thought).toHaveAttribute("aria-expanded", String(open))
|
||||
await timeline.getByRole("button", { name: "Finish session" }).click()
|
||||
const used = group.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
const used = group.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(used).toHaveAttribute("aria-expanded", "false")
|
||||
await used.click()
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -187,7 +189,7 @@ for (const locale of ["de", "ar"] as const) {
|
||||
await timeline.getByRole("button", { name: "Complete read" }).click()
|
||||
await timeline.getByRole("button", { name: "Complete glob" }).click()
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_context_read,tool_context_glob"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^2 used /)
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName(/^Used 2 /)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText(locale === "de" ? "Lesen, Glob" : "\u0642\u0631\u0627\u0621\u0629, Glob")
|
||||
|
||||
@@ -50,7 +50,7 @@ for (const mode of ["hidden", "compact", "full"] as const) {
|
||||
if (following === "tool") {
|
||||
const group = timeline.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveText(/^1 used\s*Skill$/)
|
||||
await expect(trigger).toHaveText(/^Used\s*1\s*Skill$/)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
|
||||
@@ -53,7 +53,7 @@ story("space activates a focused timeline button instead of scrolling", async ({
|
||||
await page.setViewportSize({ width: 800, height: 240 })
|
||||
const timeline = await mount("current-session-terminal-work--terminal-commands", { args: { scenario: "collapsed" } })
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollHeight - innerHeight)).toBeGreaterThan(0)
|
||||
const trigger = timeline.getByRole("button", { name: "1 used Shell", exact: true })
|
||||
const trigger = timeline.getByRole("button", { name: "Used 1 Shell", exact: true })
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await trigger.focus()
|
||||
const before = await page.evaluate(() => window.scrollY)
|
||||
|
||||
@@ -44,9 +44,9 @@ story("renders every tool error outcome without leaking hidden tools", async ({
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "failures" } })
|
||||
const names = ["shell", "edit", "write", "patch", "webfetch", "websearch", "subagent", "skill", "mcp_probe"]
|
||||
const group = timeline.locator(`[data-timeline-part-ids="${names.map((name) => `tool_error_${name}`).join(",")}"]`)
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-prefix"]'),
|
||||
).toHaveText(`${names.length} used`)
|
||||
const usage = group.locator('[data-component="context-tool-group-trigger"] [data-slot="context-tool-group-usage"]')
|
||||
await expect(usage.locator('[data-slot="context-tool-group-prefix"]')).toHaveText("Used")
|
||||
await expect(usage.locator('[data-slot="context-tool-group-count"]')).toHaveText(String(names.length))
|
||||
await group.getByRole("button").click()
|
||||
await expect(timeline.locator('[data-kind="tool-error-card"]')).toHaveCount(names.length + 1)
|
||||
const dismissed = timeline.locator('[data-timeline-part-id="tool_error_question_dismissed"]')
|
||||
@@ -72,7 +72,7 @@ story("transitions shell and question through running error outcomes", async ({
|
||||
// Moved from packages/app/e2e/regression/session-timeline-tool-projection.spec.ts
|
||||
story("labels all web search provider variants", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "providers" } })
|
||||
await timeline.getByRole("button", { name: "3 used Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
await timeline.getByRole("button", { name: "Used 3 Parallel Web Search, Exa Web Search, Web Search" }).click()
|
||||
const tools = timeline.locator('[data-component="context-tool-group-list"]')
|
||||
await expect(tools.getByRole("button", { name: /Parallel Web Search/ })).toBeVisible()
|
||||
await expect(tools.getByRole("button", { name: /Exa Web Search/ })).toBeVisible()
|
||||
@@ -105,7 +105,7 @@ story("labels read tools from their path input", async ({ mount }) => {
|
||||
story("labels skill tools from IDs and result metadata", async ({ mount }) => {
|
||||
const timeline = await mount("current-session-research-agents--agent-research", { args: { scenario: "skills" } })
|
||||
const group = timeline.locator('[data-timeline-part-ids="tool_skill_id,tool_skill_name"]')
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("2 used Skill")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 2 Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Skill")
|
||||
@@ -130,7 +130,7 @@ story("groups every collapsed tool until visible text separates the stack", asyn
|
||||
'[data-timeline-part-ids="tool_boundary_glob,tool_boundary_grep,tool_boundary_shell,tool_boundary_list"]',
|
||||
)
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("4 used Glob, Grep, Shell, List")
|
||||
await expect(group.getByRole("button")).toHaveAccessibleName("Used 4 Glob, Grep, Shell, List")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Glob, Grep, Shell, List")
|
||||
|
||||
@@ -6,8 +6,8 @@ for (const open of [true, false]) {
|
||||
async ({ mount }, info) => {
|
||||
const root = await mount("current-session-file-changes--appending-tool-calls")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: /^\d+ used Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("2 used Shell, Patch")
|
||||
const trigger = group.getByRole("button", { name: /^Used \d+ Shell, Patch$/ })
|
||||
await expect(trigger).toHaveAccessibleName("Used 2 Shell, Patch")
|
||||
await trigger.click()
|
||||
const shell = group.locator('[data-timeline-part-id="tool_shell_existing"] [data-slot="collapsible-trigger"]')
|
||||
await group.locator('[data-timeline-part-id="tool_patch_existing"]').evaluate((element) => {
|
||||
@@ -34,7 +34,7 @@ for (const open of [true, false]) {
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Patch")
|
||||
await expect(trigger).toHaveAccessibleName(`${count} used Shell, Patch`)
|
||||
await expect(trigger).toHaveAccessibleName(`Used ${count} Shell, Patch`)
|
||||
await expect(diff).toBeVisible()
|
||||
await root
|
||||
.locator('[data-component="session-timeline"]')
|
||||
|
||||
@@ -6,11 +6,11 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-reasoning", { args: { reasoningDefaultOpen } })
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const used = group.getByRole("button", { name: /^\d+ used Read, Skill$/ })
|
||||
const used = group.getByRole("button", { name: /^Used \d+ Read, Skill$/ })
|
||||
const first = group.locator('[data-timeline-part-id="reasoning_first"]')
|
||||
const second = group.locator('[data-timeline-part-id="reasoning_second"]')
|
||||
await expect(used).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(used).toHaveAccessibleName("4 used Read, Skill")
|
||||
await expect(used).toHaveAccessibleName("Used 4 Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Read, Skill")
|
||||
@@ -34,7 +34,7 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
)
|
||||
await first.getByRole("button", { name: "Thought", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Append follow-up read", exact: true }).click()
|
||||
await expect(used).toHaveAccessibleName("5 used Read, Skill")
|
||||
await expect(used).toHaveAccessibleName("Used 5 Read, Skill")
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Read, Skill")
|
||||
@@ -70,10 +70,19 @@ for (const reasoningDefaultOpen of [false, true]) {
|
||||
story("summarizes subagents as Agent while retaining their card titles", async ({ mount }) => {
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(group.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
await expect(
|
||||
group.locator('[data-component="context-tool-group-trigger"] [data-slot="basic-tool-tool-title"]'),
|
||||
).toHaveText("Shell, Read, Agent")
|
||||
await expect(group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })).toBeVisible()
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
const prefix = header.locator('[data-slot="context-tool-group-prefix"]')
|
||||
const count = header.locator('[data-slot="context-tool-group-count"]')
|
||||
const title = header.locator('[data-slot="basic-tool-tool-title"]')
|
||||
await expect(prefix).toHaveText("Used")
|
||||
await expect(count).toHaveText("4")
|
||||
await expect(title).toHaveText("Shell, Read, Agent")
|
||||
const colors = await Promise.all(
|
||||
[prefix, count, title].map((part) => part.evaluate((node) => getComputedStyle(node).color)),
|
||||
)
|
||||
expect(colors[1]).toBe(colors[2])
|
||||
expect(colors[0]).not.toBe(colors[1])
|
||||
const gap = await group.evaluate((element) => {
|
||||
const title = element.querySelector('[data-component="context-tool-group-trigger"]')!.getBoundingClientRect()
|
||||
const arrow = element.querySelector('[data-slot="collapsible-arrow-icon"]')!.getBoundingClientRect()
|
||||
@@ -88,7 +97,7 @@ for (const width of [840, 390]) {
|
||||
await page.setViewportSize({ width, height: 600 })
|
||||
const root = await mount("current-tool-group--mixed-tools")
|
||||
const group = root.locator('[data-component="collapsed-tool-group"]')
|
||||
const trigger = group.getByRole("button", { name: "4 used Shell, Read, Agent", exact: true })
|
||||
const trigger = group.getByRole("button", { name: "Used 4 Shell, Read, Agent", exact: true })
|
||||
const header = group.locator('[data-component="context-tool-group-trigger"]')
|
||||
await expect(header.locator('[data-slot="basic-tool-tool-title"]')).toHaveText("Shell, Read, Agent")
|
||||
await expect(header.locator('[data-component="tag"]')).toHaveCount(0)
|
||||
|
||||
@@ -607,19 +607,35 @@
|
||||
[data-slot="context-tool-group-title"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
[data-slot="context-tool-group-usage"] {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-family: var(--v2-font-family-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
|
||||
& > [data-slot="context-tool-group-prefix"],
|
||||
& > [data-slot="context-tool-group-count"] {
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-count"] {
|
||||
color: var(--v2-text-text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-prefix"] {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="basic-tool-tool-title"] {
|
||||
|
||||
@@ -395,17 +395,38 @@ export function SessionCompactionMessage(props: { message: SessionMessageCompact
|
||||
if (props.message.status !== "failed" || props.message.error.type === "aborted") return ""
|
||||
return props.error
|
||||
}
|
||||
const compact = createMemo(
|
||||
() => new Intl.NumberFormat(i18n.locale(), { notation: "compact", maximumFractionDigits: 1 }),
|
||||
)
|
||||
// Usage of the compaction request itself; the resulting context size only shows on the next assistant step.
|
||||
const usage = () => {
|
||||
if (props.message.status === "running" || !props.message.tokens) return ""
|
||||
const tokens = props.message.tokens
|
||||
const input = tokens.input + tokens.cache.read + tokens.cache.write
|
||||
const output = tokens.output + tokens.reasoning
|
||||
if (input + output <= 0) return ""
|
||||
return i18n.t("ui.messagePart.compaction.usage", {
|
||||
input: compact().format(input),
|
||||
output: compact().format(output),
|
||||
})
|
||||
}
|
||||
const label = createMemo(() =>
|
||||
[
|
||||
i18n.t(
|
||||
props.message.status === "completed" && props.message.providerContext
|
||||
? "ui.messagePart.providerCompaction"
|
||||
: "ui.messagePart.compaction",
|
||||
),
|
||||
usage(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-compaction-message">
|
||||
<div class="py-2">
|
||||
<TimelineSeparator
|
||||
label={i18n.t(
|
||||
props.message.status === "completed" && props.message.providerContext
|
||||
? "ui.messagePart.providerCompaction"
|
||||
: "ui.messagePart.compaction",
|
||||
)}
|
||||
/>
|
||||
<TimelineSeparator label={label()} />
|
||||
</div>
|
||||
<Show when={summary().trim()}>
|
||||
<div data-component="text-part" data-timeline-part-id={props.message.id}>
|
||||
|
||||
@@ -998,6 +998,8 @@ export const compactionDocument = document([
|
||||
reason: "auto",
|
||||
summary: "The Session timeline now consumes current nested assistant content.",
|
||||
recent: "Add deterministic stories and verify Storybook.",
|
||||
cost: 0.0142,
|
||||
tokens: { input: 3_180, output: 412, reasoning: 96, cache: { read: 8_704, write: 0 } },
|
||||
time: { created: STORY_TIME + 63_000 },
|
||||
},
|
||||
assistant({
|
||||
|
||||
@@ -536,13 +536,25 @@ export function CurrentContextToolGroup(props: {
|
||||
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
|
||||
if (!names() && !thoughts) {
|
||||
const title = i18n.t("ui.messagePart.context.details")
|
||||
return { text: title, title, before: "", after: "" }
|
||||
return { text: title, title, before: "", count: "", between: "", after: "" }
|
||||
}
|
||||
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
|
||||
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
|
||||
const text = i18n.plural("ui.messagePart.tools.used", count, { tools: title })
|
||||
const index = text.indexOf(title)
|
||||
return { text, title, before: text.slice(0, index).trim(), after: text.slice(index + title.length).trim() }
|
||||
const before = text.slice(0, index).trim()
|
||||
const countText = String(count)
|
||||
const countIndex = before.indexOf(countText)
|
||||
const after = text.slice(index + title.length).trim()
|
||||
if (countIndex === -1) return { text, title, before, count: "", between: "", after }
|
||||
return {
|
||||
text,
|
||||
title,
|
||||
before: before.slice(0, countIndex).trim(),
|
||||
count: countText,
|
||||
between: before.slice(countIndex + countText.length).trim(),
|
||||
after,
|
||||
}
|
||||
})
|
||||
const items = createMemo(() =>
|
||||
props.parts.reduce<(SessionMessageAssistantTool[] | Exclude<ContextGroupPart, SessionMessageAssistantTool>)[]>(
|
||||
@@ -607,8 +619,18 @@ export function CurrentContextToolGroup(props: {
|
||||
trigger={
|
||||
<div data-component="context-tool-group-trigger" aria-label={label().text}>
|
||||
<span data-slot="context-tool-group-title">
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()}</span>}
|
||||
<Show when={label().before || label().count || label().between}>
|
||||
<span data-slot="context-tool-group-usage">
|
||||
<Show when={label().before}>
|
||||
{(before) => <span data-slot="context-tool-group-prefix">{before()} </span>}
|
||||
</Show>
|
||||
<Show when={label().count}>
|
||||
{(count) => <span data-slot="context-tool-group-count">{count()} </span>}
|
||||
</Show>
|
||||
<Show when={label().between}>
|
||||
{(between) => <span data-slot="context-tool-group-prefix">{between()} </span>}
|
||||
</Show>
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="basic-tool-tool-title">{label().title}</span>
|
||||
<Show when={label().after}>
|
||||
|
||||
@@ -105,6 +105,14 @@
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 3px;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-component="session-review-v2-sidebar-root"]
|
||||
[data-slot="session-review-v2-sidebar-filter"]
|
||||
[data-component="text-input-v2"]:where(:hover, :focus-within):not([data-disabled], [data-invalid]) {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] {
|
||||
|
||||
@@ -395,6 +395,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
if (!enabled()) return
|
||||
route.navigate({ type: "session", sessionID: root(sessionID) })
|
||||
},
|
||||
open(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
if (state().tabs.some((tab) => tab.sessionID === session)) return
|
||||
cancelledTabs.delete(session)
|
||||
update((draft) => {
|
||||
draft.tabs = openSessionTab(draft.tabs, { sessionID: session, title: title(session) })
|
||||
})
|
||||
},
|
||||
promote(sessionID: string) {
|
||||
if (!enabled()) return
|
||||
const session = root(sessionID)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user