From 9553187ba65f41664445081533d01664ab645e00 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:24:04 -0500 Subject: [PATCH 1/8] fix(core): allow patch file-to-directory replacements (#46476) --- packages/core/test/tool-patch.test.ts | 16 ++++++++++++++++ packages/util/src/fs-util.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index 30e95e5486b..01aac61f476 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -241,6 +241,22 @@ describe("PatchTool", () => { ), ) + it.live("replaces a file with a directory containing an added file", () => + withTempTool((directory, registry) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.writeFile(path.join(directory, "parent"), "before\n")) + const settled = yield* executeTool( + registry, + call("*** Begin Patch\n*** Delete File: parent\n*** Add File: parent/child.txt\n+after\n*** End Patch"), + ) + expect(settled.status).toBe("completed") + expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "parent/child.txt"), "utf8"))).toBe( + "after\n", + ) + }), + ), + ) + it.live("counts deleted lines with and without a trailing newline", () => withTempTool((directory, registry) => Effect.gen(function* () { diff --git a/packages/util/src/fs-util.ts b/packages/util/src/fs-util.ts index 17cbb90b0cc..c478f56fd58 100644 --- a/packages/util/src/fs-util.ts +++ b/packages/util/src/fs-util.ts @@ -250,7 +250,7 @@ export namespace FSUtil { try { return normalizePath(realpathSync(resolved)) } catch (e: any) { - if (e?.code === "ENOENT") return normalizePath(resolved) + if (e?.code === "ENOENT" || e?.code === "ENOTDIR") return normalizePath(resolved) throw e } } From db768c48861da5b58d0cc9a5ad6089de952d1fd4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 07:03:50 +0800 Subject: [PATCH 2/8] refactor(app): share mobile drawer primitive (#46453) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- .../regression/mobile-status-drawer.spec.ts | 4 +- packages/app/src/shell/mobile-drawer.css | 90 ++++++++++++ packages/app/src/shell/mobile-drawer.tsx | 47 ++++++ .../app/src/shell/mobile-panel-drawer.css | 34 +++++ .../app/src/shell/mobile-panel-drawer.tsx | 42 +++--- .../app/src/shell/status/status-drawer.css | 136 ------------------ .../app/src/shell/status/status-drawer.tsx | 1 + packages/app/src/shell/titlebar/titlebar.css | 84 +---------- packages/app/src/shell/titlebar/titlebar.tsx | 34 ++--- 9 files changed, 208 insertions(+), 264 deletions(-) create mode 100644 packages/app/src/shell/mobile-drawer.css create mode 100644 packages/app/src/shell/mobile-drawer.tsx create mode 100644 packages/app/src/shell/mobile-panel-drawer.css diff --git a/packages/app/e2e/regression/mobile-status-drawer.spec.ts b/packages/app/e2e/regression/mobile-status-drawer.spec.ts index a30d87f715c..efd6e48a1f3 100644 --- a/packages/app/e2e/regression/mobile-status-drawer.spec.ts +++ b/packages/app/e2e/regression/mobile-status-drawer.spec.ts @@ -10,7 +10,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr .locator('[data-slot="session-mobile-view-navigation"]') .getByRole("button", { name: "More options", exact: true }) const drawer = page.getByRole("dialog", { name: "Status", exact: true }) - const overlay = page.locator('[data-slot="mobile-status-overlay"]') + const overlay = page.locator('[data-slot="mobile-drawer-overlay"]') for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) { await more.click() @@ -21,7 +21,7 @@ test("status drawer dismisses and reopens after button, backdrop, Escape, and dr if (dismissal === "backdrop") await overlay.click({ position: { x: 10, y: 10 } }) if (dismissal === "escape") await page.keyboard.press("Escape") if (dismissal === "drag") { - const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]') + const handle = drawer.locator('[data-slot="mobile-drawer-handle"]') const bounds = await handle.boundingBox() expect(bounds).not.toBeNull() await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2) diff --git a/packages/app/src/shell/mobile-drawer.css b/packages/app/src/shell/mobile-drawer.css new file mode 100644 index 00000000000..34e7577fd7d --- /dev/null +++ b/packages/app/src/shell/mobile-drawer.css @@ -0,0 +1,90 @@ +[data-slot="mobile-drawer-overlay"] { + position: fixed; + inset: 0; + z-index: 50; + background: var(--v2-overlay-simple-overlay-scrim); + animation: mobile-drawer-backdrop-in 240ms ease-out; +} + +[data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) { + animation: mobile-drawer-backdrop-out 200ms ease-in forwards; +} + +[data-slot="mobile-drawer-content"] { + box-sizing: border-box; + position: fixed; + inset-inline: 0; + bottom: 0; + z-index: 51; + display: flex; + flex-direction: column; + max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px)); + padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px)); + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); + border-radius: 16px 16px 0 0; + background: var(--v2-background-bg-deep); + color: var(--v2-text-text-base); + box-shadow: var(--v2-elevation-overlay); + outline: none; + app-region: no-drag; +} + +[data-slot="mobile-drawer-content"][data-transitioning] { + transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +[data-slot="mobile-drawer-content"][data-closing] { + transition-duration: 200ms; +} + +[data-slot="mobile-drawer-content"][data-closed] { + visibility: hidden; + pointer-events: none; +} + +[data-slot="mobile-drawer-handle"] { + display: flex; + height: 28px; + flex-shrink: 0; + align-items: center; + justify-content: center; + touch-action: none; +} + +[data-slot="mobile-drawer-handle"] span { + width: 32px; + height: 4px; + border-radius: 999px; + background: var(--v2-border-border-strong); +} + +@keyframes mobile-drawer-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes mobile-drawer-backdrop-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-slot="mobile-drawer-content"][data-transitioning], + [data-slot="mobile-drawer-content"][data-closing] { + transition: none; + } + + [data-slot="mobile-drawer-overlay"], + [data-slot="mobile-drawer-overlay"]:is([data-closing], [data-closed]) { + animation: none; + } +} diff --git a/packages/app/src/shell/mobile-drawer.tsx b/packages/app/src/shell/mobile-drawer.tsx new file mode 100644 index 00000000000..35f14f0d940 --- /dev/null +++ b/packages/app/src/shell/mobile-drawer.tsx @@ -0,0 +1,47 @@ +import Drawer from "@corvu/drawer" +import type { ParentProps } from "solid-js" +import { useLanguage } from "@/runtime/i18n/language" +import "./mobile-drawer.css" + +export function MobileDrawer( + props: ParentProps<{ + open: boolean + onOpenChange: (open: boolean) => void + onContentPresentChange?: (present: boolean) => void + returnFocus?: () => HTMLElement | undefined + closeOnOutsideFocus?: boolean + }>, +) { + return ( + + {props.children} + + ) +} + +export const MobileDrawerTrigger = Drawer.Trigger + +export function MobileDrawerContent(props: ParentProps) { + const language = useLanguage() + return ( + + + + + {props.children} + + + ) +} + +export const MobileDrawerLabel = Drawer.Label +export const MobileDrawerClose = Drawer.Close diff --git a/packages/app/src/shell/mobile-panel-drawer.css b/packages/app/src/shell/mobile-panel-drawer.css new file mode 100644 index 00000000000..53efedf627b --- /dev/null +++ b/packages/app/src/shell/mobile-panel-drawer.css @@ -0,0 +1,34 @@ +[data-slot="mobile-panel"] { + display: flex; + min-height: 0; + flex-direction: column; +} + +[data-slot="mobile-panel-header"] { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-inline-start: 8px; + padding-block-end: 8px; +} + +[data-slot="mobile-panel-header"] h2 { + margin: 0; + font-size: 14px; + font-weight: 530; + line-height: var(--line-height-base); +} + +[data-slot="mobile-panel-close"][data-component="button-v2"] { + height: 44px; + flex-shrink: 0; +} + +[data-slot="mobile-panel-content"] { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + touch-action: pan-y; +} diff --git a/packages/app/src/shell/mobile-panel-drawer.tsx b/packages/app/src/shell/mobile-panel-drawer.tsx index 1a8716cffde..fefbbaaf1da 100644 --- a/packages/app/src/shell/mobile-panel-drawer.tsx +++ b/packages/app/src/shell/mobile-panel-drawer.tsx @@ -1,7 +1,8 @@ -import Drawer from "@corvu/drawer" import type { ParentProps } from "solid-js" +import { Button } from "@opencode-ai/ui/button" import { useLanguage } from "@/runtime/i18n/language" -import "./status/status-drawer.css" +import { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer" +import "./mobile-panel-drawer.css" export function MobilePanelDrawer( props: ParentProps<{ @@ -13,32 +14,29 @@ export function MobilePanelDrawer( ) { const language = useLanguage() return ( - - {/* Preserve Corvu's content and dismissal lifecycle across reopenings. */} - - - - -
- {props.title} - + +
+
+ {props.title} + {language.t("common.close")} - +
-
- {props.children} -
- - - +
{props.children}
+
+
+ ) } diff --git a/packages/app/src/shell/status/status-drawer.css b/packages/app/src/shell/status/status-drawer.css index 82aec4b0ec4..b50ebebb940 100644 --- a/packages/app/src/shell/status/status-drawer.css +++ b/packages/app/src/shell/status/status-drawer.css @@ -1,109 +1,3 @@ -[data-slot="mobile-status-overlay"] { - position: fixed; - inset: 0; - z-index: 50; - background: var(--v2-overlay-simple-overlay-scrim); - animation: mobile-status-backdrop-in 240ms ease-out; -} - -[data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) { - animation: mobile-status-backdrop-out 200ms ease-in forwards; -} - -[data-slot="mobile-status-drawer"] { - box-sizing: border-box; - position: fixed; - inset-inline: 0; - bottom: 0; - z-index: 51; - display: flex; - flex-direction: column; - max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px)); - padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px)); - padding-left: max(12px, env(safe-area-inset-left, 0px)); - padding-right: max(12px, env(safe-area-inset-right, 0px)); - border-radius: 16px 16px 0 0; - background: var(--v2-background-bg-deep); - color: var(--v2-text-text-base); - box-shadow: var(--v2-elevation-overlay); - outline: none; - app-region: no-drag; -} - -[data-slot="mobile-status-drawer"][data-transitioning] { - transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -[data-slot="mobile-status-drawer"][data-closing] { - transition-duration: 200ms; -} - -[data-slot="mobile-status-drawer"][data-closed] { - visibility: hidden; - pointer-events: none; -} - -[data-slot="mobile-status-drag-handle"] { - display: flex; - height: 28px; - flex-shrink: 0; - align-items: center; - justify-content: center; - touch-action: none; -} - -[data-slot="mobile-status-drag-handle"] span { - width: 32px; - height: 4px; - border-radius: 999px; - background: var(--v2-border-border-strong); -} - -[data-slot="mobile-status-header"] { - display: flex; - flex-shrink: 0; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-inline-start: 8px; - padding-block-end: 8px; -} - -[data-slot="mobile-status-header"] h2 { - margin: 0; - font-size: 14px; - font-weight: 530; - line-height: var(--line-height-base); -} - -[data-slot="mobile-status-close"] { - min-height: 44px; - flex-shrink: 0; - padding-inline: 12px; - border-radius: 6px; - color: var(--v2-text-text-base); - font-size: 13px; - line-height: var(--line-height-compact); -} - -@media (hover: hover) { - [data-slot="mobile-status-close"]:hover { - background: var(--v2-overlay-simple-overlay-hover); - } -} - -[data-slot="mobile-status-close"]:focus-visible { - outline: 2px solid var(--v2-border-border-focus); - outline-offset: -2px; -} - -[data-slot="mobile-status-content"] { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; - touch-action: pan-y; -} - [data-slot="mobile-status-loading"] { display: flex; min-height: 56px; @@ -113,33 +7,3 @@ font-size: 13px; line-height: var(--line-height-base); } - -@keyframes mobile-status-backdrop-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes mobile-status-backdrop-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - [data-slot="mobile-status-drawer"][data-transitioning], - [data-slot="mobile-status-drawer"][data-closing] { - transition: none; - } - - [data-slot="mobile-status-overlay"], - [data-slot="mobile-status-overlay"]:is([data-closing], [data-closed]) { - animation: none; - } -} diff --git a/packages/app/src/shell/status/status-drawer.tsx b/packages/app/src/shell/status/status-drawer.tsx index 0630ededae7..2427e6bc268 100644 --- a/packages/app/src/shell/status/status-drawer.tsx +++ b/packages/app/src/shell/status/status-drawer.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "solid-js" import { useLanguage } from "@/runtime/i18n/language" import { MobilePanelDrawer } from "../mobile-panel-drawer" +import "./status-drawer.css" const Body = lazy(async () => { const { StatusPopoverBody } = await import("./body") diff --git a/packages/app/src/shell/titlebar/titlebar.css b/packages/app/src/shell/titlebar/titlebar.css index 4af625694b3..42e471d8c8e 100644 --- a/packages/app/src/shell/titlebar/titlebar.css +++ b/packages/app/src/shell/titlebar/titlebar.css @@ -14,63 +14,13 @@ var(--v2-background-bg-layer-02); } -[data-slot="mobile-tabs-overlay"] { - position: fixed; - inset: 0; - z-index: 50; - background: var(--v2-overlay-simple-overlay-scrim); - animation: mobile-tabs-backdrop-in 240ms ease-out; -} - -[data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) { - animation: mobile-tabs-backdrop-out 200ms ease-in forwards; -} - /* Keep the strip mounted for tab shortcuts and session metadata while collapsed. */ [data-slot="mobile-tabs-drawer"] { - box-sizing: border-box; - position: fixed; - inset-inline: 0; - bottom: 0; - z-index: 51; display: flex; + min-height: 0; flex-direction: column; gap: 8px; - max-height: min(75dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 16px)); - padding: 0 12px max(12px, env(safe-area-inset-bottom, 0px)); - border-radius: 16px 16px 0 0; - background: var(--v2-background-bg-deep); - box-shadow: var(--v2-elevation-overlay); - outline: none; -} - -[data-slot="mobile-tabs-drawer"][data-transitioning] { - transition: transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1); -} - -[data-slot="mobile-tabs-drawer"][data-closing] { - transition-duration: 200ms; -} - -[data-slot="mobile-tabs-drawer"][data-closed] { - visibility: hidden; - pointer-events: none; -} - -[data-slot="mobile-tabs-drag-handle"] { - display: flex; - height: 28px; - flex-shrink: 0; - align-items: center; - justify-content: center; - touch-action: none; -} - -[data-slot="mobile-tabs-drag-handle"] span { - width: 32px; - height: 4px; - border-radius: 999px; - background: var(--v2-border-border-strong); + margin-block-start: 8px; } [data-slot="mobile-tabs-drawer-list"] { @@ -79,36 +29,6 @@ flex-direction: column; } -@keyframes mobile-tabs-backdrop-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes mobile-tabs-backdrop-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -@media (prefers-reduced-motion: reduce) { - [data-slot="mobile-tabs-drawer"][data-transitioning], - [data-slot="mobile-tabs-drawer"][data-closing] { - transition: none; - } - - [data-slot="mobile-tabs-overlay"], - [data-slot="mobile-tabs-overlay"]:is([data-closing], [data-closed]) { - animation: none; - } -} - [data-slot="mobile-tabs-drawer"] [data-slot="vertical-tabs"] { display: flex; flex-direction: column; diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 975dde00e0a..87f95c19c40 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -25,7 +25,7 @@ import type { ComposerState } from "@/composer/persistence" import "./titlebar.css" import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind" import { TitlebarRightMount } from "@/shell/titlebar/right-slot" -import Drawer from "@corvu/drawer" +import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer" import { sessionLabel } from "@/session/title" import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar" import { projectForSession } from "@/shell/layout/helpers" @@ -415,7 +415,7 @@ export function Titlebar(props: { setMobileTabs("open", open)} onContentPresentChange={(present) => { @@ -423,11 +423,9 @@ export function Titlebar(props: { setMobileTabs("settings", false) openSettings() }} - side="bottom" > - @@ -467,15 +465,11 @@ export function Titlebar(props: { {currentTitle()} {tabsStore.length} - - - - - {language.t("titlebar.tabs")} - -
+ + + {language.t("titlebar.tabs")} +
+
-
+
- - - +
+ + } > Date: Mon, 31 Aug 2026 19:23:21 -0400 Subject: [PATCH 3/8] refactor(core): slice model references at the first slash (#46467) --- packages/core/src/model.ts | 6 +++--- packages/core/test/model.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 21bf996a1be..dec621381ac 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined { } export function parse(input: string): { providerID: Provider.ID; modelID: ID } { - const [providerID, ...modelID] = input.split("/") + const index = input.indexOf("/") return { - providerID: Provider.ID.make(providerID), - modelID: ID.make(modelID.join("/")), + providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)), + modelID: ID.make(index === -1 ? "" : input.slice(index + 1)), } } diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts index 0c545eeeefd..3844e42a0dd 100644 --- a/packages/core/test/model.test.ts +++ b/packages/core/test/model.test.ts @@ -5,6 +5,23 @@ import { Provider } from "@opencode-ai/core/provider" const decode = Schema.decodeUnknownSync(Model.Ref) +describe("Model.parse", () => { + test.each([ + ["vendor/model", "vendor", "model"], + ["vendor/team/model", "vendor", "team/model"], + ["vendor", "vendor", ""], + ["", "", ""], + ["/model", "", "model"], + ["vendor/", "vendor", ""], + ["vendor//model/", "vendor", "/model/"], + ])("parses %j at the first slash", (input, providerID, modelID) => { + expect(Model.parse(input)).toEqual({ + providerID: Provider.ID.make(providerID), + modelID: Model.ID.make(modelID), + }) + }) +}) + describe("Model.Ref", () => { test("accepts a model selection without a variant", () => { expect(decode({ id: "claude-sonnet", providerID: "anthropic" })).toEqual({ From b31defc0a5da80666c7e538caeab9eb847ae8fbb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 19:23:35 -0400 Subject: [PATCH 4/8] refactor(util): slice the final filename segment (#46466) --- packages/util/src/path.test.ts | 15 +++++++++++++++ packages/util/src/path.ts | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/util/src/path.test.ts b/packages/util/src/path.test.ts index 87dd4deb4f3..aa06f33d6b1 100644 --- a/packages/util/src/path.test.ts +++ b/packages/util/src/path.test.ts @@ -18,6 +18,21 @@ describe("client paths", () => { expect(getDirectory("")).toBe("") }) + test.each([ + ["/repo/src/index.ts///", "index.ts"], + ["C:\\repo\\src\\index.ts", "index.ts"], + ["C:\\repo/src\\file", "file"], + ["C:/repo\\src/file/\\", "file"], + ["/", ""], + ["\\", ""], + ["/\\/\\", ""], + ["C:\\", "C:"], + ["file", "file"], + ["", ""], + ])("reads the filename from %j", (path, filename) => { + expect(getFilename(path)).toBe(filename) + }) + test("keeps filename truncation stable", () => { expect(getFilenameTruncated("/repo/long-component-name.tsx", 16)).toBe("long-compon….tsx") expect(truncateMiddle("abcdefghijklmnop", 9)).toBe("abcd…mnop") diff --git a/packages/util/src/path.ts b/packages/util/src/path.ts index 59c9d6517f7..6d4f2ae1a9d 100644 --- a/packages/util/src/path.ts +++ b/packages/util/src/path.ts @@ -1,8 +1,8 @@ export function getFilename(path: string | undefined) { if (!path) return "" const trimmed = path.replace(/[/\\]+$/, "") - const parts = trimmed.split(/[/\\]/) - return parts[parts.length - 1] ?? "" + const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")) + return trimmed.slice(index + 1) } export function getDirectory(path: string | undefined) { From c0220ddd8b9a3086847f4e9c608d0f3735303d79 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 19:23:52 -0400 Subject: [PATCH 5/8] refactor(codemode): reject unresolved intersections before rendering (#46468) --- packages/codemode/src/tool-schema.ts | 2 +- packages/codemode/test/signature.test.ts | 38 ++++++++++++++---------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/codemode/src/tool-schema.ts b/packages/codemode/src/tool-schema.ts index 1acf34152ca..242e9363ba7 100644 --- a/packages/codemode/src/tool-schema.ts +++ b/packages/codemode/src/tool-schema.ts @@ -127,8 +127,8 @@ const renderSchema = ( ]) } if (schema.allOf) { - const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown" + const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen)) return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members]) } if (Array.isArray(schema.type)) { diff --git a/packages/codemode/test/signature.test.ts b/packages/codemode/test/signature.test.ts index cce387899f7..bde314c5885 100644 --- a/packages/codemode/test/signature.test.ts +++ b/packages/codemode/test/signature.test.ts @@ -344,28 +344,34 @@ describe("union schemas render every alternative", () => { expect(outputTypeScript(tool)).toBe("number | boolean") }) - test("allOf renders intersections with parenthesized union members", () => { + test("allOf keeps siblings and parenthesized union members in order", () => { const schema = { + properties: { common: { type: "boolean" } }, allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }], } as const - expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)") + expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)") + expect(jsonSchemaToTypeScript(schema, true)).toBe( + ["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"), + ) }) - test("allOf does not discard an unresolved constraint", () => { - expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe( - "unknown", - ) + test.each([false, true])("allOf does not discard an unresolved constraint (pretty=%s)", (pretty) => { + for (const $ref of ["#/$defs/Missing", "#/definitions/Missing", "https://example.com/external.json"]) { + expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref }] }, pretty)).toBe("unknown") + expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { allOf: [{ $ref }] }] }, pretty)).toBe("unknown") + expect( + jsonSchemaToTypeScript({ allOf: [{ properties: { nested: { $ref } } }, { type: "string" }] }, pretty), + ).toBe("unknown") + } expect( - jsonSchemaToTypeScript({ - allOf: [{ type: "string" }, { allOf: [{ $ref: "https://example.com/external.json" }] }], - }), - ).toBe("unknown") - expect( - jsonSchemaToTypeScript({ - type: "string", - allOf: [{ $ref: "#/$defs/Constraint" }], - $defs: { Constraint: { description: "TypeScript-neutral constraint" } }, - }), + jsonSchemaToTypeScript( + { + type: "string", + allOf: [{ $ref: "#/$defs/Constraint" }], + $defs: { Constraint: { description: "TypeScript-neutral constraint" } }, + }, + pretty, + ), ).toBe("string") }) }) From 57a9decefe68bacde032b9a5b71f224184c12a99 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 19:24:07 -0400 Subject: [PATCH 6/8] refactor(codemode): simplify input conflict detection (#46465) --- packages/codemode/src/openapi/spec.ts | 4 +--- packages/codemode/test/openapi.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/codemode/src/openapi/spec.ts b/packages/codemode/src/openapi/spec.ts index c2443d03236..750b0f31aa9 100644 --- a/packages/codemode/src/openapi/spec.ts +++ b/packages/codemode/src/openapi/spec.ts @@ -461,9 +461,7 @@ export const operationInput = ( const fields = [...parameters.value, ...requestBody.value.fields] const conflicts = new Set( - [...Map.groupBy(fields, (field) => field.name)] - .filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1) - .map(([name]) => name), + [...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name), ) const used = new Set() return { diff --git a/packages/codemode/test/openapi.test.ts b/packages/codemode/test/openapi.test.ts index 93888f670a9..ebba09f8521 100644 --- a/packages/codemode/test/openapi.test.ts +++ b/packages/codemode/test/openapi.test.ts @@ -319,7 +319,10 @@ describe("OpenAPI.fromSpec", () => { parameters: [{ name: "limit", in: "query", schema: { type: "string" } }], get: { operationId: "test", - parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }], + parameters: [ + { name: "limit", in: "query", schema: { type: "boolean" } }, + { name: "limit", in: "query", required: true, schema: { type: "number" } }, + ], responses: { 200: { description: "Success" } }, }, }, From cc0cc59700c7db27c12c4bd9b2f4f4737c848b30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 31 Aug 2026 19:24:33 -0400 Subject: [PATCH 7/8] fix(ai): preserve done-only response messages (#46064) --- packages/ai/src/protocols/open-responses.ts | 22 ++++- .../provider/open-responses-finals.test.ts | 26 +++++ .../provider/open-responses-lifecycle.test.ts | 97 ++++++++++++++++++- 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index 20ce29b6e5d..232eafb3e46 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -397,6 +397,9 @@ export interface ParserState { readonly lifecycle: Lifecycle.State readonly outputItems: Readonly> readonly message: { readonly id: string; readonly phase: MessagePhase | null | undefined } | undefined + // Item ids are response-scoped identities. Keep completed ids tombstoned so + // reconnect replay cannot reopen fragments already emitted downstream. + readonly completedMessages: ReadonlySet readonly reasoningItems: Readonly> } @@ -952,12 +955,16 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { const item = event.item if (item?.type === "message" && item.id !== undefined) { const itemID = item.id + if (state.completedMessages.has(itemID)) return [state, NO_EVENTS] const phase = messagePhase(item.phase) + const completedMessages = new Set(state.completedMessages) + if (state.message !== undefined && state.message.id !== itemID) completedMessages.add(state.message.id) // A new message closes earlier messages, including ones that never streamed. const events: LLMEvent[] = [] const lifecycle = [...state.lifecycle.text] .filter((id) => id !== itemID) .reduce((lifecycle, id) => { + completedMessages.add(id) const openPhase = state.message?.id === id ? state.message.phase : undefined return Lifecycle.textEnd( lifecycle, @@ -970,6 +977,7 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => { { ...state, lifecycle, + completedMessages, message: { id: itemID, phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase, @@ -1086,7 +1094,12 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( if (!item) return [state, NO_EVENTS] satisfies StepResult if (item.type === "message" && item.id !== undefined) { - const message = state.message?.id === item.id ? state.message : undefined + if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult + const completedMessages = new Set(state.completedMessages) + completedMessages.add(item.id) + if (state.message !== undefined && state.message.id !== item.id) + return [{ ...state, completedMessages }, NO_EVENTS] satisfies StepResult + const message = state.message const itemPhase = messagePhase(item.phase) const phase = itemPhase === undefined ? message?.phase : itemPhase const parts: ReadonlyArray = Array.isArray(item.content) ? item.content : [] @@ -1099,13 +1112,13 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* ( const text = content.length > 0 ? content.join("") : undefined const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) }) const events: LLMEvent[] = [] - const lifecycle = - message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle + const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle return [ { ...state, lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text), - message: message ? undefined : state.message, + completedMessages, + message: undefined, }, events, ] satisfies StepResult @@ -1419,6 +1432,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA lifecycle: Lifecycle.initial(), outputItems: {}, message: undefined, + completedMessages: new Set(), reasoningItems: {}, }) diff --git a/packages/ai/test/provider/open-responses-finals.test.ts b/packages/ai/test/provider/open-responses-finals.test.ts index d46a335ed7a..b8ad309c63d 100644 --- a/packages/ai/test/provider/open-responses-finals.test.ts +++ b/packages/ai/test/provider/open-responses-finals.test.ts @@ -82,6 +82,32 @@ describe("Open Responses completed item text", () => { expect(response.events.filter(LLMEvent.is.textStart)).toEqual([]) }), ) + + it.effect("assembles a done-only message once across replayed item events", () => + Effect.gen(function* () { + const item = { + type: "message", + id: "msg_1", + content: [{ type: "output_text", text: "Recovered" }], + } + const response = yield* generate( + { type: "response.output_text.delta", item_id: "msg_1", delta: "Ignored after resume" }, + { type: "response.output_item.done", item }, + { type: "response.output_item.added", item }, + { type: "response.output_item.done", item }, + completed, + ) + expect(response.text).toBe("Recovered") + expect(response.message.content).toEqual([ + { + type: "text", + text: "Recovered", + providerMetadata: { "openai-compatible": { itemId: "msg_1" } }, + }, + ]) + expect(response.events.filter(LLMEvent.is.textEnd)).toHaveLength(1) + }), + ) }) describe("Open Responses completed item reasoning", () => { diff --git a/packages/ai/test/provider/open-responses-lifecycle.test.ts b/packages/ai/test/provider/open-responses-lifecycle.test.ts index dfd3c8e4a4a..9a35b4c9a83 100644 --- a/packages/ai/test/provider/open-responses-lifecycle.test.ts +++ b/packages/ai/test/provider/open-responses-lifecycle.test.ts @@ -216,7 +216,63 @@ describe("Open Responses basic-item lifecycles", () => { ]) }), ) - it.effect("allows a message to be registered again without inheriting its previous phase", () => + + it.effect("preserves non-empty done-only message content without replaying duplicates", () => + Effect.gen(function* () { + const text = { + type: "message", + id: "msg_text", + content: [{ type: "output_text", text: "Done-only text." }], + } + const refusal = { + type: "message", + id: "msg_refusal", + content: [{ type: "refusal", refusal: "Done-only refusal." }], + } + const events = yield* collect( + { type: "response.output_item.done", item: text }, + { type: "response.output_item.done", item: text }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "" }] }, + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_empty", content: [{ type: "output_text", text: "Late" }] }, + }, + { type: "response.output_item.done", item: refusal }, + { type: "response.output_item.done", item: refusal }, + completed, + ) + + expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { + type: "text-start", + id: "msg_text", + providerMetadata: { "openai-compatible": { itemId: "msg_text" } }, + }, + { + type: "text-end", + id: "msg_text", + text: "Done-only text.", + providerMetadata: { "openai-compatible": { itemId: "msg_text" } }, + }, + { + type: "text-start", + id: "msg_refusal", + providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } }, + }, + { + type: "text-end", + id: "msg_refusal", + text: "Done-only refusal.", + providerMetadata: { "openai-compatible": { itemId: "msg_refusal" } }, + }, + ]) + }), + ) + + it.effect("treats a repeated message lifecycle as replay", () => Effect.gen(function* () { const events = yield* collect( { type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } }, @@ -233,9 +289,44 @@ describe("Open Responses basic-item lifecycles", () => { id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } }, }, - { type: "text-end", id: "msg_1", providerMetadata: { "openai-compatible": { itemId: "msg_1" } } }, ]) - expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"]) + expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First"]) + }), + ) + + it.effect("ignores a stale done-only message while another message is active", () => + Effect.gen(function* () { + const events = yield* collect( + { type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } }, + { type: "response.output_text.delta", item_id: "msg_1", delta: "Draft" }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Recovered" }] }, + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "Final" }] }, + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_2", content: [{ type: "output_text", text: "Late" }] }, + }, + completed, + ) + expect(events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { + type: "text-start", + id: "msg_1", + providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } }, + }, + { type: "text-delta", id: "msg_1", text: "Draft" }, + { + type: "text-end", + id: "msg_1", + text: "Final", + providerMetadata: { "openai-compatible": { itemId: "msg_1", phase: "commentary" } }, + }, + ]) }), ) ;[undefined, "fc_1"].forEach((id) => { From 5b2276666f10e1ca83406927d479a1a7dc33f0eb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:31:47 -0500 Subject: [PATCH 8/8] test(tui): stop Windows image preview test crashes (#46479) Co-authored-by: rekram1-node --- packages/tui/test/mini/footer.view.test.tsx | 4 +++- .../tui/test/prompt/local-attachment.test.ts | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) 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/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() }) })