Merge remote-tracking branch 'origin/v2' into default-preview-tabs

This commit is contained in:
Kit Langton 2026-08-31 20:27:45 -04:00
commit eaba0f3663
14 changed files with 175 additions and 66 deletions

View file

@ -1,5 +1,16 @@
import { expect, story } from "../../storybook/playwright/story"
story("raises the docked composer only in dark mode", async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--empty-draft")
const composer = component.locator('[data-component="composer"]')
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "light"))
await expect(composer).toHaveCSS("background-color", "rgb(255, 255, 255)")
await page.locator("html").evaluate((root) => root.setAttribute("data-color-scheme", "dark"))
await expect(composer).toHaveCSS("background-color", "rgb(36, 36, 36)")
})
for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
story(`select all stays inside the composer with ${draft}`, async ({ mount, page }) => {
const component = await mount(`opencode-composer-flow--${draft}`)

View file

@ -1,3 +1,7 @@
[data-component="composer-editor"]:empty::before {
content: "\200B";
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"] {
background: var(--v2-background-bg-layer-01);
}

View file

@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
<form
data-component="composer"
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl bg-v2-background-bg-base"
classList={{
"bg-v2-background-bg-layer-01": props.borderUnderlay,
"bg-v2-background-bg-base": !props.borderUnderlay,
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
"border border-v2-icon-icon-info border-dashed": state.drag === "active",
}}

View file

@ -53,7 +53,6 @@ export const fromSpec = (options: Options): Result => {
if (!isRecord(pathValue)) continue
for (const [method, operationValue] of Object.entries(pathValue)) {
if (!methods.has(method) || !isRecord(operationValue)) continue
const segments = operationPath(method, path, operationValue, used, namespaces)
const operation: Operation = {
operationId: nonEmptyString(operationValue.operationId),
method: method.toUpperCase(),
@ -99,6 +98,7 @@ export const fromSpec = (options: Options): Result => {
auth: options.auth,
headers: options.headers ?? {},
}
const segments = operationPath(method, path, operationValue, used, namespaces)
used.add(segments.join("."))
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
setTool(

View file

@ -282,6 +282,30 @@ describe("OpenAPI.fromSpec", () => {
expect(Tool.isTool(toolAt(result.tools, "group.operation.other"))).toBe(true)
})
test("does not reserve names for unsupported operations between duplicate operation IDs", () => {
const operation = { operationId: "group.item", responses: { 200: { description: "Success" } } }
for (const unsupported of [false, true]) {
const result = OpenAPI.fromSpec({
baseUrl,
spec: {
openapi: "3.1.0",
paths: {
"/first": { get: operation },
...(unsupported ? { "/unsupported": { get: { ...operation, "x-websocket": true } } } : {}),
"/last": { get: operation },
},
},
})
expect(Object.keys(result.tools)).toEqual(["group", "group_item_2"])
expect(toolAt(result.tools, "group.item")).toMatchObject({ _tag: "CodeModeTool", description: "GET /first" })
expect(toolAt(result.tools, "group_item_2")).toMatchObject({ _tag: "CodeModeTool", description: "GET /last" })
expect(result.skipped).toEqual(
unsupported ? [{ method: "GET", path: "/unsupported", reason: "WebSocket operations are not supported" }] : [],
)
}
})
test("synthesizes flat operation IDs from methods and paths", () => {
const response = { responses: { 200: { description: "Success" } } }
const tools = OpenAPI.fromSpec({

View file

@ -96,7 +96,7 @@ const layer = Layer.effect(
step = 1
}
if (pending?.type === "move")
return DrainResult.Moved({ continuation: !entering && continuing ? { step } : undefined })
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
if (pending?.type === "compaction") {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))

View file

@ -147,6 +147,20 @@ describe("cross-spawn spawner", () => {
})
describe("stderr", () => {
fx.live(
"captures both streams across backpressure",
Effect.gen(function* () {
const handle = yield* js(
'process.stdout.write("o".repeat(256 * 1024)); process.stderr.write("e".repeat(256 * 1024))',
)
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["o".repeat(256 * 1024), "e".repeat(256 * 1024)])
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"captures stderr output",
Effect.gen(function* () {
@ -199,6 +213,30 @@ describe("cross-spawn spawner", () => {
)
})
describe("delayed output consumption", () => {
for (const combined of [false, true]) {
fx.live(
`retains ${combined ? "combined" : "separate"} output after process completion`,
Effect.gen(function* () {
const handle = yield* js(
'require("node:fs").writeSync(1, "stdout\\n"); require("node:fs").writeSync(2, "stderr\\n")',
)
expect(yield* handle.exitCode).toBe(ChildProcessSpawner.ExitCode(0))
if (combined) {
const output = yield* decodeByteStream(handle.all)
expect(output).toContain("stdout")
expect(output).toContain("stderr")
return
}
const output = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
concurrency: "unbounded",
})
expect(output).toEqual(["stdout", "stderr"])
}),
)
}
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",

View file

@ -1454,32 +1454,49 @@ describe("SessionRunnerLLM", () => {
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
})
scenario("preserves a tool continuation across a steered move", function* (s) {
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery: "steer",
},
for (const delivery of ["steer", "queue"] as const) {
scenario(`preserves a tool continuation and step allowance across chained moves (${delivery})`, function* (s) {
const agents = yield* Agent.Service
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
agent.steps = 2
}),
)
yield* s.admit("Echo before moving")
yield* s.llm.push(TestLLM.tool("call-move", "echo", { text: "moving" }), TestLLM.text("Done", "text-after-move"))
const tools = yield* s.blockTools()
const run = yield* s.resume.pipe(Effect.forkChild)
yield* tools.started
yield* Effect.forEach(["steer", delivery] as const, (delivery) =>
s.sessionInbox.admit({
id: SessionMessage.ID.create(),
sessionID,
item: {
type: "move",
payload: {
location: Location.Ref.make({ directory: AbsolutePath.make("/project") }),
projectID: Project.ID.global,
},
delivery,
},
}),
)
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(messageRoles(s.requests[1])?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(s.requests[0]?.toolChoice).toBeUndefined()
expect(s.requests[1]?.toolChoice).toMatchObject({ type: "none" })
expect(
(yield* recordedEventTypes(sessionID)).filter(
(type) => type === "session.step.started.1" || type === "session.moved.1",
),
).toEqual(["session.step.started.1", "session.moved.1", "session.moved.1", "session.step.started.1"])
expect(yield* s.inbox).toEqual([])
})
yield* tools.release
yield* Fiber.join(run)
expect(s.requests).toHaveLength(2)
expect(s.requests.map(messageRoles).at(1)?.slice(0, 3)).toEqual(["user", "assistant", "tool"])
expect(yield* s.inbox).toEqual([])
})
}
scenario("keeps queued input parked across a mid-turn move", function* (s) {
yield* s.admit("Echo before moving")

View file

@ -153,6 +153,19 @@ story("mounts cached completed Markdown with sanitized HTML and decorations", as
await expect(markdown).toHaveAttribute("data-markdown-ready", "")
})
story("keeps inline code backgrounds 18px tall", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountMarkdown } = await import(fixture)
await mountMarkdown({ text: "`value` and `src/file.ts`" })
}, fixture)
const code = page.getByTestId("markdown-fixture").locator(":not(pre) > code")
await expect(code).toHaveCount(2)
await expect(code.nth(1)).toHaveAttribute("data-inline-code-kind", "path")
expect(
await code.evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().height)),
).toEqual([18, 18])
})
story("shares in-flight Markdown rendering without overwriting a reclaimed cache entry", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { getCachedMarkdown, renderCachedMarkdown } = await import(fixture)

View file

@ -283,7 +283,7 @@
font-feature-settings: var(--font-family-mono--font-feature-settings);
color: var(--v2-text-text-base);
font-weight: var(--font-weight-medium);
padding: 2px 4px;
padding: 0 4px;
border-radius: 4px;
background: color-mix(in oklch, var(--v2-text-text-base) var(--markdown-inline-code-bg-mix), transparent);
}

View file

@ -239,7 +239,9 @@ async function renderFooter(
}
}
test.each([
// OpenTUI image teardown crashes Bun 1.3.14's Windows test runner after the assertions pass.
// Keep the native preview coverage on Linux while the attachment behavior remains covered on both platforms below.
test.skipIf(process.platform === "win32").each([
{ width: 80, height: 24, mono: false, preview: true },
{ width: 24, height: 8, mono: false, preview: true },
{ width: 80, height: 24, mono: true, preview: true },

View file

@ -93,6 +93,8 @@ async function setup(
return {
renderer: out.renderer,
renderOnce: out.renderOnce,
externalOutput: out.externalOutput,
scrollback: new RunScrollbackStream(out.renderer, input.theme ?? RUN_THEME_FALLBACK, {
treeSitterClient,
wrote: input.wrote ?? false,
@ -220,28 +222,22 @@ test.each([false, true])("monochrome switches preserve printed blocks and open f
await out.scrollback.setMono(mono)
out.scrollback.setTheme(mono ? RUN_THEME_MONO : RUN_THEME_FALLBACK)
await out.scrollback.append(assistant('Printed block\n\n```ts\nconst arrow = "'))
const printed = claim(out.renderer)
try {
expect(render(printed)).toContain("Printed block")
expect(render(printed)).not.toContain("const arrow")
await out.scrollback.setMono(!mono)
out.scrollback.setTheme(mono ? RUN_THEME_FALLBACK : RUN_THEME_MONO)
expect(render(claim(out.renderer))).toBe("")
await out.scrollback.append(assistant('\u2192"\n```\n\nNext block'))
await out.scrollback.complete()
const next = claim(out.renderer)
try {
expect(render(next)).toContain(mono ? 'const arrow = "\u2192"' : 'const arrow = "->"')
expect(render(next)).toContain("Next block")
expect(render(next)).not.toContain("Printed block")
expect(render(next)).not.toContain("```")
expect(render(printed)).toContain("Printed block")
} finally {
destroy(next)
}
} finally {
destroy(printed)
}
await out.renderOnce()
const printed = out.externalOutput.takeText()
expect(printed).toContain("Printed block")
expect(printed).not.toContain("const arrow")
await out.scrollback.setMono(!mono)
out.scrollback.setTheme(mono ? RUN_THEME_FALLBACK : RUN_THEME_MONO)
expect(out.externalOutput.takeText()).toBe("")
await out.scrollback.append(assistant('\u2192"\n```\n\nNext block'))
// A frame can flush the code block while completion is awaiting highlighting.
await out.renderOnce()
await out.scrollback.complete()
const next = out.externalOutput.takeText()
expect(next).toContain(mono ? 'const arrow = "\u2192"' : 'const arrow = "->"')
expect(next).toContain("Next block")
expect(next).not.toContain("Printed block")
expect(next).not.toContain("```")
} finally {
out.scrollback.destroy()
destroy(claim(out.renderer))

View file

@ -96,7 +96,7 @@ describe("prompt local attachments", () => {
await Bun.write(file, new Uint8Array([1, 2, 3]))
for (const input of [file, `'${file}'`, pathToFileURL(file).href]) {
expect(await resolvePastedAttachments(input, "linux")).toEqual([
expect(await resolvePastedAttachments(input, process.platform)).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
])
}
@ -112,7 +112,7 @@ describe("prompt local attachments", () => {
`'${image}' "${pdf}"`,
`# dropped files\r\n${pathToFileURL(image).href}\r\n${pathToFileURL(pdf).href}`,
]) {
expect(await resolvePastedAttachments(input, "linux")).toEqual([
expect(await resolvePastedAttachments(input, process.platform)).toEqual([
{ type: "file", uri: "data:image/png;base64,AQID", filename: "one image.png" },
{ type: "file", uri: "data:application/pdf;base64,BAUG", filename: "two file.pdf" },
])
@ -133,7 +133,7 @@ describe("prompt local attachments", () => {
`${image} ${text}`,
`${image} ${path.join(tmp.path, "missing.png")}`,
]) {
expect(await resolvePastedAttachments(input, "linux")).toBeUndefined()
expect(await resolvePastedAttachments(input, process.platform)).toBeUndefined()
}
})
@ -143,7 +143,9 @@ describe("prompt local attachments", () => {
const content = "<svg />\r\n"
await Bun.write(file, content)
expect(await resolvePastedAttachments(file, "linux")).toEqual([{ type: "text", content, filename: "image.svg" }])
expect(await resolvePastedAttachments(file, process.platform)).toEqual([
{ type: "text", content, filename: "image.svg" },
])
})
test("shares the byte budget across binary and SVG attachments", async () => {
@ -156,15 +158,15 @@ describe("prompt local attachments", () => {
Bun.write(svg, content),
])
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toMatchObject([
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toMatchObject([
{ type: "file", filename: "image.png" },
{ type: "text", content, filename: "image.svg" },
])
await Bun.write(svg, content + " ")
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toBeUndefined()
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toBeUndefined()
await Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES + 1))
expect(await resolvePastedAttachments(image, "linux")).toBeUndefined()
expect(await resolvePastedAttachments(image, process.platform)).toBeUndefined()
})
test("bounds the number of resolved paths", async () => {
@ -172,7 +174,7 @@ describe("prompt local attachments", () => {
const file = path.join(tmp.path, "image.png")
await Bun.write(file, new Uint8Array([1]))
expect(await resolvePastedAttachments(Array(32).fill(file).join(" "), "linux")).toHaveLength(32)
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), "linux")).toBeUndefined()
expect(await resolvePastedAttachments(Array(32).fill(file).join(" "), process.platform)).toHaveLength(32)
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), process.platform)).toBeUndefined()
})
})

View file

@ -240,10 +240,13 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
) => {
const capture = (readable: NodeChildProcess.ChildProcess["stdout"], name: string) => {
if (!readable) return Stream.empty
// Bun resumes stdio on exit; retain bytes before the lazy Effect reader attaches.
const buffer = new PassThrough()
readable.on("error", (cause) => buffer.destroy(toError(cause)))
readable.pipe(buffer)
return NodeStream.fromReadable({
evaluate: () => readable,
evaluate: () => buffer,
onError: (cause) => toPlatformError(`fromReadable(${name})`, toError(cause), command),
closeOnDone: false,
}).pipe(
Stream.interruptWhen(Deferred.await(stopOutput)),
Stream.ensuring(
@ -318,7 +321,8 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
const discard = (readable: NodeChildProcess.ChildProcess["stdout"]) => {
if (!readable || readable.destroyed) return
// read() also drains while a backpressured Effect adapter still has a readable listener.
readable.unpipe()
// Discard descendant output without refilling a capture buffer that is no longer consumed.
const drain = () => {
while (readable.read() !== null) {}
}