Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/app/e2e/prompt/prompt-shell.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { closeSettingsPanel, openSettings, withSession } from "../actions"
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
Expand All @@ -10,6 +11,19 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part
}

function capturePageErrors(page: Page) {
const pageErrors: string[] = []
const onPageError = (err: Error & { name?: string }) => {
const detail = [err.name, err.message, err.stack, String(err)].filter(Boolean).join("\n")
pageErrors.push(detail)
}
page.on("pageerror", onPageError)
return {
pageErrors,
dispose: () => page.off("pageerror", onPageError),
}
}

test("shell mode runs a command in the project directory", async ({ page, project }) => {
test.setTimeout(120_000)

Expand Down Expand Up @@ -58,6 +72,58 @@ test("shell mode runs a command in the project directory", async ({ page, projec
})
})

test("shell mode renders command failures without crashing the renderer", async ({ page, project }) => {
test.setTimeout(120_000)

const errors = capturePageErrors(page)
const COMMAND = "definitely-not-a-command-171 --help"
const COMMAND_FAILURE_PATTERN = /command not found|is not recognized/i

try {
await project.open()

await withSession(project.sdk, `e2e shell failure ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
await project.shell(COMMAND)

await page.locator('[data-component="tool-trigger"]').last().click()
await expect(page.locator('[data-component="bash-output"]').last()).toContainText(COMMAND)
await expect(page.locator('[data-component="bash-output"]').last()).toContainText(COMMAND_FAILURE_PATTERN)

await expect
.poll(
async () => {
const list = await project.sdk.session
.messages({ sessionID: session.id, limit: 50 })
.then((x) => x.data ?? [])
const msg = list.findLast(
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
)
if (!msg) return

const part = msg.parts
.filter(isBash)
.find((item) => item.state.input?.command === COMMAND && item.state.status === "completed")
if (!part || part.state.status !== "completed") return

const output =
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
if (!COMMAND_FAILURE_PATTERN.test(output)) return

return { command: part.state.input.command, output }
},
{ timeout: 90_000 },
)
.toEqual(expect.objectContaining({ command: COMMAND, output: expect.stringMatching(COMMAND_FAILURE_PATTERN) }))
})

expect(errors.pageErrors.join("\n")).not.toContain("switchFunc(...) is not a function")
} finally {
errors.dispose()
}
})

test("shell mode unmounts model and variant controls", async ({ page, project }) => {
await project.open()

Expand Down
14 changes: 14 additions & 0 deletions packages/ui/src/components/basic-tool-trigger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect, test } from "bun:test"
import { readFileSync } from "node:fs"

test("BasicTool trigger rendering avoids Solid Switch for dynamic JSX triggers", () => {
const source = readFileSync(new URL("./basic-tool.tsx", import.meta.url), "utf8")
const triggerInfoStart = source.indexOf("const triggerInfo = () =>")
const triggerStart = source.indexOf("const trigger = () =>", triggerInfoStart)
expect(triggerInfoStart).toBeGreaterThanOrEqual(0)
expect(triggerStart).toBeGreaterThan(triggerInfoStart)
const triggerSource = source.slice(triggerInfoStart, triggerStart)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(triggerSource).not.toMatch(/<\s*Switch\b/)
expect(triggerSource).not.toMatch(/<\s*Match\b[^>]*\bwhen=\{\s*isTriggerTitle\(props\.trigger\)/)
})
116 changes: 58 additions & 58 deletions packages/ui/src/components/basic-tool.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createEffect, For, on, onCleanup, Show, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { useI18n } from "../context/i18n"
import { createStore } from "solid-js/store"
Expand Down Expand Up @@ -124,70 +124,70 @@ export function BasicTool(props: BasicToolProps) {
setState("open", value)
}

const triggerInfo = () => {
const title = isTriggerTitle(props.trigger) ? props.trigger : undefined
if (!title) return props.trigger as JSX.Element

return (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[title.titleClass ?? ""]: !!title.titleClass,
}}
>
<TextShimmer text={title.title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={title.subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[title.subtitleClass ?? ""]: !!title.subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{title.subtitle}
</span>
</Show>
<Show when={title.args?.length}>
<For each={title.args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[title.argsClass ?? ""]: !!title.argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && title.action}>
<span data-slot="basic-tool-tool-action">{title.action}</span>
</Show>
</div>
)
}

const trigger = () => (
<div
data-component="tool-trigger"
data-clickable={props.clickable ? "true" : undefined}
data-hide-details={props.hideDetails ? "true" : undefined}
>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(title) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[title().titleClass ?? ""]: !!title().titleClass,
}}
>
<TextShimmer text={title().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={title().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[title().subtitleClass ?? ""]: !!title().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{title().subtitle}
</span>
</Show>
<Show when={title().args?.length}>
<For each={title().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[title().argsClass ?? ""]: !!title().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && title().action}>
<span data-slot="basic-tool-tool-action">{title().action}</span>
</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
<div data-slot="basic-tool-tool-info">{triggerInfo()}</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
Expand Down
Loading