mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-03 05:44:44 +00:00
Merge remote-tracking branch 'origin/v2' into default-preview-tabs
This commit is contained in:
commit
eaba0f3663
14 changed files with 175 additions and 66 deletions
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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}`))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue