mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-29 03:44:44 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fab33d59b | |||
| 8b1091c35a |
@@ -397,9 +397,6 @@ export interface ParserState {
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
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<string>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
}
|
||||
|
||||
@@ -955,16 +952,12 @@ 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,
|
||||
@@ -977,7 +970,6 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
completedMessages,
|
||||
message: {
|
||||
id: itemID,
|
||||
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
|
||||
@@ -1094,12 +1086,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id !== 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 message = state.message?.id === item.id ? state.message : undefined
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? message?.phase : itemPhase
|
||||
const parts: ReadonlyArray<unknown> = Array.isArray(item.content) ? item.content : []
|
||||
@@ -1112,13 +1099,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 = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
const lifecycle =
|
||||
message && text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(lifecycle, events, item.id, metadata, text),
|
||||
completedMessages,
|
||||
message: undefined,
|
||||
message: message ? undefined : state.message,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
@@ -1432,7 +1419,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
completedMessages: new Set<string>(),
|
||||
reasoningItems: {},
|
||||
})
|
||||
|
||||
|
||||
@@ -82,32 +82,6 @@ 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", () => {
|
||||
|
||||
@@ -216,63 +216,7 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
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", () =>
|
||||
it.effect("allows a message to be registered again without inheriting its previous phase", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* collect(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1", phase: "commentary" } },
|
||||
@@ -289,44 +233,9 @@ 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"])
|
||||
}),
|
||||
)
|
||||
|
||||
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" } },
|
||||
},
|
||||
])
|
||||
expect(events.filter(LLMEvent.is.textDelta).map((event) => event.text)).toEqual(["First", "Second"])
|
||||
}),
|
||||
)
|
||||
;[undefined, "fc_1"].forEach((id) => {
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
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}`)
|
||||
|
||||
@@ -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-drawer-overlay"]')
|
||||
const overlay = page.locator('[data-slot="mobile-status-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-drawer-handle"]')
|
||||
const handle = drawer.locator('[data-slot="mobile-status-drag-handle"]')
|
||||
const bounds = await handle.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2)
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
[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,8 +114,10 @@ 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 bg-v2-background-bg-base"
|
||||
class="group/composer relative min-h-[96px] w-full overflow-clip rounded-xl"
|
||||
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",
|
||||
}}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
[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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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 (
|
||||
<Drawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
onContentPresentChange={props.onContentPresentChange}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
closeOnOutsideFocus={props.closeOnOutsideFocus}
|
||||
>
|
||||
{props.children}
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileDrawerTrigger = Drawer.Trigger
|
||||
|
||||
export function MobileDrawerContent(props: ParentProps) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-drawer-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-drawer-content" dir={language.direction()}>
|
||||
<div data-slot="mobile-drawer-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
{props.children}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export const MobileDrawerLabel = Drawer.Label
|
||||
export const MobileDrawerClose = Drawer.Close
|
||||
@@ -1,34 +0,0 @@
|
||||
[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;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
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 { MobileDrawer, MobileDrawerClose, MobileDrawerContent, MobileDrawerLabel } from "./mobile-drawer"
|
||||
import "./mobile-panel-drawer.css"
|
||||
import "./status/status-drawer.css"
|
||||
|
||||
export function MobilePanelDrawer(
|
||||
props: ParentProps<{
|
||||
@@ -14,29 +13,32 @@ export function MobilePanelDrawer(
|
||||
) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<MobileDrawer
|
||||
<Drawer
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
returnFocus={props.returnFocus}
|
||||
side="bottom"
|
||||
finalFocusEl={props.returnFocus?.()}
|
||||
// Menu focus handoff must not dismiss the drawer during its opening transition.
|
||||
closeOnOutsideFocus={false}
|
||||
>
|
||||
<MobileDrawerContent>
|
||||
<div data-slot="mobile-panel" data-corvu-no-drag>
|
||||
<div data-slot="mobile-panel-header">
|
||||
<MobileDrawerLabel>{props.title}</MobileDrawerLabel>
|
||||
<MobileDrawerClose
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
data-slot="mobile-panel-close"
|
||||
aria-label={language.t("common.close")}
|
||||
>
|
||||
{language.t("common.close")}
|
||||
</MobileDrawerClose>
|
||||
{/* Preserve Corvu's content and dismissal lifecycle across reopenings. */}
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-status-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-status-drawer" dir={language.direction()}>
|
||||
<div data-slot="mobile-status-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-panel-content">{props.children}</div>
|
||||
</div>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
<div data-slot="mobile-status-header" data-corvu-no-drag>
|
||||
<Drawer.Label>{props.title}</Drawer.Label>
|
||||
<Drawer.Close data-slot="mobile-status-close" aria-label={language.t("common.close")}>
|
||||
{language.t("common.close")}
|
||||
</Drawer.Close>
|
||||
</div>
|
||||
<div data-slot="mobile-status-content" data-corvu-no-drag>
|
||||
{props.children}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,109 @@
|
||||
[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;
|
||||
@@ -7,3 +113,33 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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")
|
||||
|
||||
@@ -14,13 +14,63 @@
|
||||
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;
|
||||
margin-block-start: 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);
|
||||
}
|
||||
|
||||
[data-slot="mobile-tabs-drawer-list"] {
|
||||
@@ -29,6 +79,36 @@
|
||||
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;
|
||||
|
||||
@@ -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 { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
|
||||
import Drawer from "@corvu/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: {
|
||||
<Show
|
||||
when={!mobile()}
|
||||
fallback={
|
||||
<MobileDrawer
|
||||
<Drawer
|
||||
open={mobileTabs.open}
|
||||
onOpenChange={(open) => setMobileTabs("open", open)}
|
||||
onContentPresentChange={(present) => {
|
||||
@@ -423,9 +423,11 @@ export function Titlebar(props: {
|
||||
setMobileTabs("settings", false)
|
||||
openSettings()
|
||||
}}
|
||||
side="bottom"
|
||||
>
|
||||
<MobileDrawerTrigger
|
||||
<Drawer.Trigger
|
||||
data-slot="mobile-tabs-trigger"
|
||||
aria-expanded={mobileTabs.open}
|
||||
class="flex h-7 min-w-0 flex-1 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base focus-visible:outline-none [app-region:no-drag]"
|
||||
aria-label={language.t("titlebar.tabs")}
|
||||
>
|
||||
@@ -465,11 +467,15 @@ export function Titlebar(props: {
|
||||
{currentTitle()}
|
||||
</span>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>
|
||||
</MobileDrawerTrigger>
|
||||
<MobileDrawerContent>
|
||||
<MobileDrawerLabel class="sr-only">{language.t("titlebar.tabs")}</MobileDrawerLabel>
|
||||
<div data-slot="mobile-tabs-drawer" data-corvu-no-drag>
|
||||
<div data-slot="mobile-tabs-drawer-list">
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Portal forceMount>
|
||||
<Drawer.Overlay data-slot="mobile-tabs-overlay" />
|
||||
<Drawer.Content forceMount data-slot="mobile-tabs-drawer" dir={language.direction()}>
|
||||
<Drawer.Label class="sr-only">{language.t("titlebar.tabs")}</Drawer.Label>
|
||||
<div data-slot="mobile-tabs-drag-handle" aria-hidden="true">
|
||||
<span />
|
||||
</div>
|
||||
<div data-slot="mobile-tabs-drawer-list" data-corvu-no-drag>
|
||||
<TitlebarTabStrip
|
||||
orientation="vertical"
|
||||
tabs={tabsStore}
|
||||
@@ -487,6 +493,7 @@ export function Titlebar(props: {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-corvu-no-drag
|
||||
data-action="mobile-tabs-new-session"
|
||||
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-[6px] px-2 text-[13px] leading-4 text-v2-text-text-base hover:bg-v2-background-bg-layer-02 focus-visible:outline-none focus-visible:bg-v2-background-bg-layer-02"
|
||||
onClick={() => {
|
||||
@@ -497,7 +504,10 @@ export function Titlebar(props: {
|
||||
<Icon name="plus" />
|
||||
{language.t("command.session.new")}
|
||||
</button>
|
||||
<div class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2">
|
||||
<div
|
||||
class="flex shrink-0 flex-col gap-1 border-t border-v2-border-border-muted pt-2"
|
||||
data-corvu-no-drag
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-action="mobile-tabs-home"
|
||||
@@ -536,9 +546,9 @@ export function Titlebar(props: {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MobileDrawerContent>
|
||||
</MobileDrawer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
|
||||
@@ -153,7 +153,12 @@ export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
|
||||
export type SessionInboxSyntheticPayload = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||
export type SessionInboxSyntheticPayload = {
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
export type SessionInboxCompactionPayload = {}
|
||||
|
||||
@@ -161,7 +166,12 @@ export type InstructionEntryKey = string
|
||||
|
||||
export type SessionGenerateResponse = { data: { text: string } }
|
||||
|
||||
export type SessionInboxSyntheticPayload1 = { text: string; description?: string; metadata?: { [x: string]: any } }
|
||||
export type SessionInboxSyntheticPayload1 = {
|
||||
text: string
|
||||
description?: string
|
||||
metadata?: { [x: string]: any }
|
||||
resume?: boolean
|
||||
}
|
||||
|
||||
export type ShellInfo = {
|
||||
id: string
|
||||
|
||||
@@ -53,6 +53,7 @@ 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(),
|
||||
@@ -98,7 +99,6 @@ 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(
|
||||
|
||||
@@ -461,7 +461,9 @@ export const operationInput = (
|
||||
const fields = [...parameters.value, ...requestBody.value.fields]
|
||||
|
||||
const conflicts = new Set(
|
||||
[...Map.groupBy(fields, (field) => field.name)].filter(([, matches]) => matches.length > 1).map(([name]) => name),
|
||||
[...Map.groupBy(fields, (field) => field.name)]
|
||||
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
|
||||
.map(([name]) => name),
|
||||
)
|
||||
const used = new Set<string>()
|
||||
return {
|
||||
|
||||
@@ -127,8 +127,8 @@ const renderSchema = (
|
||||
])
|
||||
}
|
||||
if (schema.allOf) {
|
||||
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
|
||||
const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
|
||||
if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
|
||||
return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
|
||||
}
|
||||
if (Array.isArray(schema.type)) {
|
||||
|
||||
@@ -282,30 +282,6 @@ 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({
|
||||
@@ -343,10 +319,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
parameters: [{ name: "limit", in: "query", schema: { type: "string" } }],
|
||||
get: {
|
||||
operationId: "test",
|
||||
parameters: [
|
||||
{ name: "limit", in: "query", schema: { type: "boolean" } },
|
||||
{ name: "limit", in: "query", required: true, schema: { type: "number" } },
|
||||
],
|
||||
parameters: [{ name: "limit", in: "query", required: true, schema: { type: "number" } }],
|
||||
responses: { 200: { description: "Success" } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -344,34 +344,28 @@ describe("union schemas render every alternative", () => {
|
||||
expect(outputTypeScript(tool)).toBe("number | boolean")
|
||||
})
|
||||
|
||||
test("allOf keeps siblings and parenthesized union members in order", () => {
|
||||
test("allOf renders intersections with parenthesized union members", () => {
|
||||
const schema = {
|
||||
properties: { common: { type: "boolean" } },
|
||||
allOf: [{ type: "object", properties: { id: { type: "string" } } }, { type: ["string", "null"] }],
|
||||
} as const
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("{ common?: boolean } & { id?: string } & (string | null)")
|
||||
expect(jsonSchemaToTypeScript(schema, true)).toBe(
|
||||
["{", " common?: boolean,", " } & {", " id?: string,", " } & (string | null)"].join("\n"),
|
||||
)
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("{ id?: string } & (string | null)")
|
||||
})
|
||||
|
||||
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")
|
||||
}
|
||||
test("allOf does not discard an unresolved constraint", () => {
|
||||
expect(jsonSchemaToTypeScript({ allOf: [{ type: "string" }, { $ref: "https://example.com/external.json" }] })).toBe(
|
||||
"unknown",
|
||||
)
|
||||
expect(
|
||||
jsonSchemaToTypeScript(
|
||||
{
|
||||
type: "string",
|
||||
allOf: [{ $ref: "#/$defs/Constraint" }],
|
||||
$defs: { Constraint: { description: "TypeScript-neutral constraint" } },
|
||||
},
|
||||
pretty,
|
||||
),
|
||||
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" } },
|
||||
}),
|
||||
).toBe("string")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,10 +38,10 @@ export function compatibility(input: unknown): Compatibility | undefined {
|
||||
}
|
||||
|
||||
export function parse(input: string): { providerID: Provider.ID; modelID: ID } {
|
||||
const index = input.indexOf("/")
|
||||
const [providerID, ...modelID] = input.split("/")
|
||||
return {
|
||||
providerID: Provider.ID.make(index === -1 ? input : input.slice(0, index)),
|
||||
modelID: ID.make(index === -1 ? "" : input.slice(index + 1)),
|
||||
providerID: Provider.ID.make(providerID),
|
||||
modelID: ID.make(modelID.join("/")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -159,7 +159,8 @@ export const layer = Layer.effect(
|
||||
// promotable here or was fully delivered and needs no resumption.
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (next === undefined) return interrupted
|
||||
if (next.delivery === "steer" || SessionInbox.isControl(next)) yield* coordinator.wake(sessionID, "steer")
|
||||
if (next.delivery === "steer" || next.type === "compaction" || next.type === "move")
|
||||
yield* coordinator.wake(sessionID, "steer")
|
||||
return interrupted
|
||||
}),
|
||||
resume: coordinator.run,
|
||||
|
||||
@@ -47,9 +47,6 @@ export {
|
||||
*/
|
||||
export type Promotable = "input" | "steer"
|
||||
|
||||
export const isControl = (item: Pick<Item, "type">): item is Pick<Compaction | Move, "type"> =>
|
||||
item.type === "compaction" || item.type === "move"
|
||||
|
||||
const decodeUser = Schema.decodeUnknownSync(UserPayload)
|
||||
const encodeUser = Schema.encodeSync(UserPayload)
|
||||
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticPayload)
|
||||
@@ -508,7 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
Effect.gen(function* () {
|
||||
const steers = yield* pendingSteers(db, sessionID)
|
||||
if (steers.length > 0 || scope === "steer") {
|
||||
const control = steers.findIndex(isControl)
|
||||
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
|
||||
}
|
||||
|
||||
@@ -520,10 +517,10 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!queued || isControl(queued)) return 0
|
||||
if (!queued) return 0
|
||||
const promoted = yield* publish(db, bus, sessionID, [queued])
|
||||
const arrivedSteers = yield* pendingSteers(db, sessionID)
|
||||
const control = arrivedSteers.findIndex(isControl)
|
||||
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
|
||||
return (
|
||||
promoted +
|
||||
(yield* publish(db, bus, sessionID, control === -1 ? arrivedSteers : arrivedSteers.slice(0, control)))
|
||||
|
||||
@@ -594,7 +594,7 @@ const layer = Layer.effectDiscard(
|
||||
id: event.data.inboxID,
|
||||
sessionID: event.data.sessionID,
|
||||
})
|
||||
if (SessionInbox.isControl(input)) return
|
||||
if (input.type === "compaction" || input.type === "move") return
|
||||
yield* insertMessage(
|
||||
db,
|
||||
event,
|
||||
|
||||
@@ -54,8 +54,8 @@ const layer = Layer.effect(
|
||||
if (!force && !continuing) {
|
||||
const pending = yield* SessionInbox.nextPromotable(db, sessionID, "input")
|
||||
if (!pending) return DrainResult.Complete()
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !SessionInbox.isControl(pending))
|
||||
return DrainResult.Complete()
|
||||
const control = pending.type === "compaction" || pending.type === "move"
|
||||
if (promotable === "steer" && pending.delivery === "queue" && !control) return DrainResult.Complete()
|
||||
}
|
||||
yield* plugins.flush
|
||||
yield* settleStaleToolCalls(sessionID)
|
||||
@@ -96,7 +96,7 @@ const layer = Layer.effect(
|
||||
step = 1
|
||||
}
|
||||
if (pending?.type === "move")
|
||||
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
|
||||
return DrainResult.Moved({ continuation: !entering && 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}`))
|
||||
@@ -128,16 +128,15 @@ const layer = Layer.effect(
|
||||
}
|
||||
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
|
||||
return DrainResult.Complete()
|
||||
const ready = yield* restore(
|
||||
return yield* restore(
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* prepareContext(sessionID)
|
||||
const scope = entering && !continuing ? promotable : "steer"
|
||||
const promoted = yield* SessionInbox.promote(db, bus, sessionID, scope)
|
||||
if (promoted === 0) {
|
||||
// Cancellation during preparation can expose a control instead of input.
|
||||
const next = yield* SessionInbox.nextPromotable(db, sessionID, scope)
|
||||
if (next && SessionInbox.isControl(next)) return undefined
|
||||
}
|
||||
const promoted = yield* SessionInbox.promote(
|
||||
db,
|
||||
bus,
|
||||
sessionID,
|
||||
entering && !continuing ? promotable : "steer",
|
||||
)
|
||||
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
|
||||
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
|
||||
onlyIfMissing: true,
|
||||
@@ -146,7 +145,6 @@ const layer = Layer.effect(
|
||||
return { _tag: "Ready" as const, context: yield* context.load(selected) }
|
||||
}),
|
||||
)
|
||||
if (ready) return ready
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -316,6 +316,7 @@ export const make = Effect.fn("Session.make")(function* () {
|
||||
text: input.text,
|
||||
description: input.description,
|
||||
metadata: input.metadata,
|
||||
resume: input.resume === false ? false : undefined,
|
||||
}),
|
||||
delivery: SessionInbox.Delivery.make(input.delivery ?? "steer"),
|
||||
} satisfies SessionInbox.Item
|
||||
|
||||
@@ -147,20 +147,6 @@ 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* () {
|
||||
@@ -213,30 +199,6 @@ 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",
|
||||
|
||||
@@ -5,23 +5,6 @@ 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({
|
||||
|
||||
@@ -1112,6 +1112,7 @@ describe("Session.prompt", () => {
|
||||
text: "Background work completed",
|
||||
description: "shell completion",
|
||||
metadata: { job: "shell" },
|
||||
resume: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1422,67 +1422,6 @@ describe("SessionRunnerLLM", () => {
|
||||
})
|
||||
})
|
||||
|
||||
scenario("dispatches a queued move exposed by cancellation during preparation", function* (s) {
|
||||
const runner = yield* SessionRunner.Service
|
||||
const prompt = yield* s.session.prompt({ sessionID, text: "Cancel me", delivery: "queue", resume: false })
|
||||
const location = Location.Ref.make({ directory: AbsolutePath.make("/moved") })
|
||||
yield* s.sessionInbox.admit({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
item: {
|
||||
type: "move",
|
||||
payload: { location, projectID: Project.ID.global },
|
||||
delivery: "queue",
|
||||
},
|
||||
})
|
||||
s.systemLoadHook = Effect.gen(function* () {
|
||||
s.systemLoadHook = Effect.void
|
||||
yield* s.session.cancelInbox({ sessionID, inboxID: prompt.id }).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Moved({}))
|
||||
expect((yield* s.session.get(sessionID)).location).toEqual(location)
|
||||
expect(s.closedTransports).toEqual([sessionID])
|
||||
expect(s.requests).toHaveLength(0)
|
||||
expect(yield* s.messages).toMatchObject([{ type: "location-switched", location }])
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect((yield* recordedEventTypes(sessionID)).slice(-2)).toEqual([
|
||||
Bus.versionedType(SessionEvent.InboxDelivered.type, 1),
|
||||
Bus.versionedType(SessionEvent.Moved.type, 1),
|
||||
])
|
||||
})
|
||||
|
||||
scenario("dispatches a queued compaction exposed by cancellation during preparation", function* (s) {
|
||||
const runner = yield* SessionRunner.Service
|
||||
const prompt = yield* s.session.prompt({ sessionID, text: "Cancel me", delivery: "queue", resume: false })
|
||||
const compaction = yield* s.sessionInbox.admitCompaction({
|
||||
id: SessionMessage.ID.create(),
|
||||
sessionID,
|
||||
delivery: "queue",
|
||||
})
|
||||
s.systemLoadHook = Effect.gen(function* () {
|
||||
s.systemLoadHook = Effect.void
|
||||
yield* s.session.cancelInbox({ sessionID, inboxID: prompt.id }).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
expect(yield* runner.drain({ sessionID, force: false })).toEqual(SessionRunner.DrainResult.Complete())
|
||||
expect(s.requests).toHaveLength(0)
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
expect(yield* s.messages).toMatchObject([
|
||||
{
|
||||
id: compaction.id,
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
},
|
||||
])
|
||||
expect((yield* recordedEventTypes(sessionID)).slice(-3)).toEqual([
|
||||
Bus.versionedType(SessionEvent.InboxDelivered.type, 1),
|
||||
Bus.versionedType(SessionEvent.Compaction.Started.type, 1),
|
||||
Bus.versionedType(SessionEvent.Compaction.Failed.type, 1),
|
||||
])
|
||||
})
|
||||
|
||||
scenario("delivers a queued move atomically at the idle boundary", function* (s) {
|
||||
const inboxID = SessionMessage.ID.create()
|
||||
yield* s.sessionInbox.admit({
|
||||
@@ -1515,49 +1454,32 @@ describe("SessionRunnerLLM", () => {
|
||||
).toEqual([Bus.versionedType(SessionEvent.Moved.type, 1), Bus.versionedType(SessionEvent.InboxDelivered.type, 1)])
|
||||
})
|
||||
|
||||
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([])
|
||||
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",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -22,6 +22,7 @@ export const SyntheticPayload = Schema.Struct({
|
||||
text: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
|
||||
resume: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Inbox.SyntheticPayload" })
|
||||
|
||||
export interface CompactionPayload extends Schema.Schema.Type<typeof CompactionPayload> {}
|
||||
|
||||
@@ -53,8 +53,13 @@ describe("contract hygiene", () => {
|
||||
text: "completed",
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
resume: undefined,
|
||||
}),
|
||||
).toEqual({ text: "completed" })
|
||||
expect(Schema.encodeSync(SessionInbox.SyntheticPayload)({ text: "parked", resume: false })).toEqual({
|
||||
text: "parked",
|
||||
resume: false,
|
||||
})
|
||||
|
||||
const info = Session.Info.make({
|
||||
id: Session.ID.make("ses_untitled"),
|
||||
|
||||
@@ -172,7 +172,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
|
||||
: members.some((id) => (data.session.form.list(id)?.length ?? 0) > 0)
|
||||
? ("question" as const)
|
||||
: (false as const),
|
||||
busy: members.some((id) => data.session.status(id) === "running" || data.session.pending.list(id).length > 0),
|
||||
busy: members.some(
|
||||
(id) =>
|
||||
data.session.status(id) === "running" ||
|
||||
data.session.pending.list(id).some((item) => item.type !== "synthetic" || item.payload.resume !== false),
|
||||
),
|
||||
renaming: data.session.title.pending(session),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +932,110 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test("parked synthetic context does not keep a tab busy", async () => {
|
||||
const setup = await renderSessionTabs("shell")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "shell")
|
||||
setup.emit({
|
||||
id: "evt_shell_complete",
|
||||
created: Date.now(),
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "shell", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "shell",
|
||||
inboxID: "msg_shell_complete",
|
||||
item: {
|
||||
type: "synthetic",
|
||||
payload: {
|
||||
text: "The following shell command was executed by the user",
|
||||
metadata: { source: "shell", state: "completed" },
|
||||
resume: false,
|
||||
},
|
||||
delivery: "steer",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await wait(() => setup.data.session.pending.list("shell").length === 1)
|
||||
expect(setup.tabs.status("shell").busy).toBe(false)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_execution_started",
|
||||
created: Date.now(),
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: "shell", seq: 2, version: 1 },
|
||||
data: { sessionID: "shell" },
|
||||
})
|
||||
await wait(() => setup.tabs.status("shell").busy)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_execution_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "shell", seq: 3, version: 1 },
|
||||
data: { sessionID: "shell" },
|
||||
})
|
||||
await wait(() => !setup.tabs.status("shell").busy)
|
||||
|
||||
setup.emit(admitted("shell", "msg_4"))
|
||||
await wait(() => setup.tabs.status("shell").busy)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("resumable synthetic context keeps a tab busy until execution settles", async () => {
|
||||
const setup = await renderSessionTabs("background")
|
||||
|
||||
try {
|
||||
await wait(() => setup.tabs.current() === "background")
|
||||
setup.emit({
|
||||
id: "evt_completion",
|
||||
created: Date.now(),
|
||||
type: "session.inbox.enqueued",
|
||||
durable: { aggregateID: "background", seq: 1, version: 1 },
|
||||
data: {
|
||||
sessionID: "background",
|
||||
inboxID: "msg_completion",
|
||||
item: {
|
||||
type: "synthetic",
|
||||
payload: { text: "Background subagent completed" },
|
||||
delivery: "steer",
|
||||
},
|
||||
},
|
||||
})
|
||||
await wait(() => setup.tabs.status("background").busy)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_background_started",
|
||||
created: Date.now(),
|
||||
type: "session.execution.started",
|
||||
durable: { aggregateID: "background", seq: 2, version: 1 },
|
||||
data: { sessionID: "background" },
|
||||
})
|
||||
setup.emit({
|
||||
id: "evt_completion_delivered",
|
||||
created: Date.now(),
|
||||
type: "session.inbox.delivered",
|
||||
durable: { aggregateID: "background", seq: 3, version: 1 },
|
||||
data: { sessionID: "background", inboxID: "msg_completion" },
|
||||
})
|
||||
await wait(() => setup.data.session.pending.list("background").length === 0 && setup.tabs.status("background").busy)
|
||||
|
||||
setup.emit({
|
||||
id: "evt_background_succeeded",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "background", seq: 4, version: 1 },
|
||||
data: { sessionID: "background" },
|
||||
})
|
||||
await wait(() => !setup.tabs.status("background").busy)
|
||||
} finally {
|
||||
await setup.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("tracks a temporary new session tab across close and creation", async () => {
|
||||
const setup = await renderSessionTabs("first")
|
||||
|
||||
|
||||
@@ -239,9 +239,7 @@ async function renderFooter(
|
||||
}
|
||||
}
|
||||
|
||||
// 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([
|
||||
test.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 },
|
||||
|
||||
@@ -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, process.platform)).toEqual([
|
||||
expect(await resolvePastedAttachments(input, "linux")).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, process.platform)).toEqual([
|
||||
expect(await resolvePastedAttachments(input, "linux")).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, process.platform)).toBeUndefined()
|
||||
expect(await resolvePastedAttachments(input, "linux")).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -143,9 +143,7 @@ describe("prompt local attachments", () => {
|
||||
const content = "<svg />\r\n"
|
||||
await Bun.write(file, content)
|
||||
|
||||
expect(await resolvePastedAttachments(file, process.platform)).toEqual([
|
||||
{ type: "text", content, filename: "image.svg" },
|
||||
])
|
||||
expect(await resolvePastedAttachments(file, "linux")).toEqual([{ type: "text", content, filename: "image.svg" }])
|
||||
})
|
||||
|
||||
test("shares the byte budget across binary and SVG attachments", async () => {
|
||||
@@ -158,15 +156,15 @@ describe("prompt local attachments", () => {
|
||||
Bun.write(svg, content),
|
||||
])
|
||||
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toMatchObject([
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toMatchObject([
|
||||
{ type: "file", filename: "image.png" },
|
||||
{ type: "text", content, filename: "image.svg" },
|
||||
])
|
||||
await Bun.write(svg, content + " ")
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, process.platform)).toBeUndefined()
|
||||
expect(await resolvePastedAttachments(`${image} ${svg}`, "linux")).toBeUndefined()
|
||||
|
||||
await Bun.write(image, new Uint8Array(MAX_LOCAL_ATTACHMENT_BYTES + 1))
|
||||
expect(await resolvePastedAttachments(image, process.platform)).toBeUndefined()
|
||||
expect(await resolvePastedAttachments(image, "linux")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("bounds the number of resolved paths", async () => {
|
||||
@@ -174,7 +172,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(" "), process.platform)).toHaveLength(32)
|
||||
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), process.platform)).toBeUndefined()
|
||||
expect(await resolvePastedAttachments(Array(32).fill(file).join(" "), "linux")).toHaveLength(32)
|
||||
expect(await resolvePastedAttachments(Array(33).fill(file).join(" "), "linux")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,13 +240,10 @@ 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: () => buffer,
|
||||
evaluate: () => readable,
|
||||
onError: (cause) => toPlatformError(`fromReadable(${name})`, toError(cause), command),
|
||||
closeOnDone: false,
|
||||
}).pipe(
|
||||
Stream.interruptWhen(Deferred.await(stopOutput)),
|
||||
Stream.ensuring(
|
||||
@@ -321,8 +318,7 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
|
||||
|
||||
const discard = (readable: NodeChildProcess.ChildProcess["stdout"]) => {
|
||||
if (!readable || readable.destroyed) return
|
||||
readable.unpipe()
|
||||
// Discard descendant output without refilling a capture buffer that is no longer consumed.
|
||||
// read() also drains while a backpressured Effect adapter still has a readable listener.
|
||||
const drain = () => {
|
||||
while (readable.read() !== null) {}
|
||||
}
|
||||
|
||||
@@ -18,21 +18,6 @@ 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")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export function getFilename(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const trimmed = path.replace(/[/\\]+$/, "")
|
||||
const index = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"))
|
||||
return trimmed.slice(index + 1)
|
||||
const parts = trimmed.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? ""
|
||||
}
|
||||
|
||||
export function getDirectory(path: string | undefined) {
|
||||
|
||||
Reference in New Issue
Block a user