diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index d332ca32acc..c6af7a08cce 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -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}`) diff --git a/packages/app/src/composer/editor/editor.css b/packages/app/src/composer/editor/editor.css index 2998f380743..ec5f9c55d7e 100644 --- a/packages/app/src/composer/editor/editor.css +++ b/packages/app/src/composer/editor/editor.css @@ -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); +} diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 29c903e3e7b..96bf45da042 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -114,10 +114,8 @@ export function ComposerEditor(props: ComposerEditorProps) {
{ 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( diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index ebba09f8521..ff59fd90094 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -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({ diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index b35308626c9..c9622e5ee45 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -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}`)) diff --git a/packages/core/test/effect/cross-spawn-spawner.test.ts b/packages/core/test/effect/cross-spawn-spawner.test.ts index d9cb9c08203..3c3a018f814 100644 --- a/packages/core/test/effect/cross-spawn-spawner.test.ts +++ b/packages/core/test/effect/cross-spawn-spawner.test.ts @@ -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", diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index e50ec46818b..c5a2bf30ad1 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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") diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 84699cf5a25..2ed9b93cc7c 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -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) diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index 6a53aabb803..f0e91e35eb8 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -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); } diff --git a/packages/tui/test/mini/footer.view.test.tsx b/packages/tui/test/mini/footer.view.test.tsx index 07f030f357f..4fcaacf50ff 100644 --- a/packages/tui/test/mini/footer.view.test.tsx +++ b/packages/tui/test/mini/footer.view.test.tsx @@ -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 }, diff --git a/packages/tui/test/mini/scrollback.surface.test.ts b/packages/tui/test/mini/scrollback.surface.test.ts index a5be6effd76..cb8b87c1b34 100644 --- a/packages/tui/test/mini/scrollback.surface.test.ts +++ b/packages/tui/test/mini/scrollback.surface.test.ts @@ -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)) diff --git a/packages/tui/test/prompt/local-attachment.test.ts b/packages/tui/test/prompt/local-attachment.test.ts index 5aedbef8267..187c714c9a6 100644 --- a/packages/tui/test/prompt/local-attachment.test.ts +++ b/packages/tui/test/prompt/local-attachment.test.ts @@ -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 = "\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() }) }) diff --git a/packages/util/src/cross-spawn-spawner.ts b/packages/util/src/cross-spawn-spawner.ts index 35c63416113..a8f8623d0a5 100644 --- a/packages/util/src/cross-spawn-spawner.ts +++ b/packages/util/src/cross-spawn-spawner.ts @@ -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) {} }