chore: merge v2 into service channel config
This commit is contained in:
commit
e76b29c0b4
1174 changed files with 21121 additions and 336917 deletions
|
|
@ -121,7 +121,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
|||
await task
|
||||
|
||||
expect(stdout).toContain("Renamed session")
|
||||
expect(stdout).toContain("opencode -s dummy")
|
||||
expect(stdout).toContain("opencode2 -s dummy")
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
|
|
|
|||
|
|
@ -272,3 +272,27 @@ test("selects a repopulated option after removing the only option", async () =>
|
|||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the cursor index while options are temporarily empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const options = ["first", "second", "third"].map((value) => ({ title: value, value }))
|
||||
const select = await mountSelect(tmp.path, options)
|
||||
|
||||
try {
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "second")
|
||||
select.app.mockInput.pressArrow("down")
|
||||
await select.app.waitFor(() => select.moved.at(-1) === "third")
|
||||
select.replaceOptions([])
|
||||
await select.app.waitForFrame((frame) => frame.includes("No items available"))
|
||||
|
||||
select.replaceOptions(options)
|
||||
await select.app.waitForFrame((frame) => frame.includes("third"))
|
||||
select.app.mockInput.pressEnter()
|
||||
await select.app.waitFor(() => select.selected.length === 1)
|
||||
|
||||
expect(select.selected).toEqual(["third"])
|
||||
} finally {
|
||||
select.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { onMount, type ParentProps } from "solid-js"
|
||||
|
|
@ -14,18 +13,6 @@ import {
|
|||
buildFileTree,
|
||||
} from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
const theme = {
|
||||
background: RGBA.fromHex("#000000"),
|
||||
backgroundPanel: RGBA.fromHex("#111111"),
|
||||
backgroundElement: RGBA.fromHex("#333333"),
|
||||
primary: RGBA.fromHex("#00ffff"),
|
||||
secondary: RGBA.fromHex("#0088ff"),
|
||||
selectedListItemText: RGBA.fromHex("#ffffff"),
|
||||
text: RGBA.fromHex("#ffffff"),
|
||||
textMuted: RGBA.fromHex("#888888"),
|
||||
error: RGBA.fromHex("#ff0000"),
|
||||
}
|
||||
|
||||
describe("DiffViewerFileTree", () => {
|
||||
test.skip("renders sorted hierarchical file rows", async () => {
|
||||
const lines = visibleLines(
|
||||
|
|
@ -41,7 +28,6 @@ describe("DiffViewerFileTree", () => {
|
|||
]}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused={true}
|
||||
/>
|
||||
)),
|
||||
|
|
@ -59,13 +45,13 @@ describe("DiffViewerFileTree", () => {
|
|||
|
||||
test("keeps loading and error quiet while rendering an empty settled state", async () => {
|
||||
const loading = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} />
|
||||
))
|
||||
const failed = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} />
|
||||
))
|
||||
const empty = await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />
|
||||
<DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} />
|
||||
))
|
||||
|
||||
expect(loading).not.toContain("Loading diff...")
|
||||
|
|
@ -86,16 +72,13 @@ describe("DiffViewerFileTree", () => {
|
|||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
focused
|
||||
highlightedNode={src.id}
|
||||
/>
|
||||
)),
|
||||
)
|
||||
const unfocused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />
|
||||
)),
|
||||
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} />),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src/config")
|
||||
|
|
@ -114,14 +97,7 @@ describe("DiffViewerFileTree", () => {
|
|||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
width={32}
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={collapsed}
|
||||
/>
|
||||
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} expandedNodes={collapsed} />
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src/config"])
|
||||
|
|
@ -134,7 +110,6 @@ describe("DiffViewerFileTree", () => {
|
|||
width={32}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={allExpandedFileTreeDirectories(tree)}
|
||||
/>
|
||||
)),
|
||||
|
|
|
|||
72
packages/tui/test/cli/tui/theme-mode.test.tsx
Normal file
72
packages/tui/test/cli/tui/theme-mode.test.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeProvider, useTheme } from "../../../src/context/theme"
|
||||
|
||||
async function wait(fn: () => boolean) {
|
||||
const started = Date.now()
|
||||
while (!fn()) {
|
||||
if (Date.now() - started > 2000) throw new Error("timed out waiting for theme mode")
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
}
|
||||
|
||||
test("uses an available mode while retaining the pinned preference", async () => {
|
||||
const lightOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
lightOnly.theme.background = "#eeeeee"
|
||||
lightOnly.theme.text = "#111111"
|
||||
const dual = structuredClone(DEFAULT_THEMES.opencode)
|
||||
dual.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||
dual.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
darkOnly.theme.background = "#111111"
|
||||
darkOnly.theme.text = "#eeeeee"
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
const value = useTheme()
|
||||
theme = value
|
||||
return <text>{value.mode()}</text>
|
||||
}
|
||||
|
||||
function current() {
|
||||
if (!theme) throw new Error("Theme provider is not mounted")
|
||||
return theme
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
|
||||
<ThemeProvider
|
||||
mode="dark"
|
||||
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
|
||||
>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
expect(current().mode()).toBe("light")
|
||||
expect(current().modes()).toEqual(["light"])
|
||||
expect(current().supports("dark")).toBeFalse()
|
||||
expect(current().setMode("dark")).toBeFalse()
|
||||
expect(current().set("dark-only")).toBeTrue()
|
||||
await wait(() => current().mode() === "dark")
|
||||
expect(current().modes()).toEqual(["dark"])
|
||||
expect(current().set("light-only")).toBeTrue()
|
||||
await wait(() => current().mode() === "light")
|
||||
expect(current().set("dual")).toBeTrue()
|
||||
await wait(() => current().mode() === "dark")
|
||||
expect(current().modes()).toEqual(["light", "dark"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
|
|
@ -58,45 +59,15 @@ describe("run catalog shared", () => {
|
|||
|
||||
test("merges current providers and models into the footer catalog shape", () => {
|
||||
const providers = runProviders(
|
||||
[catalogProvider("openai", "OpenAI")],
|
||||
[
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
package: "",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
catalogModel({
|
||||
id: "gpt-5",
|
||||
modelID: "openai",
|
||||
providerID: "openai",
|
||||
name: "Little Frank",
|
||||
capabilities: {
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: [{ id: "high" }],
|
||||
time: {
|
||||
released: 1,
|
||||
},
|
||||
cost: [
|
||||
{
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: {
|
||||
context: 128000,
|
||||
output: 8192,
|
||||
},
|
||||
},
|
||||
variants: ["high"],
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
@ -110,6 +81,9 @@ describe("run catalog shared", () => {
|
|||
cost: {
|
||||
input: 0,
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
},
|
||||
status: "active",
|
||||
variants: {
|
||||
high: {},
|
||||
|
|
|
|||
|
|
@ -2,30 +2,12 @@ import { describe, expect, test } from "bun:test"
|
|||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { entryBody, entryCanStream, entryDone } from "../../src/mini/entry.body"
|
||||
import type { StreamCommit, ToolSnapshot } from "../../src/mini/types"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
function commit(input: Partial<StreamCommit> & Pick<StreamCommit, "kind" | "text" | "phase" | "source">): StreamCommit {
|
||||
return input
|
||||
}
|
||||
|
||||
function toolPart(
|
||||
name: string,
|
||||
state: SessionMessageAssistantTool["state"],
|
||||
id = `${name}-1`,
|
||||
): SessionMessageAssistantTool {
|
||||
return {
|
||||
type: "tool",
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
time:
|
||||
state.status === "streaming"
|
||||
? { created: 1 }
|
||||
: state.status === "completed" || state.status === "error"
|
||||
? { created: 1, ran: 1, completed: 2 }
|
||||
: { created: 1, ran: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolCommit(input: {
|
||||
tool: string
|
||||
state: SessionMessageAssistantTool["state"]
|
||||
|
|
@ -45,7 +27,7 @@ function toolCommit(input: {
|
|||
input.toolState ??
|
||||
(input.state.status === "error" ? "error" : input.state.status === "completed" ? "completed" : "running"),
|
||||
messageID: input.messageID,
|
||||
part: toolPart(input.tool, input.state, input.id),
|
||||
part: canonicalToolPart(input.tool, input.state, input.id),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
67
packages/tui/test/mini/fixture/catalog.ts
Normal file
67
packages/tui/test/mini/fixture/catalog.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { spyOn } from "bun:test"
|
||||
import type {
|
||||
LocationRef,
|
||||
ModelListOutput,
|
||||
OpenCodeClient,
|
||||
ProviderListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
export function catalogProvider(id: string, name: string): ProviderListOutput["data"][number] {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
package: "",
|
||||
}
|
||||
}
|
||||
|
||||
export function catalogModel(input: {
|
||||
id: string
|
||||
modelID?: string
|
||||
providerID: string
|
||||
name?: string
|
||||
context?: number
|
||||
variants?: string[]
|
||||
}): ModelListOutput["data"][number] {
|
||||
return {
|
||||
id: input.id,
|
||||
modelID: input.modelID ?? input.id,
|
||||
providerID: input.providerID,
|
||||
name: input.name ?? input.id,
|
||||
capabilities: {
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
variants: (input.variants ?? []).map((id) => ({ id })),
|
||||
time: { released: 1 },
|
||||
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: input.context ?? 128_000, output: 8_192 },
|
||||
}
|
||||
}
|
||||
|
||||
export function stubCatalogLists(
|
||||
sdk: OpenCodeClient,
|
||||
input: {
|
||||
location?: LocationRef
|
||||
providers?: ProviderListOutput["data"]
|
||||
models?: ModelListOutput["data"]
|
||||
} = {},
|
||||
) {
|
||||
const location = {
|
||||
directory: input.location?.directory ?? "/tmp",
|
||||
workspaceID: input.location?.workspaceID,
|
||||
project: { id: "proj_1", directory: input.location?.directory ?? "/tmp" },
|
||||
}
|
||||
const empty = { location, data: [] }
|
||||
|
||||
return {
|
||||
provider: spyOn(sdk.provider, "list").mockResolvedValue({ location, data: input.providers ?? [] } as never),
|
||||
model: spyOn(sdk.model, "list").mockResolvedValue({ location, data: input.models ?? [] } as never),
|
||||
agent: spyOn(sdk.agent, "list").mockResolvedValue(empty as never),
|
||||
reference: spyOn(sdk.reference, "list").mockResolvedValue(empty as never),
|
||||
command: spyOn(sdk.command, "list").mockResolvedValue(empty as never),
|
||||
skill: spyOn(sdk.skill, "list").mockResolvedValue(empty as never),
|
||||
}
|
||||
}
|
||||
58
packages/tui/test/mini/fixture/footer-api.ts
Normal file
58
packages/tui/test/mini/fixture/footer-api.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../../src/mini/types"
|
||||
|
||||
export function createFooterApiFixture(input: { events?: FooterEvent[]; commits?: StreamCommit[] } = {}) {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
const events = input.events ?? []
|
||||
const commits = input.commits ?? []
|
||||
const calls: Array<{ type: "event"; value: FooterEvent } | { type: "commit"; value: StreamCommit }> = []
|
||||
let closed = false
|
||||
|
||||
const api: FooterApi = {
|
||||
get isClosed() {
|
||||
return closed
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
return () => prompts.delete(fn)
|
||||
},
|
||||
onClose(fn) {
|
||||
if (closed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
closes.add(fn)
|
||||
return () => closes.delete(fn)
|
||||
},
|
||||
event(next) {
|
||||
events.push(next)
|
||||
calls.push({ type: "event", value: next })
|
||||
},
|
||||
append(next) {
|
||||
commits.push(next)
|
||||
calls.push({ type: "commit", value: next })
|
||||
},
|
||||
idle: () => Promise.resolve(),
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
for (const fn of [...closes]) fn()
|
||||
},
|
||||
destroy() {
|
||||
api.close()
|
||||
prompts.clear()
|
||||
closes.clear()
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
events,
|
||||
commits,
|
||||
calls,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
const prompt: RunPrompt = mode ? { text, parts: [], mode } : { text, parts: [] }
|
||||
for (const fn of [...prompts]) fn(prompt)
|
||||
},
|
||||
}
|
||||
}
|
||||
20
packages/tui/test/mini/fixture/tool-part.ts
Normal file
20
packages/tui/test/mini/fixture/tool-part.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
|
||||
export function canonicalToolPart(
|
||||
name: string,
|
||||
state: SessionMessageAssistantTool["state"],
|
||||
id = `${name}-1`,
|
||||
): SessionMessageAssistantTool {
|
||||
return {
|
||||
type: "tool",
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
time:
|
||||
state.status === "streaming"
|
||||
? { created: 1 }
|
||||
: state.status === "completed" || state.status === "error"
|
||||
? { created: 1, ran: 1, completed: 2 }
|
||||
: { created: 1, ran: 1 },
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ test("down opens subagents from an empty prompt", async () => {
|
|||
const [state] = createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "gpt-5",
|
||||
usage: "",
|
||||
first: false,
|
||||
|
|
@ -70,7 +69,6 @@ test("down opens subagents from an empty prompt", async () => {
|
|||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,6 @@ function footerState(input: Partial<FooterState> = {}) {
|
|||
return createSignal<FooterState>({
|
||||
phase: "idle",
|
||||
status: "",
|
||||
queue: 0,
|
||||
model: "gpt-5",
|
||||
usage: "",
|
||||
first: false,
|
||||
|
|
@ -158,7 +157,6 @@ async function renderFooter(
|
|||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
|
|
@ -261,15 +259,9 @@ function footerComposerFrame(root: BoxRenderable | RootRenderable) {
|
|||
|
||||
function footerStatusline(root: BoxRenderable | RootRenderable) {
|
||||
const status = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
|
||||
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
|
||||
const boxes = root.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
for (const box of boxes) {
|
||||
const first = box.getChildren().find((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
if (
|
||||
box.backgroundColor?.toInts().every((value, index) => value === status[index]) &&
|
||||
first?.backgroundColor?.toInts().every((value, index) => value === accent[index])
|
||||
)
|
||||
return box
|
||||
if (box.backgroundColor?.toInts().every((value, index) => value === status[index])) return box
|
||||
boxes.push(...box.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable))
|
||||
}
|
||||
throw new Error("Footer statusline not found")
|
||||
|
|
@ -422,6 +414,7 @@ test("direct skill panel renders searchable skill list", async () => {
|
|||
command({ name: "internal", description: "Skill command", source: "skill" }),
|
||||
command({ name: "formatter", description: "Apply formatter fixes", source: "skill" }),
|
||||
])
|
||||
const selected: string[] = []
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
|
|
@ -430,7 +423,9 @@ test("direct skill panel renders searchable skill list", async () => {
|
|||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
commands={commands}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
onSelect={(name) => {
|
||||
selected.push(name)
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
|
|
@ -451,6 +446,11 @@ test("direct skill panel renders searchable skill list", async () => {
|
|||
expect(frame).toContain("formatter")
|
||||
expect(frame).toContain("Apply formatter fixes")
|
||||
expect(frame).not.toContain("review")
|
||||
await app.mockInput.typeText("format")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).not.toContain("internal")
|
||||
app.mockInput.pressEnter()
|
||||
expect(selected).toEqual(["formatter"])
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -672,8 +672,15 @@ test("direct subagent panel closes when moving up from the first item", async ()
|
|||
}
|
||||
})
|
||||
|
||||
test("direct queued prompt panel renders pending prompt actions", async () => {
|
||||
const [prompts] = createSignal([{ messageID: "m-1", prompt: { text: "fix the auth test", parts: [] } }])
|
||||
test("direct pending panel shows durable delivery without edit actions", async () => {
|
||||
const [prompts] = createSignal([
|
||||
{
|
||||
messageID: "m-1",
|
||||
prompt: { text: "fix the auth test", parts: [] },
|
||||
delivery: "queue" as const,
|
||||
admittedSeq: 1,
|
||||
},
|
||||
])
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
|
|
@ -682,8 +689,6 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
|||
theme={() => RUN_THEME_FALLBACK.footer}
|
||||
prompts={prompts}
|
||||
onClose={() => {}}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
/>
|
||||
</box>
|
||||
),
|
||||
|
|
@ -695,12 +700,14 @@ test("direct queued prompt panel renders pending prompt actions", async () => {
|
|||
const frame = app.captureCharFrame()
|
||||
const list = panelMenu(app.renderer.root)
|
||||
|
||||
expect(frame).toContain("Queued prompts")
|
||||
expect(frame).toContain("Pending work")
|
||||
expect(frame).toContain("fix the auth test")
|
||||
expect(frame).toContain("queued")
|
||||
expect(frame).toContain("queue")
|
||||
expect(frame).not.toContain("┌")
|
||||
expect(frame).not.toContain("┃")
|
||||
expectPaletteList(list, 0)
|
||||
expect(frame).not.toContain("edit")
|
||||
expect(frame).not.toContain("remove")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
|
@ -981,11 +988,10 @@ test.skip("direct footer clears the synthetic skills draft when the panel closes
|
|||
}
|
||||
})
|
||||
|
||||
test("direct footer shows editable prompts and additional queued work while running", async () => {
|
||||
test("direct footer shows authoritative pending work while running", async () => {
|
||||
const [state] = createSignal<FooterState>({
|
||||
phase: "running",
|
||||
status: "",
|
||||
queue: 3,
|
||||
model: "gpt-5",
|
||||
usage: "",
|
||||
first: false,
|
||||
|
|
@ -1018,7 +1024,14 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
state={state}
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
queuedPrompts={() => [{ messageID: "m-queued", prompt: { text: "follow up", parts: [] } }]}
|
||||
queuedPrompts={() => [
|
||||
{
|
||||
messageID: "m-queued",
|
||||
prompt: { text: "follow up", parts: [] },
|
||||
delivery: "queue",
|
||||
admittedSeq: 1,
|
||||
},
|
||||
]}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
onSubmit={() => true}
|
||||
|
|
@ -1035,7 +1048,6 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
onRows={() => {}}
|
||||
onLayout={() => {}}
|
||||
onStatus={() => {}}
|
||||
onQueuedRemove={async () => true}
|
||||
/>
|
||||
</Keymap.Provider>
|
||||
)
|
||||
|
|
@ -1058,30 +1070,25 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
const frame = app.captureCharFrame()
|
||||
const transparent = RGBA.fromValues(0, 0, 0, 0).toInts()
|
||||
const tinted = (RUN_THEME_FALLBACK.footer.status as RGBA).toInts()
|
||||
const accent = (RUN_THEME_FALLBACK.footer.statusAccent as RGBA).toInts()
|
||||
const statusline = footerStatusline(app.renderer.root)
|
||||
const statusItems = statusline.getChildren().filter((item): item is BoxRenderable => item instanceof BoxRenderable)
|
||||
const mode = statusItems[0]
|
||||
const main = statusItems[1]
|
||||
const main = statusItems[0]
|
||||
const spinner = main.getChildren()[0]
|
||||
const model = statusItems[2]
|
||||
const queued = statusItems[3]
|
||||
const background = statusItems[1]
|
||||
const queued = statusItems[2]
|
||||
const hint = statusItems.at(-1)!
|
||||
|
||||
expect(spinner).toBeDefined()
|
||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||
expect(frame).toContain("3 queued")
|
||||
expect(frame).toContain("1 pending")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("ctrl+x q 3 queued")
|
||||
expect(frame).toContain("ctrl+x q 1 pending")
|
||||
expect(frame).toContain("↓ subagents")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).toContain("a-model-name-long-enough-to-force-responsive-truncation")
|
||||
expect(frame).toContain("subagents · ctrl+p cmd")
|
||||
expect(frame).not.toContain("1 agent")
|
||||
expect(statusline.backgroundColor.toInts()).toEqual(tinted)
|
||||
expect(mode.backgroundColor.toInts()).toEqual(accent)
|
||||
expect(main.backgroundColor.toInts()).toEqual(transparent)
|
||||
expect(model.backgroundColor.toInts()).toEqual(transparent)
|
||||
expect(background.backgroundColor.toInts()).toEqual(transparent)
|
||||
expect(queued.backgroundColor.toInts()).toEqual(transparent)
|
||||
expect(hint.backgroundColor.toInts()).toEqual(transparent)
|
||||
} finally {
|
||||
|
|
@ -1109,9 +1116,7 @@ test("direct footer always offers backgrounding for a foreground subagent", asyn
|
|||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("GPT-5")
|
||||
expect(frame).toContain("xhigh · ctrl+b background · ↓ subagents · ctrl+p cmd")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).toContain("ctrl+b background · ↓ subagents · ctrl+p cmd")
|
||||
expect(frame).not.toContain("queued")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
|
|
@ -1136,8 +1141,7 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
|||
await app.renderOnce()
|
||||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("GPT-5")
|
||||
expect(frame).toContain("xhigh · ctrl+p cmd")
|
||||
expect(frame).toContain("ctrl+p cmd")
|
||||
expect(frame).not.toContain("↓ subagents")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
|
|
@ -1176,7 +1180,7 @@ test("direct footer shows full usage metadata when room is available", async ()
|
|||
}
|
||||
})
|
||||
|
||||
test("direct footer mode label keeps left padding without a status pill", async () => {
|
||||
test("direct footer does not label normal mode as build", async () => {
|
||||
const app = await renderFooter()
|
||||
|
||||
try {
|
||||
|
|
@ -1184,10 +1188,10 @@ test("direct footer mode label keeps left padding without a status pill", async
|
|||
const statusline = app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes("BUILD") && line.includes("cmd"))
|
||||
.find((line) => line.includes("cmd"))
|
||||
|
||||
expect(statusline).toBeDefined()
|
||||
expect(statusline?.startsWith(" BUILD ")).toBe(true)
|
||||
expect(statusline).not.toContain("BUILD")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ describe("run footer width", () => {
|
|||
expect(narrow.statusline.showCommandHint).toBe(true)
|
||||
expect(narrow.statusline.showContextHints).toBe(false)
|
||||
expect(narrow.statusline.contextHintLimit).toBe(0)
|
||||
expect(narrow.statusline.showModel).toBe(false)
|
||||
|
||||
const command = footerWidthPolicy(65)
|
||||
expect(command.statusline.showCommandHint).toBe(false)
|
||||
|
|
@ -22,14 +21,11 @@ describe("run footer width", () => {
|
|||
expect(compact.statusline.showActivityMeta).toBe(true)
|
||||
expect(compact.statusline.showContextHints).toBe(true)
|
||||
expect(compact.statusline.contextHintLimit).toBe(1)
|
||||
expect(compact.statusline.showModel).toBe(false)
|
||||
|
||||
const model = footerWidthPolicy(120)
|
||||
expect(model.statusline.contextHintLimit).toBe(2)
|
||||
expect(model.statusline.showModel).toBe(true)
|
||||
const context = footerWidthPolicy(120)
|
||||
expect(context.statusline.contextHintLimit).toBe(2)
|
||||
|
||||
const spacious = footerWidthPolicy(150)
|
||||
expect(spacious.statusline.contextHintLimit).toBeUndefined()
|
||||
expect(spacious.statusline.showModel).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
permissionRun,
|
||||
} from "../../src/mini/permission.shared"
|
||||
import type { MiniPermissionRequest } from "../../src/mini/types"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
function req(input: Partial<MiniPermissionRequest> = {}): MiniPermissionRequest {
|
||||
return {
|
||||
|
|
@ -89,18 +90,16 @@ describe("run permission shared", () => {
|
|||
req({
|
||||
action: "shell",
|
||||
source: { type: "tool", messageID: "msg-shell", callID: "call-shell" },
|
||||
tool: {
|
||||
type: "tool",
|
||||
id: "call-shell",
|
||||
name: "shell",
|
||||
state: {
|
||||
tool: canonicalToolPart(
|
||||
"shell",
|
||||
{
|
||||
status: "running",
|
||||
input: { command: "git status --short" },
|
||||
structured: {},
|
||||
content: [],
|
||||
},
|
||||
time: { created: 1, ran: 1 },
|
||||
},
|
||||
"call-shell",
|
||||
),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
|
|
@ -137,18 +136,16 @@ describe("run permission shared", () => {
|
|||
action: "websearch",
|
||||
metadata: { provider: "parallel" },
|
||||
source: { type: "tool", messageID: "msg-search", callID: "call-search" },
|
||||
tool: {
|
||||
type: "tool",
|
||||
id: "call-search",
|
||||
name: "websearch",
|
||||
state: {
|
||||
tool: canonicalToolPart(
|
||||
"websearch",
|
||||
{
|
||||
status: "running",
|
||||
input: { query: "current releases" },
|
||||
structured: { provider: "exa", retained: true },
|
||||
content: [],
|
||||
},
|
||||
time: { created: 1, ran: 1 },
|
||||
},
|
||||
"call-search",
|
||||
),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
|
|
@ -164,18 +161,16 @@ describe("run permission shared", () => {
|
|||
action: "edit",
|
||||
resources: ["src/index.ts"],
|
||||
source: { type: "tool", messageID: "msg-edit", callID: "call-edit" },
|
||||
tool: {
|
||||
type: "tool",
|
||||
id: "call-edit",
|
||||
name: "edit",
|
||||
state: {
|
||||
tool: canonicalToolPart(
|
||||
"edit",
|
||||
{
|
||||
status: "running",
|
||||
input: { patchText: "*** Begin Patch\n*** Update File: src/index.ts\n@@\n-old\n+new\n*** End Patch" },
|
||||
structured: {},
|
||||
content: [],
|
||||
},
|
||||
time: { created: 1, ran: 1 },
|
||||
},
|
||||
"call-edit",
|
||||
),
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -98,4 +98,19 @@ describe("run prompt editor helpers", () => {
|
|||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses display offsets when realigning Mini parts", () => {
|
||||
const part = {
|
||||
type: "agent",
|
||||
name: "helper",
|
||||
source: { start: 0, end: 7, value: "@helper" },
|
||||
} satisfies RunPromptPart
|
||||
|
||||
expect(realignEditorPromptParts("中文🙂\n@helper", [part])).toEqual([
|
||||
{
|
||||
...part,
|
||||
source: { start: 7, end: 14, value: "@helper" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,67 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
|||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Resolved } from "../../src/config"
|
||||
import { resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
function ok<T>(data: T) {
|
||||
return Promise.resolve(data)
|
||||
}
|
||||
|
||||
function provider(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
api: { type: "native" as const, settings: {} },
|
||||
request: { headers: {}, body: {} },
|
||||
}
|
||||
}
|
||||
|
||||
function model(id: string, providerID: string, context: number, variants: string[] = []) {
|
||||
return {
|
||||
id,
|
||||
providerID,
|
||||
api: {
|
||||
id: providerID,
|
||||
type: "native" as const,
|
||||
settings: {},
|
||||
},
|
||||
name: id,
|
||||
capabilities: {
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
},
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
},
|
||||
variants: variants.map((variant) => ({
|
||||
id: variant,
|
||||
headers: {},
|
||||
body: {},
|
||||
})),
|
||||
time: {
|
||||
released: 1,
|
||||
},
|
||||
cost: [
|
||||
{
|
||||
input: 0,
|
||||
output: 0,
|
||||
cache: {
|
||||
read: 0,
|
||||
write: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
limit: {
|
||||
context,
|
||||
output: 8192,
|
||||
},
|
||||
status: "active" as const,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function config(input?: {
|
||||
leader?: string
|
||||
leaderTimeout?: number
|
||||
|
|
@ -165,10 +107,15 @@ describe("run runtime boot", () => {
|
|||
|
||||
test("loads v2 providers and models for model selector data", async () => {
|
||||
const sdk = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const providers = [provider("openai", "OpenAI")]
|
||||
const models = [model("gpt-5", "openai", 128000, ["high", "minimal"])]
|
||||
const providerList = spyOn(sdk.provider, "list").mockImplementation(() => ok({ data: providers }) as never)
|
||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ data: models }) as never)
|
||||
const location = { directory: "/workspace", project: { id: "proj_1", directory: "/workspace" } }
|
||||
const providerList = spyOn(sdk.provider, "list").mockResolvedValue({
|
||||
location,
|
||||
data: [catalogProvider("openai", "OpenAI")],
|
||||
} as never)
|
||||
spyOn(sdk.model, "list").mockResolvedValue({
|
||||
location,
|
||||
data: [catalogModel({ id: "gpt-5", providerID: "openai", variants: ["high", "minimal"] })],
|
||||
} as never)
|
||||
|
||||
await expect(resolveModelInfo(sdk, { directory: "/workspace" })).resolves.toEqual({
|
||||
providers: [
|
||||
|
|
@ -181,6 +128,9 @@ describe("run runtime boot", () => {
|
|||
cost: {
|
||||
input: 0,
|
||||
},
|
||||
limit: {
|
||||
context: 128_000,
|
||||
},
|
||||
status: "active",
|
||||
variants: {
|
||||
high: {},
|
||||
|
|
|
|||
|
|
@ -1,87 +1,19 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { runPromptQueue } from "../../src/mini/runtime.queue"
|
||||
import type { FooterApi, FooterEvent, RunPrompt, StreamCommit } from "../../src/mini/types"
|
||||
import { runPromptQueue as runPromptQueueBase, type QueueInput } from "../../src/mini/runtime.queue"
|
||||
import type { RunPrompt } from "../../src/mini/types"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
|
||||
function footer() {
|
||||
const prompts = new Set<(input: RunPrompt) => void>()
|
||||
const queuedRemoves = new Set<(messageID: string) => void>()
|
||||
const closes = new Set<() => void>()
|
||||
const events: FooterEvent[] = []
|
||||
const commits: StreamCommit[] = []
|
||||
let closed = false
|
||||
|
||||
const api: FooterApi = {
|
||||
get isClosed() {
|
||||
return closed
|
||||
},
|
||||
onPrompt(fn) {
|
||||
prompts.add(fn)
|
||||
return () => {
|
||||
prompts.delete(fn)
|
||||
}
|
||||
},
|
||||
onQueuedRemove(fn) {
|
||||
queuedRemoves.add(fn)
|
||||
return () => {
|
||||
queuedRemoves.delete(fn)
|
||||
}
|
||||
},
|
||||
onClose(fn) {
|
||||
if (closed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
closes.add(fn)
|
||||
return () => {
|
||||
closes.delete(fn)
|
||||
}
|
||||
},
|
||||
event(next) {
|
||||
events.push(next)
|
||||
},
|
||||
append(next) {
|
||||
commits.push(next)
|
||||
},
|
||||
idle() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
for (const fn of [...closes]) {
|
||||
fn()
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
api.close()
|
||||
prompts.clear()
|
||||
closes.clear()
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
events,
|
||||
commits,
|
||||
submit(text: string, mode?: RunPrompt["mode"]) {
|
||||
const next = mode ? { text, parts: [] as RunPrompt["parts"], mode } : { text, parts: [] as RunPrompt["parts"] }
|
||||
for (const fn of [...prompts]) {
|
||||
fn(next)
|
||||
}
|
||||
},
|
||||
removeQueued(messageID: string) {
|
||||
for (const fn of [...queuedRemoves]) fn(messageID)
|
||||
},
|
||||
}
|
||||
function runPromptQueue(input: Omit<QueueInput, "admit" | "settle"> & Partial<Pick<QueueInput, "admit" | "settle">>) {
|
||||
return runPromptQueueBase({
|
||||
admit: async () => {},
|
||||
settle: async () => {},
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
describe("run runtime queue", () => {
|
||||
test("ignores empty prompts", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
let calls = 0
|
||||
|
||||
const task = runPromptQueue({
|
||||
|
|
@ -99,7 +31,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("treats /exit as a close command", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
let calls = 0
|
||||
|
||||
const task = runPromptQueue({
|
||||
|
|
@ -116,7 +48,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("treats /new as a local session command", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
const seen: string[] = []
|
||||
let created = 0
|
||||
|
||||
|
|
@ -149,7 +81,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("shell mode submits /exit as a shell command", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
const seen: RunPrompt[] = []
|
||||
|
||||
const task = runPromptQueue({
|
||||
|
|
@ -168,7 +100,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("shell mode submits /new instead of creating a session", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
const seen: RunPrompt[] = []
|
||||
let created = 0
|
||||
|
||||
|
|
@ -192,7 +124,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("shell mode does not append a synthetic user row", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
|
|
@ -207,7 +139,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("shell mode does not emit a turn duration summary", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
|
|
@ -223,7 +155,7 @@ describe("run runtime queue", () => {
|
|||
})
|
||||
|
||||
test("preserves whitespace for initial input", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
const seen: string[] = []
|
||||
|
||||
await runPromptQueue({
|
||||
|
|
@ -247,206 +179,90 @@ describe("run runtime queue", () => {
|
|||
])
|
||||
})
|
||||
|
||||
test("passes prompts to onSend", async () => {
|
||||
const ui = footer()
|
||||
const seen: string[] = []
|
||||
|
||||
await runPromptQueue({
|
||||
footer: ui.api,
|
||||
initialInput: " hello ",
|
||||
onSend: (input) => {
|
||||
seen.push(input.text)
|
||||
},
|
||||
run: async () => {
|
||||
ui.api.close()
|
||||
},
|
||||
})
|
||||
|
||||
expect(seen).toEqual([" hello "])
|
||||
})
|
||||
|
||||
test("appends the user row before the turn starts", async () => {
|
||||
const ui = footer()
|
||||
|
||||
await runPromptQueue({
|
||||
footer: ui.api,
|
||||
initialInput: "/fmt bash",
|
||||
run: async () => {
|
||||
expect(ui.commits).toEqual([
|
||||
{
|
||||
kind: "user",
|
||||
text: "/fmt bash",
|
||||
phase: "start",
|
||||
source: "system",
|
||||
messageID: expect.any(String),
|
||||
},
|
||||
])
|
||||
ui.api.close()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("runs queued prompts in order", async () => {
|
||||
const ui = footer()
|
||||
const seen: string[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
test("durably admits in-flight follow-ups in submission order", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
seen.push(input.text)
|
||||
if (seen.length === 1) {
|
||||
await gate
|
||||
return
|
||||
}
|
||||
|
||||
ui.api.close()
|
||||
run: async (input, _signal, onAdmitted) => {
|
||||
admitted.push(`${input.text}:steer`)
|
||||
onAdmitted()
|
||||
await gate.promise
|
||||
},
|
||||
admit: async (input) => {
|
||||
admitted.push(`${input.text}:queue`)
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
await Promise.resolve()
|
||||
expect(seen).toEqual(["one"])
|
||||
|
||||
wake?.()
|
||||
await task
|
||||
|
||||
expect(seen).toEqual(["one", "two"])
|
||||
})
|
||||
|
||||
test("exposes ordinary in-flight prompts for removal before sending", async () => {
|
||||
const ui = footer()
|
||||
const turns: RunPrompt[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
turns.push(input)
|
||||
await gate
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
ui.submit("two")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||
expect(turns[0]?.messageID).toEqual(expect.any(String))
|
||||
ui.submit("three")
|
||||
while (admitted.length < 3) await Bun.sleep(0)
|
||||
expect(admitted).toEqual(["one:steer", "two:queue", "three:queue"])
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
||||
const first = ui.events.find((item) => item.type === "queued.prompts")
|
||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
expect(first?.type === "queued.prompts" ? first.prompts : []).toEqual([])
|
||||
expect(
|
||||
first?.type === "queued.prompts" && event?.type === "queued.prompts" ? first.prompts === event.prompts : true,
|
||||
).toBe(false)
|
||||
expect(ui.events.findLast((item) => item.type === "queue")).toEqual({ type: "queue", queue: 1 })
|
||||
expect(event?.type === "queued.prompts" ? event.prompts.map((item) => item.prompt.text) : []).toEqual(["two"])
|
||||
if (event?.type === "queued.prompts") ui.removeQueued(event.prompts[0]!.messageID)
|
||||
await Promise.resolve()
|
||||
|
||||
wake?.()
|
||||
ui.api.close()
|
||||
gate.resolve()
|
||||
await task
|
||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||
})
|
||||
|
||||
test("removing one managed queued prompt preserves the others", async () => {
|
||||
const ui = footer()
|
||||
const turns: string[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
|
||||
test("continues durable admission after one fails", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
const admitted: string[] = []
|
||||
const errors: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
turns.push(input.text)
|
||||
if (input.text === "active") await gate
|
||||
if (input.text === "queued three") ui.api.close()
|
||||
run: async (_input, _signal, admitted) => {
|
||||
admitted()
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
|
||||
ui.submit("active")
|
||||
ui.submit("queued one")
|
||||
ui.submit("queued two")
|
||||
ui.submit("queued three")
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
if (event?.type === "queued.prompts") {
|
||||
const second = event.prompts.find((item) => item.prompt.text === "queued two")
|
||||
if (second) ui.removeQueued(second.messageID)
|
||||
}
|
||||
|
||||
wake?.()
|
||||
await task
|
||||
expect(turns).toEqual(["active", "queued one", "queued three"])
|
||||
})
|
||||
|
||||
test("drains a prompt queued during an in-flight turn", async () => {
|
||||
const ui = footer()
|
||||
const seen: string[] = []
|
||||
let wake: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
wake = resolve
|
||||
})
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input) => {
|
||||
seen.push(input.text)
|
||||
if (seen.length === 1) {
|
||||
await gate
|
||||
return
|
||||
}
|
||||
|
||||
ui.api.close()
|
||||
admit: async (input) => {
|
||||
if (input.text === "two") throw new Error("admission failed")
|
||||
admitted.push(input.text)
|
||||
},
|
||||
onAdmissionError: (_prompt, error) => {
|
||||
errors.push(error instanceof Error ? error.message : String(error))
|
||||
},
|
||||
settle: async () => ui.api.close(),
|
||||
})
|
||||
|
||||
ui.submit("one")
|
||||
await Promise.resolve()
|
||||
expect(seen).toEqual(["one"])
|
||||
|
||||
wake?.()
|
||||
await Promise.resolve()
|
||||
ui.submit("two")
|
||||
ui.submit("three")
|
||||
while (admitted.length === 0) await Bun.sleep(0)
|
||||
gate.resolve()
|
||||
await task
|
||||
|
||||
expect(seen).toEqual(["one", "two"])
|
||||
expect(errors).toEqual(["admission failed"])
|
||||
expect(admitted).toEqual(["three"])
|
||||
})
|
||||
|
||||
test("close aborts the active run and drops pending queued work", async () => {
|
||||
const ui = footer()
|
||||
const seen: string[] = []
|
||||
let hit = false
|
||||
test("close aborts an in-flight durable admission", async () => {
|
||||
const ui = createFooterApiFixture()
|
||||
let admissionHit = false
|
||||
const admissionStarted = Promise.withResolvers<void>()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
run: async (input, signal) => {
|
||||
seen.push(input.text)
|
||||
run: async (_input, signal, admitted) => {
|
||||
admitted()
|
||||
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }))
|
||||
},
|
||||
admit: async (_prompt, signal) => {
|
||||
admissionStarted.resolve()
|
||||
await new Promise<void>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
hit = true
|
||||
admissionHit = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
hit = true
|
||||
admissionHit = true
|
||||
resolve()
|
||||
},
|
||||
{ once: true },
|
||||
|
|
@ -458,15 +274,15 @@ describe("run runtime queue", () => {
|
|||
ui.submit("one")
|
||||
await Promise.resolve()
|
||||
ui.submit("two")
|
||||
await admissionStarted.promise
|
||||
ui.api.close()
|
||||
await task
|
||||
|
||||
expect(hit).toBe(true)
|
||||
expect(seen).toEqual(["one"])
|
||||
expect(admissionHit).toBe(true)
|
||||
})
|
||||
|
||||
test("propagates run errors", async () => {
|
||||
const ui = footer()
|
||||
const ui = createFooterApiFixture()
|
||||
|
||||
const task = runPromptQueue({
|
||||
footer: ui.api,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
|||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { runInteractiveDeferredMode } from "../../src/mini/runtime"
|
||||
import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
||||
import type { FooterApi, FooterEvent, MiniHost } from "../../src/mini/types"
|
||||
import type { FooterEvent, MiniHost } from "../../src/mini/types"
|
||||
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
function defer<T>() {
|
||||
|
|
@ -38,55 +40,8 @@ function host(): MiniHost {
|
|||
}
|
||||
}
|
||||
|
||||
function footer(events: FooterEvent[] = []): FooterApi {
|
||||
let closed = false
|
||||
const closes = new Set<() => void>()
|
||||
|
||||
const notify = () => {
|
||||
for (const fn of closes) fn()
|
||||
}
|
||||
|
||||
return {
|
||||
get isClosed() {
|
||||
return closed
|
||||
},
|
||||
onPrompt: () => () => {},
|
||||
onQueuedRemove: () => () => {},
|
||||
onClose(fn) {
|
||||
if (closed) {
|
||||
fn()
|
||||
return () => {}
|
||||
}
|
||||
|
||||
closes.add(fn)
|
||||
return () => {
|
||||
closes.delete(fn)
|
||||
}
|
||||
},
|
||||
event(value) {
|
||||
events.push(value)
|
||||
},
|
||||
append() {},
|
||||
idle() {
|
||||
return Promise.resolve()
|
||||
},
|
||||
close() {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
notify()
|
||||
},
|
||||
destroy() {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
|
||||
closed = true
|
||||
notify()
|
||||
},
|
||||
}
|
||||
function footer(events: FooterEvent[] = []) {
|
||||
return createFooterApiFixture({ events }).api
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -100,12 +55,7 @@ describe("run interactive runtime", () => {
|
|||
const streamStarted = defer<void>()
|
||||
let lifecycle!: LifecycleInput
|
||||
const settled: Array<{ sessionID: string; formID: string }> = []
|
||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
stubCatalogLists(sdk)
|
||||
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
|
|
@ -143,6 +93,8 @@ describe("run interactive runtime", () => {
|
|||
streamStarted.resolve()
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
settleForm: (sessionID: string, formID: string) => settled.push({ sessionID, formID }),
|
||||
|
|
@ -195,12 +147,7 @@ describe("run interactive runtime", () => {
|
|||
const api = footer()
|
||||
let resolved = 0
|
||||
api.idle = () => painted.promise
|
||||
spyOn(sdk.provider, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.model, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
stubCatalogLists(sdk)
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
|
|
@ -279,38 +226,17 @@ describe("run interactive runtime", () => {
|
|||
cursor: {},
|
||||
}) as never,
|
||||
)
|
||||
spyOn(sdk.provider, "list").mockImplementation(
|
||||
() =>
|
||||
ok({
|
||||
location: { directory: "/tmp" },
|
||||
data: [{ id: "openai", name: "OpenAI", request: { headers: {}, body: {} } }],
|
||||
}) as never,
|
||||
)
|
||||
spyOn(sdk.model, "list").mockImplementation(
|
||||
() =>
|
||||
ok({
|
||||
location: { directory: "/tmp" },
|
||||
data: [
|
||||
{
|
||||
id: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "Little Frank",
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
request: { headers: {}, body: {} },
|
||||
variants: [{ id: "high", settings: {}, headers: {}, body: {} }],
|
||||
time: { released: 1 },
|
||||
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
|
||||
status: "active",
|
||||
enabled: true,
|
||||
limit: { context: 128000, output: 8192 },
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
)
|
||||
spyOn(sdk.agent, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.reference, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.command, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
spyOn(sdk.skill, "list").mockImplementation(() => ok({ location: { directory: "/tmp" }, data: [] }) as never)
|
||||
stubCatalogLists(sdk, {
|
||||
providers: [catalogProvider("openai", "OpenAI")],
|
||||
models: [
|
||||
catalogModel({
|
||||
id: "gpt-5",
|
||||
providerID: "openai",
|
||||
name: "Little Frank",
|
||||
variants: ["high"],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
|
|
@ -391,13 +317,7 @@ describe("run interactive runtime", () => {
|
|||
const session = spyOn(sdk.session, "get").mockImplementation(
|
||||
(_request, options) => pending(options?.signal) as never,
|
||||
)
|
||||
const response = { location: { directory: "/tmp" }, data: [] }
|
||||
spyOn(sdk.provider, "list").mockResolvedValue(response as never)
|
||||
spyOn(sdk.model, "list").mockResolvedValue(response as never)
|
||||
spyOn(sdk.agent, "list").mockResolvedValue(response as never)
|
||||
spyOn(sdk.reference, "list").mockResolvedValue(response as never)
|
||||
spyOn(sdk.command, "list").mockResolvedValue(response as never)
|
||||
spyOn(sdk.skill, "list").mockResolvedValue(response as never)
|
||||
stubCatalogLists(sdk)
|
||||
|
||||
const task = runInteractiveDeferredMode(
|
||||
{
|
||||
|
|
@ -457,13 +377,9 @@ describe("run interactive runtime", () => {
|
|||
let getDirectory: (() => string) | undefined
|
||||
let findFiles: ((query: string) => Promise<string[]>) | undefined
|
||||
let transportLocation: unknown
|
||||
const response = { location: { directory: "/session", workspaceID: "work-1" }, data: [] }
|
||||
const providerList = spyOn(sdk.provider, "list").mockResolvedValue(response as never)
|
||||
const modelList = spyOn(sdk.model, "list").mockResolvedValue(response as never)
|
||||
const agentList = spyOn(sdk.agent, "list").mockResolvedValue(response as never)
|
||||
const referenceList = spyOn(sdk.reference, "list").mockResolvedValue(response as never)
|
||||
const commandList = spyOn(sdk.command, "list").mockResolvedValue(response as never)
|
||||
const skillList = spyOn(sdk.skill, "list").mockResolvedValue(response as never)
|
||||
const catalogs = stubCatalogLists(sdk, {
|
||||
location: { directory: "/session", workspaceID: "work-1" },
|
||||
})
|
||||
const fileFind = spyOn(sdk.file, "find").mockResolvedValue({
|
||||
location: {
|
||||
directory: "/session",
|
||||
|
|
@ -518,6 +434,8 @@ describe("run interactive runtime", () => {
|
|||
setTimeout(() => input.footer.close(), 0)
|
||||
return {
|
||||
runPromptTurn: async () => {},
|
||||
queuePromptTurn: async () => {},
|
||||
waitForIdle: async () => {},
|
||||
interruptActiveTurn: async () => {},
|
||||
selectSubagent: () => {},
|
||||
replayOnResize: async () => false,
|
||||
|
|
@ -538,12 +456,12 @@ describe("run interactive runtime", () => {
|
|||
const query = { location: { directory: "/session", workspace: "work-1" } }
|
||||
expect(getDirectory?.()).toBe("/session")
|
||||
expect(transportLocation).toMatchObject({ directory: "/session", workspaceID: "work-1" })
|
||||
expect(providerList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(modelList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(agentList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(referenceList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(commandList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(skillList).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.provider).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.model).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.agent).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.reference).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.command).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(catalogs.skill).toHaveBeenCalledWith(query, { signal: expect.any(AbortSignal) })
|
||||
expect(fileFind).toHaveBeenCalledWith({ query: "index", type: "file", ...query })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { RunScrollbackStream } from "../../src/mini/scrollback.surface"
|
|||
import { entryGroupKey } from "../../src/mini/scrollback.writer"
|
||||
import { RUN_THEME_FALLBACK, type RunTheme } from "../../src/mini/theme"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
|
||||
type ClaimedCommit = {
|
||||
snapshot: {
|
||||
|
|
@ -220,21 +221,6 @@ function error(text: string): StreamCommit {
|
|||
}
|
||||
}
|
||||
|
||||
function toolPart(name: string, state: SessionMessageAssistantTool["state"], id: string): SessionMessageAssistantTool {
|
||||
return {
|
||||
type: "tool",
|
||||
id,
|
||||
name,
|
||||
state,
|
||||
time:
|
||||
state.status === "streaming"
|
||||
? { created: 1 }
|
||||
: state.status === "completed" || state.status === "error"
|
||||
? { created: 1, ran: 1, completed: 2 }
|
||||
: { created: 1, ran: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
function toolCommit(input: {
|
||||
tool: string
|
||||
phase: StreamCommit["phase"]
|
||||
|
|
@ -256,7 +242,7 @@ function toolCommit(input: {
|
|||
messageID,
|
||||
tool: input.tool,
|
||||
...(input.toolState ? { toolState: input.toolState } : {}),
|
||||
...(input.state ? { part: toolPart(input.tool, input.state, id) } : {}),
|
||||
...(input.state ? { part: canonicalToolPart(input.tool, input.state, id) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,9 @@ import {
|
|||
type PermissionV2Request,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { createSessionTransport } from "../../src/mini/stream-v2.transport"
|
||||
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
|
||||
import type { StreamCommit } from "../../src/mini/types"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { canonicalToolPart } from "./fixture/tool-part"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
type RunV2Event = EventSubscribeOutput
|
||||
|
|
@ -91,31 +93,7 @@ function promptAdmission(input: Parameters<OpenCodeClient["session"]["prompt"]>[
|
|||
}
|
||||
|
||||
function footer() {
|
||||
const commits: StreamCommit[] = []
|
||||
const events: FooterEvent[] = []
|
||||
let closed = false
|
||||
const api: FooterApi = {
|
||||
get isClosed() {
|
||||
return closed
|
||||
},
|
||||
onPrompt: () => () => {},
|
||||
onQueuedRemove: () => () => {},
|
||||
onClose: () => () => {},
|
||||
event(value) {
|
||||
events.push(value)
|
||||
},
|
||||
append(value) {
|
||||
commits.push(value)
|
||||
},
|
||||
idle: () => Promise.resolve(),
|
||||
close() {
|
||||
closed = true
|
||||
},
|
||||
destroy() {
|
||||
closed = true
|
||||
},
|
||||
}
|
||||
return { api, commits, events }
|
||||
return createFooterApiFixture()
|
||||
}
|
||||
|
||||
type SessionMessages = MessageListOutput["data"]
|
||||
|
|
@ -149,6 +127,8 @@ function sdk(input: {
|
|||
globals?: FormInfo[]
|
||||
globalLocation?: { directory: string; workspaceID?: string }
|
||||
permissions?: Record<string, PermissionV2Request[]>
|
||||
pending?: Record<string, Awaited<ReturnType<OpenCodeClient["session"]["pending"]["list"]>>>
|
||||
wait?: () => Promise<void>
|
||||
}) {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
let subscription = 0
|
||||
|
|
@ -181,6 +161,8 @@ function sdk(input: {
|
|||
}),
|
||||
)
|
||||
spyOn(client.session, "active").mockImplementation(() => ok(input.active?.() ?? {}))
|
||||
spyOn(client.session.pending, "list").mockImplementation((request) => ok(input.pending?.[request.sessionID] ?? []))
|
||||
spyOn(client.session, "wait").mockImplementation(() => input.wait?.() ?? ok(undefined))
|
||||
spyOn(client.session, "message").mockImplementation((request) => {
|
||||
const message = input.messages?.[request.sessionID]?.find((item) => item.id === request.messageID)
|
||||
return message ? (ok(message) as never) : Promise.reject(new Error(`message not found: ${request.messageID}`))
|
||||
|
|
@ -218,6 +200,50 @@ afterEach(() => {
|
|||
})
|
||||
|
||||
describe("V2 mini transport", () => {
|
||||
test("formats footer usage with compact tokens and context percentage", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ streams: [events] }),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
contextLimit: (model) =>
|
||||
model.providerID === "test" && model.modelID === "model" ? 160_000 : undefined,
|
||||
})
|
||||
|
||||
events.push({
|
||||
id: "evt_step_started",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable("ses_1", 1),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_step_ended",
|
||||
created: 2,
|
||||
type: "session.step.ended",
|
||||
durable: durable("ses_1", 2),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 7_000, output: 500, reasoning: 8, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
|
||||
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.usage)) await Bun.sleep(0)
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { usage: "7.5K (5%)" } })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("recursively hydrates blockers for direct and transitive descendants", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
|
@ -268,18 +294,16 @@ describe("V2 mini transport", () => {
|
|||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [
|
||||
{
|
||||
type: "tool" as const,
|
||||
id: "call_child_source",
|
||||
name: "shell",
|
||||
state: {
|
||||
canonicalToolPart(
|
||||
"shell",
|
||||
{
|
||||
status: "running" as const,
|
||||
input: { command: "git status --short" },
|
||||
structured: {},
|
||||
content: [],
|
||||
},
|
||||
time: { created: 1, ran: 1 },
|
||||
},
|
||||
"call_child_source",
|
||||
),
|
||||
],
|
||||
time: { created: 1 },
|
||||
}
|
||||
|
|
@ -441,19 +465,23 @@ describe("V2 mini transport", () => {
|
|||
await transport.close()
|
||||
})
|
||||
|
||||
test("hydrates projection, reduces live output, and completes on settlement", async () => {
|
||||
test("waits authoritatively and reconciles the projected terminal suffix", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const settled = defer()
|
||||
const messages: SessionMessages = []
|
||||
const client = sdk({
|
||||
streams: [events],
|
||||
messages: { ses_1: messages },
|
||||
wait: () => settled.promise,
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: true,
|
||||
replay: true,
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt"])
|
||||
|
||||
let admitted = false
|
||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||
|
|
@ -488,7 +516,7 @@ describe("V2 mini transport", () => {
|
|||
sessionID: "ses_1",
|
||||
assistantMessageID: "msg_assistant",
|
||||
ordinal: 0,
|
||||
delta: "answer",
|
||||
delta: "ans",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
|
|
@ -498,18 +526,232 @@ describe("V2 mini transport", () => {
|
|||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
let done = false
|
||||
void turn.then(() => {
|
||||
done = true
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
expect(done).toBe(false)
|
||||
messages.push(
|
||||
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
|
||||
{
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
time: { created: 3, completed: 4 },
|
||||
},
|
||||
)
|
||||
settled.resolve()
|
||||
await turn
|
||||
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["previous prompt", "answer"])
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
|
||||
expect(ui.commits.map((item) => item.text)).toEqual(["ans", "wer"])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("shows durable pending delivery and appends queued input on promotion", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({
|
||||
streams: [events],
|
||||
pending: {
|
||||
ses_1: [
|
||||
{
|
||||
admittedSeq: 1,
|
||||
id: "msg_queued",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
type: "user",
|
||||
data: { text: "follow up" },
|
||||
delivery: "queue",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
const pending = () =>
|
||||
ui.events
|
||||
.findLast((item) => item.type === "queued.prompts")
|
||||
?.prompts.map((item) => [item.messageID, item.delivery, item.admittedSeq])
|
||||
|
||||
expect(pending()).toEqual([["msg_queued", "queue", 1]])
|
||||
events.push({
|
||||
id: "evt_promoted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
while (!ui.commits.some((item) => item.messageID === "msg_queued")) await Bun.sleep(0)
|
||||
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "user", messageID: "msg_queued", text: "follow up" }),
|
||||
)
|
||||
expect(pending()).toEqual([])
|
||||
const prompt = spyOn(client.session, "prompt").mockImplementation((request) =>
|
||||
ok({ ...promptAdmission(request), admittedSeq: 2 }) as never,
|
||||
)
|
||||
await transport.queuePromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_next", text: "another", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
expect(prompt).toHaveBeenCalledWith(expect.objectContaining({ delivery: "queue" }), expect.anything())
|
||||
events.push({
|
||||
id: "evt_earlier_admission",
|
||||
created: 3,
|
||||
type: "session.input.admitted",
|
||||
durable: durable("ses_1", 1),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
inputID: "msg_earlier",
|
||||
input: { type: "user", data: { text: "earlier" }, delivery: "steer" },
|
||||
},
|
||||
})
|
||||
while (true) {
|
||||
const pending = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||
if (pending?.type === "queued.prompts" && pending.prompts.length >= 2) break
|
||||
await Bun.sleep(0)
|
||||
}
|
||||
expect(pending()).toEqual([
|
||||
["msg_earlier", "steer", 1],
|
||||
["msg_next", "queue", 2],
|
||||
])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("reports an observed execution failure before prompt promotion", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const idle = defer()
|
||||
const client = sdk({ streams: [events], messages: { ses_1: [] }, wait: () => idle.promise })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
let admitted = false
|
||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||
admitted = true
|
||||
return ok(promptAdmission(request)) as never
|
||||
})
|
||||
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
while (!admitted) await Bun.sleep(0)
|
||||
events.push({
|
||||
id: "evt_failed",
|
||||
created: 2,
|
||||
type: "session.execution.failed",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", error: { type: "unknown", message: "instructions unavailable" } },
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
idle.resolve()
|
||||
|
||||
await turn
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "error", messageID: "msg_prompt", text: "instructions unavailable" }),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("attributes an execution-only failure to the latest promoted prompt", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const idle = defer()
|
||||
const messages: SessionMessages = []
|
||||
const client = sdk({ streams: [events], messages: { ses_1: messages }, wait: () => idle.promise })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
let admitted = false
|
||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||
admitted = true
|
||||
return ok(promptAdmission(request)) as never
|
||||
})
|
||||
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
while (!admitted) await Bun.sleep(0)
|
||||
events.push({
|
||||
id: "evt_prompt_promoted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
await transport.queuePromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_queued", text: "follow up", parts: [] },
|
||||
files: [],
|
||||
includeFiles: false,
|
||||
})
|
||||
events.push({
|
||||
id: "evt_queued_promoted",
|
||||
created: 3,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 3),
|
||||
data: { sessionID: "ses_1", inputID: "msg_queued" },
|
||||
})
|
||||
events.push({
|
||||
id: "evt_failed",
|
||||
created: 4,
|
||||
type: "session.execution.failed",
|
||||
durable: durable("ses_1", 4),
|
||||
data: { sessionID: "ses_1", error: { type: "unknown", message: "model unavailable" } },
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
messages.push(
|
||||
{ id: "msg_prompt", type: "user", text: "hello", time: { created: 2 } },
|
||||
{ id: "msg_queued", type: "user", text: "follow up", time: { created: 3 } },
|
||||
)
|
||||
idle.resolve()
|
||||
|
||||
await turn
|
||||
expect(ui.commits).toContainEqual(
|
||||
expect.objectContaining({ kind: "error", messageID: "msg_queued", text: "model unavailable" }),
|
||||
)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("sends local file and directory mentions as structured prompt files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filePath = path.join(tmp.path, "note.ts")
|
||||
const contextPath = path.join(tmp.path, "context.txt")
|
||||
const directoryPath = path.join(tmp.path, "docs")
|
||||
await Bun.write(filePath, "export const answer = 42\n")
|
||||
await Bun.write(contextPath, "context body")
|
||||
await fs.mkdir(directoryPath)
|
||||
await Bun.write(path.join(directoryPath, "README.md"), "# hello\n")
|
||||
|
||||
|
|
@ -573,12 +815,16 @@ describe("V2 mini transport", () => {
|
|||
},
|
||||
],
|
||||
},
|
||||
files: [],
|
||||
files: [
|
||||
{ type: "file", url: pathToFileURL(contextPath).href, filename: "context.txt", mime: "text/plain" },
|
||||
{ type: "file", url: "file:///tmp/image.png", filename: "image.png", mime: "image/png" },
|
||||
],
|
||||
includeFiles: true,
|
||||
})
|
||||
|
||||
expect(request?.text).toBe("Review @note.ts and @docs")
|
||||
expect(request?.text).toBe('Review @note.ts and @docs\n\n<file name="context.txt">\ncontext body\n</file>')
|
||||
expect(request?.files).toEqual([
|
||||
{ uri: "file:///tmp/image.png", name: "image.png" },
|
||||
{
|
||||
uri: pathToFileURL(filePath).href,
|
||||
name: "note.ts",
|
||||
|
|
@ -788,11 +1034,12 @@ describe("V2 mini transport", () => {
|
|||
await transport.close()
|
||||
})
|
||||
|
||||
test("rebootstraps after disconnect and completes a promoted turn from idle active state", async () => {
|
||||
test("reconnects and hydrates without completing before session.wait", async () => {
|
||||
const first = feed()
|
||||
const second = feed()
|
||||
first.push(connected("evt_connected_1"))
|
||||
second.push(connected("evt_connected_2"))
|
||||
const idle = defer()
|
||||
let running = true
|
||||
const client = sdk({
|
||||
streams: [first, second],
|
||||
|
|
@ -801,6 +1048,7 @@ describe("V2 mini transport", () => {
|
|||
if (running) active.ses_1 = { type: "running" }
|
||||
return active
|
||||
},
|
||||
wait: () => idle.promise,
|
||||
})
|
||||
let projected = false
|
||||
spyOn(client.message, "list").mockImplementation(() =>
|
||||
|
|
@ -846,11 +1094,25 @@ describe("V2 mini transport", () => {
|
|||
while (!admitted) await Bun.sleep(0)
|
||||
projected = true
|
||||
running = false
|
||||
second.push({
|
||||
id: "evt_prior_failed",
|
||||
created: 1,
|
||||
type: "session.execution.failed",
|
||||
durable: durable("ses_1", 1),
|
||||
data: { sessionID: "ses_1", error: { type: "unknown", message: "prior execution failed" } },
|
||||
})
|
||||
second.push({
|
||||
id: "evt_prompted",
|
||||
created: 2,
|
||||
type: "session.input.promoted",
|
||||
durable: durable("ses_1", 2),
|
||||
data: { sessionID: "ses_1", inputID: "msg_prompt" },
|
||||
})
|
||||
first.close()
|
||||
while (!ui.events.some((event) => event.type === "stream.patch" && event.patch.status === "reconnecting"))
|
||||
await Bun.sleep(0)
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "running", status: "reconnecting" } })
|
||||
expect(ui.events).toContainEqual({ type: "stream.patch", patch: { phase: "idle", status: "" } })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
|
|
@ -1791,52 +2053,6 @@ describe("V2 mini transport", () => {
|
|||
await transport.close()
|
||||
})
|
||||
|
||||
test("resolves an interrupted turn even when promotion never arrived", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({
|
||||
streams: [events],
|
||||
active: () => ({ ses_1: { type: "running" } }),
|
||||
})
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
let admitted = false
|
||||
// The generated method has conditional return types for throwOnError; this mock represents the successful branch.
|
||||
// @ts-expect-error successful SDK response is valid for both modes at runtime
|
||||
spyOn(client.session, "prompt").mockImplementation((request) => {
|
||||
admitted = true
|
||||
return ok({ data: promptAdmission(request) })
|
||||
})
|
||||
const interrupted = spyOn(client.session, "interrupt").mockImplementation(() => ok(undefined))
|
||||
|
||||
const turn = transport.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: { messageID: "msg_prompt", text: "hello", parts: [] },
|
||||
files: [],
|
||||
includeFiles: true,
|
||||
})
|
||||
while (!admitted) await Bun.sleep(0)
|
||||
await transport.interruptActiveTurn()
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
})
|
||||
await turn
|
||||
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("falls back to the default model when selecting a variant on a fresh session", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
|
@ -1903,7 +2119,8 @@ describe("V2 mini transport", () => {
|
|||
test("interrupts the current Session when an active turn is aborted", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const idle = defer()
|
||||
const client = sdk({ streams: [events], wait: () => idle.promise })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
|
|
@ -1942,13 +2159,7 @@ describe("V2 mini transport", () => {
|
|||
})
|
||||
await Bun.sleep(0)
|
||||
controller.abort()
|
||||
events.push({
|
||||
id: "evt_settled",
|
||||
created: 0,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1", reason: "user" },
|
||||
})
|
||||
idle.resolve()
|
||||
await turn
|
||||
|
||||
expect(interrupted).toHaveBeenCalledWith({ sessionID: "ses_1" })
|
||||
|
|
@ -2393,10 +2604,19 @@ describe("V2 mini transport", () => {
|
|||
prompt: {
|
||||
messageID: "msg_cmd",
|
||||
text: "/deploy prod",
|
||||
parts: [],
|
||||
parts: [
|
||||
{
|
||||
type: "file",
|
||||
url: "file:///tmp/mentioned.txt",
|
||||
filename: "mentioned.txt",
|
||||
source: { type: "file", text: { start: 8, end: 12, value: "prod" } },
|
||||
},
|
||||
],
|
||||
command: { name: "deploy", arguments: "prod" },
|
||||
},
|
||||
files: [],
|
||||
files: [
|
||||
{ type: "file", url: "file:///tmp/context.txt", filename: "context.txt", mime: "text/plain" },
|
||||
],
|
||||
includeFiles: true,
|
||||
})
|
||||
|
||||
|
|
@ -2407,6 +2627,14 @@ describe("V2 mini transport", () => {
|
|||
arguments: "prod",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
files: [
|
||||
{ uri: "file:///tmp/context.txt", name: "context.txt" },
|
||||
{
|
||||
uri: "file:///tmp/mentioned.txt",
|
||||
name: "mentioned.txt",
|
||||
mention: { start: 8, end: 12, text: "prod" },
|
||||
},
|
||||
],
|
||||
delivery: "steer",
|
||||
})
|
||||
// Selection rides the command payload; no separate client-side switch.
|
||||
|
|
@ -2478,90 +2706,6 @@ describe("V2 mini transport", () => {
|
|||
await transport.close()
|
||||
})
|
||||
|
||||
test("does not resolve a skill turn before the matching activation is observed", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const client = sdk({ streams: [events] })
|
||||
const ui = footer()
|
||||
const transport = await createSessionTransport({
|
||||
sdk: client,
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: ui.api,
|
||||
})
|
||||
let sent = false
|
||||
spyOn(client.session, "skill").mockImplementation(() => {
|
||||
sent = true
|
||||
return ok(undefined) as never
|
||||
})
|
||||
|
||||
let done = false
|
||||
const turn = transport
|
||||
.runPromptTurn({
|
||||
agent: undefined,
|
||||
model: undefined,
|
||||
variant: undefined,
|
||||
prompt: {
|
||||
messageID: "msg_skill",
|
||||
text: "/tigerstyle",
|
||||
parts: [],
|
||||
command: { name: "tigerstyle", arguments: "", source: "skill" },
|
||||
},
|
||||
files: [],
|
||||
includeFiles: true,
|
||||
})
|
||||
.then(() => {
|
||||
done = true
|
||||
})
|
||||
while (!sent) await Bun.sleep(0)
|
||||
events.push({
|
||||
id: "evt_other",
|
||||
created: 0,
|
||||
type: "session.skill.activated",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
id: "other",
|
||||
name: "other",
|
||||
text: "other instructions",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_unrelated_settled",
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
await Bun.sleep(0)
|
||||
expect(done).toBe(false)
|
||||
|
||||
events.push({
|
||||
id: "evt_skill",
|
||||
created: 0,
|
||||
type: "session.skill.activated",
|
||||
durable: durable("ses_1"),
|
||||
data: {
|
||||
sessionID: "ses_1",
|
||||
id: "tigerstyle",
|
||||
name: "tigerstyle",
|
||||
text: "skill instructions",
|
||||
},
|
||||
})
|
||||
events.push({
|
||||
id: "evt_skill_settled",
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("ses_1"),
|
||||
data: { sessionID: "ses_1" },
|
||||
})
|
||||
await turn
|
||||
|
||||
expect(done).toBe(true)
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("refreshes catalogs on connection and location-scoped invalidations", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
|
@ -2845,6 +2989,14 @@ describe("V2 mini transport", () => {
|
|||
agents: [],
|
||||
time: { created: 1 },
|
||||
},
|
||||
{
|
||||
id: "msg_child_a",
|
||||
type: "assistant" as const,
|
||||
agent: "explore",
|
||||
model: { providerID: "test", id: "model" },
|
||||
content: [{ type: "text" as const, text: "child answer" }],
|
||||
time: { created: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
|
@ -2890,18 +3042,34 @@ describe("V2 mini transport", () => {
|
|||
{ sessionID: "ses_child", label: "Explore", title: "Find files", status: "running" },
|
||||
])
|
||||
|
||||
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
|
||||
|
||||
events.push({
|
||||
id: "evt_child_text",
|
||||
id: "evt_child_text_replayed",
|
||||
created: 0,
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child_a",
|
||||
ordinal: 0,
|
||||
delta: "child answer",
|
||||
delta: "answer",
|
||||
},
|
||||
})
|
||||
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer")))
|
||||
await Bun.sleep(0)
|
||||
expect(states().at(-1)?.details.ses_child?.commits.filter((item) => item.text === "child answer")).toHaveLength(1)
|
||||
|
||||
events.push({
|
||||
id: "evt_child_text_suffix",
|
||||
created: 0,
|
||||
type: "session.text.delta",
|
||||
data: {
|
||||
sessionID: "ses_child",
|
||||
assistantMessageID: "msg_child_a",
|
||||
ordinal: 0,
|
||||
delta: " suffix",
|
||||
},
|
||||
})
|
||||
while (!states().some((state) => state.details.ses_child?.commits.some((item) => item.text === "child answer suffix")))
|
||||
await Bun.sleep(0)
|
||||
|
||||
events.push({
|
||||
|
|
|
|||
|
|
@ -1,33 +1,10 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { writeSessionOutput } from "../../src/mini/stream"
|
||||
import type { FooterApi, FooterEvent, StreamCommit } from "../../src/mini/types"
|
||||
|
||||
function footer() {
|
||||
const events: FooterEvent[] = []
|
||||
const commits: StreamCommit[] = []
|
||||
|
||||
const api: FooterApi = {
|
||||
isClosed: false,
|
||||
onPrompt: () => () => {},
|
||||
onQueuedRemove: () => () => {},
|
||||
onClose: () => () => {},
|
||||
event: (next) => {
|
||||
events.push(next)
|
||||
},
|
||||
append: (next) => {
|
||||
commits.push(next)
|
||||
},
|
||||
idle: () => Promise.resolve(),
|
||||
close: () => {},
|
||||
destroy: () => {},
|
||||
}
|
||||
|
||||
return { api, events, commits }
|
||||
}
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
|
||||
describe("run stream bridge", () => {
|
||||
test("defaults status patches to running phase", () => {
|
||||
const out = footer()
|
||||
const out = createFooterApiFixture()
|
||||
|
||||
writeSessionOutput(
|
||||
{
|
||||
|
|
@ -35,11 +12,7 @@ describe("run stream bridge", () => {
|
|||
},
|
||||
{
|
||||
commits: [],
|
||||
footer: {
|
||||
patch: {
|
||||
status: "assistant responding",
|
||||
},
|
||||
},
|
||||
updates: [{ type: "stream.patch", patch: { status: "assistant responding" } }],
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -53,4 +26,28 @@ describe("run stream bridge", () => {
|
|||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("delivers commits before ordered footer updates", () => {
|
||||
const out = createFooterApiFixture()
|
||||
|
||||
writeSessionOutput(
|
||||
{ footer: out.api },
|
||||
{
|
||||
commits: [{ kind: "assistant", source: "assistant", text: "answer", phase: "progress" }],
|
||||
updates: [
|
||||
{ type: "stream.patch", patch: { phase: "idle", status: "" } },
|
||||
{ type: "stream.subagent", state: { tabs: [], details: {}, permissions: [], forms: [] } },
|
||||
{ type: "stream.view", view: { type: "prompt" } },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(out.calls.map((call) => (call.type === "commit" ? "commit" : call.value.type))).toEqual([
|
||||
"commit",
|
||||
"stream.patch",
|
||||
"stream.subagent",
|
||||
"stream.view",
|
||||
])
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { RGBA, type CliRenderer, type TerminalColors } from "@opentui/core"
|
||||
import { RUN_THEME_FALLBACK, generateSystem, resolveRunTheme, resolveTheme } from "../../src/mini/theme"
|
||||
import { DEFAULT_THEMES } from "../../src/theme"
|
||||
|
||||
const palette = ["#15161e", "#f7768e", "#9ece6a", "#e0af68", "#7aa2f7", "#bb9af7", "#7dcfff", "#c0caf5"] as const
|
||||
|
||||
|
|
@ -62,6 +63,18 @@ test("falls back when palette lookup fails", async () => {
|
|||
expect(await resolveRunTheme(renderer({ fail: true }))).toBe(RUN_THEME_FALLBACK)
|
||||
})
|
||||
|
||||
test("resolveTheme preserves Mini indexed color and result shape semantics", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.theme.primary = 6
|
||||
delete item.theme.selectedListItemText
|
||||
|
||||
const theme = resolveTheme(item, "dark")
|
||||
expect(theme.primary.intent).toBe("indexed")
|
||||
expect(theme.primary.slot).toBe(6)
|
||||
expect(theme.selectedListItemText).toBe(theme.background)
|
||||
expect("_hasSelectedListItemText" in theme).toBe(false)
|
||||
})
|
||||
|
||||
test("returns syntax styles and indexed splash colors", async () => {
|
||||
const theme = await resolveRunTheme(renderer({ themeMode: "dark" }))
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeTool, toolOutputText } from "../../src/mini/tool"
|
||||
import { normalizeTool, toolOutputText, toolPath } from "../../src/mini/tool"
|
||||
|
||||
describe("Mini tool presentation", () => {
|
||||
test("uses V2 shell output without the model-facing status", () => {
|
||||
|
|
@ -72,4 +72,9 @@ describe("Mini tool presentation", () => {
|
|||
}),
|
||||
).toMatchObject({ name: "subagent", state: { input: { agent: "explore" } } })
|
||||
})
|
||||
|
||||
test("keeps segment-safe contained tool paths relative", () => {
|
||||
expect(toolPath("..cache/result.txt", { directory: "/work/project" })).toBe("..cache/result.txt")
|
||||
expect(toolPath("../shared/result.txt", { directory: "/work/project" })).toBe("/work/shared/result.txt")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -23,12 +23,14 @@ const providers: RunProvider[] = [
|
|||
describe("run variant shared", () => {
|
||||
test("prefers cli then session then saved variants", () => {
|
||||
expect(resolveVariant("max", "high", "low", ["low", "high"])).toBe("max")
|
||||
expect(resolveVariant("default", "high", "low", ["low", "high"])).toBeUndefined()
|
||||
expect(resolveVariant(undefined, "high", "low", ["low", "high"])).toBe("high")
|
||||
expect(resolveVariant(undefined, "missing", "low", ["low", "high"])).toBe("low")
|
||||
})
|
||||
|
||||
test("cycles through variants and back to default", () => {
|
||||
expect(cycleVariant(undefined, ["low", "high"])).toBe("low")
|
||||
expect(cycleVariant("default", ["low", "high"])).toBe("low")
|
||||
expect(cycleVariant("low", ["low", "high"])).toBe("high")
|
||||
expect(cycleVariant("high", ["low", "high"])).toBeUndefined()
|
||||
expect(cycleVariant(undefined, [])).toBeUndefined()
|
||||
|
|
|
|||
45
packages/tui/test/model-preference.test.ts
Normal file
45
packages/tui/test/model-preference.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { createModelPreferenceRepository, decodeModelPreference } from "../src/model-preference"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("repairs known model preferences and preserves unrelated fields", () => {
|
||||
expect(
|
||||
decodeModelPreference({
|
||||
unrelated: { keep: true },
|
||||
recent: [{ providerID: "openai", modelID: "gpt-5", ignored: true }, null],
|
||||
favorite: "malformed",
|
||||
variant: { "openai/gpt-5": "high", default: "default", invalid: 42 },
|
||||
}),
|
||||
).toEqual({
|
||||
unrelated: { keep: true },
|
||||
recent: [{ providerID: "openai", modelID: "gpt-5" }],
|
||||
favorite: [],
|
||||
variant: { "openai/gpt-5": "high" },
|
||||
})
|
||||
})
|
||||
|
||||
test("atomically serializes patches and variant updates", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "model.json")
|
||||
await Bun.write(file, JSON.stringify({ unrelated: "keep", favorite: [], variant: {} }))
|
||||
const repository = createModelPreferenceRepository(file)
|
||||
const openai = { providerID: "openai", modelID: "org/gpt-5" }
|
||||
const anthropic = { providerID: "anthropic", modelID: "claude/sonnet" }
|
||||
|
||||
await Promise.all([
|
||||
repository.patch({ recent: [openai] }),
|
||||
repository.saveVariant(openai, "high"),
|
||||
repository.saveVariant(anthropic, "low"),
|
||||
])
|
||||
expect(await Bun.file(file).json()).toEqual({
|
||||
unrelated: "keep",
|
||||
recent: [openai],
|
||||
favorite: [],
|
||||
variant: { "openai/org/gpt-5": "high", "anthropic/claude/sonnet": "low" },
|
||||
})
|
||||
|
||||
await repository.saveVariant(openai, "default")
|
||||
expect(await repository.resolveVariant(openai)).toBeUndefined()
|
||||
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
|
||||
})
|
||||
61
packages/tui/test/prompt/codec.test.ts
Normal file
61
packages/tui/test/prompt/codec.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@opencode-ai/schema"
|
||||
import { projectedPromptInput } from "../../src/prompt/codec"
|
||||
|
||||
describe("prompt codec", () => {
|
||||
test("converts projected URI and inline attachments without mutation", () => {
|
||||
const input = {
|
||||
text: "Review @note.ts and image.png with @scan",
|
||||
files: [
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///tmp/note.ts" },
|
||||
name: "note.ts",
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
{
|
||||
data: "YWJj",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "image.png",
|
||||
description: "screenshot",
|
||||
},
|
||||
],
|
||||
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
|
||||
} satisfies Prompt
|
||||
const before = structuredClone(input)
|
||||
|
||||
const output = projectedPromptInput(input)
|
||||
|
||||
expect(output).toEqual({
|
||||
text: input.text,
|
||||
files: [
|
||||
{
|
||||
uri: "file:///tmp/note.ts",
|
||||
name: "note.ts",
|
||||
description: undefined,
|
||||
mention: { start: 7, end: 15, text: "@note.ts" },
|
||||
},
|
||||
{
|
||||
uri: "data:image/png;base64,YWJj",
|
||||
name: "image.png",
|
||||
description: "screenshot",
|
||||
mention: undefined,
|
||||
},
|
||||
],
|
||||
agents: [{ name: "scan", mention: { start: 35, end: 40, text: "@scan" } }],
|
||||
})
|
||||
expect(input).toEqual(before)
|
||||
expect(output.files?.[0]?.mention).not.toBe(input.files[0].mention)
|
||||
expect(output.agents?.[0]?.mention).not.toBe(input.agents[0].mention)
|
||||
})
|
||||
|
||||
test("retains empty attachment keys for editable prompt replacement", () => {
|
||||
expect(projectedPromptInput({ text: "plain" })).toEqual({
|
||||
text: "plain",
|
||||
files: undefined,
|
||||
agents: undefined,
|
||||
})
|
||||
})
|
||||
})
|
||||
91
packages/tui/test/prompt/mention.test.ts
Normal file
91
packages/tui/test/prompt/mention.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { PromptInput } from "@opencode-ai/schema"
|
||||
import {
|
||||
expandPromptInputPastedText,
|
||||
realignPromptInputMentions,
|
||||
realignPromptMentions,
|
||||
} from "../../src/prompt/mention"
|
||||
|
||||
test("realigns reordered, duplicate, deleted, and prefix-related mentions", () => {
|
||||
const mentions = [
|
||||
{ start: 0, end: 4, text: "@one" },
|
||||
{ start: 5, end: 10, text: "@same" },
|
||||
{ start: 11, end: 15, text: "@two" },
|
||||
{ start: 16, end: 21, text: "@same" },
|
||||
{ start: 22, end: 27, text: "@gone" },
|
||||
]
|
||||
const before = structuredClone(mentions)
|
||||
expect(realignPromptMentions("@two @same @one @same", mentions)).toEqual([
|
||||
{ start: 11, end: 15, text: "@one" },
|
||||
{ start: 5, end: 10, text: "@same" },
|
||||
{ start: 0, end: 4, text: "@two" },
|
||||
{ start: 16, end: 21, text: "@same" },
|
||||
undefined,
|
||||
])
|
||||
expect(mentions).toEqual(before)
|
||||
expect(
|
||||
realignPromptMentions("@foobar @foo", [
|
||||
{ start: 0, end: 4, text: "@foo" },
|
||||
{ start: 5, end: 12, text: "@foobar" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ start: 8, end: 12, text: "@foo" },
|
||||
{ start: 0, end: 7, text: "@foobar" },
|
||||
])
|
||||
expect(
|
||||
realignPromptMentions("@foobar @foobar", [
|
||||
{ start: 0, end: 4, text: "@foo" },
|
||||
{ start: 13, end: 20, text: "@foobar" },
|
||||
]),
|
||||
).toEqual([undefined, { start: 8, end: 15, text: "@foobar" }])
|
||||
expect(
|
||||
realignPromptMentions("@same @same", [
|
||||
{ start: 100, end: 105, text: "@same" },
|
||||
{ start: 4, end: 9, text: "@same" },
|
||||
]),
|
||||
).toEqual([
|
||||
{ start: 6, end: 11, text: "@same" },
|
||||
{ start: 0, end: 5, text: "@same" },
|
||||
])
|
||||
})
|
||||
|
||||
test("realigns mixed prompt attachments without mutation", () => {
|
||||
const input = {
|
||||
text: "@file @gone @agent",
|
||||
files: [
|
||||
{ uri: "file:///file", mention: { start: 0, end: 5, text: "@file" } },
|
||||
{ uri: "data:image/png;base64,YWJj", name: "image.png" },
|
||||
{ uri: "file:///gone", mention: { start: 6, end: 11, text: "@gone" } },
|
||||
],
|
||||
agents: [{ name: "agent", mention: { start: 12, end: 18, text: "@agent" } }],
|
||||
} satisfies PromptInput.Prompt
|
||||
const before = structuredClone(input)
|
||||
const output = realignPromptInputMentions("@agent then @file", input)
|
||||
expect(output).toEqual({
|
||||
text: "@agent then @file",
|
||||
files: [
|
||||
{ uri: "file:///file", mention: { start: 12, end: 17, text: "@file" } },
|
||||
{ uri: "data:image/png;base64,YWJj", name: "image.png", mention: undefined },
|
||||
],
|
||||
agents: [{ name: "agent", mention: { start: 0, end: 6, text: "@agent" } }],
|
||||
})
|
||||
expect(input).toEqual(before)
|
||||
expect(output.files).not.toBe(input.files)
|
||||
expect(output.agents).not.toBe(input.agents)
|
||||
})
|
||||
|
||||
test("shifts mention hints when pasted placeholders expand", () => {
|
||||
const input = {
|
||||
text: "[Pasted text #1] @same @same",
|
||||
files: [{ uri: "file:///same", mention: { start: 23, end: 28, text: "@same" } }],
|
||||
} satisfies PromptInput.Prompt
|
||||
const expanded = expandPromptInputPastedText(input, [
|
||||
{ text: "a much longer pasted value", source: { start: 0, end: 16 } },
|
||||
])
|
||||
expect(expanded.files?.[0]?.mention).toEqual({ start: 33, end: 38, text: "@same" })
|
||||
expect(realignPromptInputMentions(expanded.text, expanded).files?.[0]?.mention).toEqual({
|
||||
start: 33,
|
||||
end: 38,
|
||||
text: "@same",
|
||||
})
|
||||
})
|
||||
24
packages/tui/test/prompt/parse.test.ts
Normal file
24
packages/tui/test/prompt/parse.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { parseFileLineRange, parseSlashHead } from "../../src/prompt/parse"
|
||||
|
||||
test("preserves file line-range parsing semantics", () => {
|
||||
expect([
|
||||
parseFileLineRange("src/app.ts#12-20"),
|
||||
parseFileLineRange("src/app.ts#12-"),
|
||||
parseFileLineRange("src/app.ts#12-12"),
|
||||
parseFileLineRange("src/app.ts#bad"),
|
||||
parseFileLineRange("src/app.ts"),
|
||||
]).toEqual([
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: 20 } },
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
|
||||
{ base: "src/app.ts", lineRange: { startLine: 12, endLine: undefined } },
|
||||
{ base: "src/app.ts" },
|
||||
{ base: "src/app.ts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps frontend-specific slash separators", () => {
|
||||
expect(parseSlashHead("/editor\rfirst")).toEqual({ name: "editor\rfirst", arguments: "", end: 13 })
|
||||
expect(parseSlashHead("/editor\rfirst", /\s/)).toEqual({ name: "editor", arguments: "first", end: 7 })
|
||||
expect(parseSlashHead("editor")).toBeUndefined()
|
||||
})
|
||||
|
|
@ -45,6 +45,17 @@ test("resolveTheme rejects circular color refs", () => {
|
|||
expect(() => resolveTheme(item, "dark")).toThrow("Circular color reference")
|
||||
})
|
||||
|
||||
test("resolveTheme preserves full theme numeric color and marker semantics", () => {
|
||||
const item = structuredClone(DEFAULT_THEMES.opencode)
|
||||
item.theme.primary = 6
|
||||
delete item.theme.selectedListItemText
|
||||
|
||||
const theme = resolveTheme(item, "dark")
|
||||
expect(theme.primary.intent).toBe("rgb")
|
||||
expect(theme.selectedListItemText).toBe(theme.background)
|
||||
expect(theme._hasSelectedListItemText).toBe(false)
|
||||
})
|
||||
|
||||
function terminalColors(defaultBackground: string | null, palette: Array<string | null> = []): TerminalColors {
|
||||
return {
|
||||
palette,
|
||||
|
|
|
|||
|
|
@ -7,86 +7,66 @@ import { resolveTheme } from "../../../src/theme/v2/resolve"
|
|||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
import type { ContextKey } from "../../../src/theme/v2"
|
||||
|
||||
test("provides reactive property, variant, state, and context accessors", () => {
|
||||
test("provides reactive properties, states, contexts, and color operations", () => {
|
||||
const [resolved, setResolved] = createSignal(resolveTheme(selectTheme(DEFAULT_THEME, "light")))
|
||||
const [mode, setMode] = createSignal<"light" | "dark">("light")
|
||||
const [context, setContext] = createSignal<ContextKey>()
|
||||
const theme = createComponentTheme(() => {
|
||||
const key = context()
|
||||
return key ? resolved().contexts[key] ?? resolved() : resolved()
|
||||
return key ? (resolved().contexts[key] ?? resolved()) : resolved()
|
||||
}, mode)
|
||||
|
||||
expect(theme.text()).toBe(resolved().text.default)
|
||||
expect(theme.hue.accent(500)).toBe(resolved().hue.accent[500])
|
||||
expect(theme.hue.interactive(500)).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray(200)).toBe(resolved().hue.gray[200])
|
||||
expect(theme.increase(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[300])
|
||||
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[300])
|
||||
expect(theme.decrease(theme.hue.red(300), 2)).toBe(resolved().hue.red[100])
|
||||
expect(theme.increase(theme.hue.red(900), 3)).toBe(resolved().hue.red[900])
|
||||
expect(theme.decrease(theme.hue.red(100), 3)).toBe(resolved().hue.red[100])
|
||||
expect(theme.source(theme.background.surface.offset())).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(theme.text.default).toBe(resolved().text.default)
|
||||
expect(theme.hue.accent[500]).toBe(resolved().hue.accent[500])
|
||||
expect(theme.hue.interactive[500]).toBe(resolved().hue.interactive[500])
|
||||
expect(theme.hue.gray[200]).toBe(resolved().hue.gray[200])
|
||||
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.increase(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[400])
|
||||
expect(theme.decrease(theme.hue.red[300], 2)).toBe(resolved().hue.red[100])
|
||||
expect(theme.increase(theme.hue.red[900], 3)).toBe(resolved().hue.red[900])
|
||||
expect(theme.decrease(theme.hue.red[100], 3)).toBe(resolved().hue.red[100])
|
||||
expect(theme.source(theme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||
const equivalent = RGBA.fromInts(...resolved().hue.green[500].toInts())
|
||||
expect(theme.source(equivalent)).toBeUndefined()
|
||||
expect(theme.increase(equivalent, 1)).toBe(equivalent)
|
||||
const unmatched = RGBA.fromInts(1, 2, 3)
|
||||
expect(theme.increase(unmatched, 1)).toBe(unmatched)
|
||||
expect(theme.text.subdued()).toBe(resolved().text.subdued)
|
||||
expect(theme.text.action()).toBe(resolved().text.action.primary.default)
|
||||
expect(theme.text.action("hovered")).toBe(resolved().text.action.primary.hovered)
|
||||
expect(theme.text.action("pressed")).toBe(resolved().text.action.primary.pressed)
|
||||
expect(theme.text.action("selected")).toBe(resolved().text.action.primary.selected)
|
||||
expect(theme.background.action("selected")).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action("hovered")).toBe(resolved().background.action.primary.hovered)
|
||||
expect(theme.background.action({ selected: true })).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action({ selected: true, hovered: true })).toBe(
|
||||
resolved().background.action.primary.selected,
|
||||
)
|
||||
expect(theme.background.action({ focused: true, selected: true })).toBe(
|
||||
resolved().background.action.primary.focused,
|
||||
)
|
||||
expect(theme.background.action({ pressed: true, focused: true, selected: true })).toBe(
|
||||
resolved().background.action.primary.pressed,
|
||||
)
|
||||
expect(
|
||||
theme.background.action({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
|
||||
).toBe(
|
||||
resolved().background.action.primary.disabled,
|
||||
)
|
||||
expect(theme.background.action({ disabled: false, selected: false })).toBe(
|
||||
resolved().background.action.primary.default,
|
||||
)
|
||||
expect(theme.background.action.destructive("disabled")).toBe(
|
||||
resolved().background.action.destructive.disabled,
|
||||
)
|
||||
expect(theme.background.formfield("hovered")).toBe(resolved().background.formfield.hovered)
|
||||
expect(theme.background.formfield({ selected: true, hovered: true })).toBe(
|
||||
resolved().background.formfield.selected,
|
||||
)
|
||||
expect(theme.background.formfield({ focused: true, selected: true, hovered: true })).toBe(
|
||||
resolved().background.formfield.focused,
|
||||
)
|
||||
expect(
|
||||
theme.background.formfield({ disabled: true, pressed: true, focused: true, selected: true, hovered: true }),
|
||||
).toBe(resolved().background.formfield.disabled)
|
||||
expect(theme.background.surface.offset()).toBe(resolved().background.surface.offset)
|
||||
expect(theme.background.surface.overlay()).toBe(resolved().background.surface.overlay)
|
||||
expect(theme.scrollbar()).toBe(resolved().scrollbar.default)
|
||||
expect(theme.diff.text.added()).toBe(resolved().diff.text.added)
|
||||
expect(theme.text.subdued).toBe(resolved().text.subdued)
|
||||
expect(theme.text.action.primary.default).toBe(resolved().text.action.primary.default)
|
||||
expect(theme.text.action.primary.hovered).toBe(resolved().text.action.primary.hovered)
|
||||
expect(theme.text.action.primary.pressed).toBe(resolved().text.action.primary.pressed)
|
||||
expect(theme.text.action.primary.selected).toBe(resolved().text.action.primary.selected)
|
||||
expect(theme.background.action.primary.selected).toBe(resolved().background.action.primary.selected)
|
||||
expect(theme.background.action.primary.hovered).toBe(resolved().background.action.primary.hovered)
|
||||
expect(theme.background.action.primary.focused).toBe(resolved().background.action.primary.focused)
|
||||
expect(theme.background.action.primary.pressed).toBe(resolved().background.action.primary.pressed)
|
||||
expect(theme.background.action.primary.disabled).toBe(resolved().background.action.primary.disabled)
|
||||
expect(theme.background.action.primary.default).toBe(resolved().background.action.primary.default)
|
||||
expect(theme.background.action.destructive.disabled).toBe(resolved().background.action.destructive.disabled)
|
||||
expect(theme.background.formfield.hovered).toBe(resolved().background.formfield.hovered)
|
||||
expect(theme.background.formfield.selected).toBe(resolved().background.formfield.selected)
|
||||
expect(theme.background.formfield.focused).toBe(resolved().background.formfield.focused)
|
||||
expect(theme.background.formfield.disabled).toBe(resolved().background.formfield.disabled)
|
||||
expect(theme.background.surface.offset).toBe(resolved().background.surface.offset)
|
||||
expect(theme.background.surface.overlay).toBe(resolved().background.surface.overlay)
|
||||
expect(theme.scrollbar.default).toBe(resolved().scrollbar.default)
|
||||
expect(theme.diff.text.added).toBe(resolved().diff.text.added)
|
||||
|
||||
setContext("@context:elevated")
|
||||
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||
expect(theme.background.action("focused")).toBe(
|
||||
expect(theme.categorical.map((scale) => scale[500])).toEqual(resolved().categorical.map((scale) => scale[500]))
|
||||
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||
expect(theme.background.action.primary.focused).toBe(
|
||||
resolved().contexts["@context:elevated"]!.background.action.primary.focused,
|
||||
)
|
||||
expect(theme.background.action("hovered")).toBe(resolved().background.surface.overlay)
|
||||
expect(theme.background.formfield("selected")).toBe(
|
||||
expect(theme.background.action.primary.hovered).toBe(resolved().background.surface.overlay)
|
||||
expect(theme.background.formfield.selected).toBe(
|
||||
resolved().contexts["@context:elevated"]!.background.formfield.selected,
|
||||
)
|
||||
|
||||
setResolved(resolveTheme(selectTheme(DEFAULT_THEME, "dark")))
|
||||
setMode("dark")
|
||||
expect(theme.text()).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||
expect(theme.decrease(theme.background.surface.offset(), 1)).toBe(resolved().hue.neutral[700])
|
||||
expect(theme.raise(theme.background.surface.offset())).toBe(resolved().hue.neutral[700])
|
||||
expect(theme.text.default).toBe(resolved().contexts["@context:elevated"]!.text.default)
|
||||
expect(theme.decrease(theme.background.surface.offset, 1)).toBe(resolved().hue.neutral[600])
|
||||
expect(theme.raise(theme.background.surface.offset)).toBe(resolved().hue.neutral[600])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,45 @@ import { selectTheme } from "../../../src/theme/v2/select"
|
|||
const light = selectTheme(DEFAULT_THEME, "light")
|
||||
const dark = selectTheme(DEFAULT_THEME, "dark")
|
||||
|
||||
test("resolves one-mode files with defaults for the available mode", () => {
|
||||
const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark")
|
||||
const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light")
|
||||
|
||||
expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue()
|
||||
expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue()
|
||||
expect(resolvedLight.categorical.length).toBeGreaterThan(0)
|
||||
expect(resolvedDark.categorical.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("rejects theme files without a mode", () => {
|
||||
// @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant.
|
||||
expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("validates and resolves categorical hues in configured order", () => {
|
||||
const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light")
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.accent)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.red)
|
||||
expect(theme.categorical[2]).toBe(theme.hue.interactive)
|
||||
expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical)
|
||||
expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme")
|
||||
expect(() =>
|
||||
resolveThemeFile(
|
||||
// @ts-expect-error Runtime decoding rejects unknown categorical hue names.
|
||||
{ version: 2, light: { categorical: ["magenta"] } },
|
||||
"light",
|
||||
),
|
||||
).toThrow("Invalid theme")
|
||||
})
|
||||
|
||||
test("uses the default categorical order for direct definitions", () => {
|
||||
const theme = resolveTheme({ ...light, categorical: undefined })
|
||||
|
||||
expect(theme.categorical[0]).toBe(theme.hue.blue)
|
||||
expect(theme.categorical[1]).toBe(theme.hue.purple)
|
||||
})
|
||||
|
||||
test("resolves independent definitions and hue aliases", () => {
|
||||
const lightTheme = resolveTheme(light)
|
||||
const darkTheme = resolveTheme(dark)
|
||||
|
|
@ -18,46 +57,35 @@ test("resolves independent definitions and hue aliases", () => {
|
|||
expect(lightTheme.hue.interactive[500].equals(lightTheme.hue.blue[500])).toBeTrue()
|
||||
expect(lightTheme.hue.neutral).not.toBe(lightTheme.hue.gray)
|
||||
expect(lightTheme.hue.neutral[500].equals(lightTheme.hue.gray[500])).toBeTrue()
|
||||
expect(lightTheme.categorical[0]).toBe(lightTheme.hue.blue)
|
||||
expect(lightTheme.source(lightTheme.hue.blue[500])).toEqual({ hue: "blue", step: 500 })
|
||||
expect(lightTheme.source(lightTheme.hue.neutral[200])).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 200 })
|
||||
expect(lightTheme.source(lightTheme.background.surface.offset)).toEqual({ hue: "neutral", step: 300 })
|
||||
expect(lightTheme.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.decrease(lightTheme.hue.red[200])).toBe(lightTheme.hue.red[100])
|
||||
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(
|
||||
lightTheme.hue.red[200],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:elevated"]?.increase(lightTheme.hue.red[100])).toBe(lightTheme.hue.red[200])
|
||||
expect(lightTheme.text.default).toBeInstanceOf(RGBA)
|
||||
expect(darkTheme.background.default).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[200])
|
||||
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.surface.offset).toBe(lightTheme.hue.neutral[300])
|
||||
expect(lightTheme.background.surface.overlay).toBe(lightTheme.hue.neutral[400])
|
||||
expect(lightTheme.syntax.keyword).toBeInstanceOf(RGBA)
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(lightTheme.text.action.primary.default).toBe(lightTheme.hue.neutral[200])
|
||||
expect(lightTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||
lightTheme.hue.interactive[500],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:elevated"]?.background.default).toBe(lightTheme.background.surface.offset)
|
||||
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(lightTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
||||
lightTheme.hue.interactive[500],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:overlay"]?.background.default).toBe(lightTheme.background.surface.overlay)
|
||||
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
||||
lightTheme.hue.neutral[100],
|
||||
)
|
||||
expect(lightTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(lightTheme.hue.neutral[100])
|
||||
expect(darkTheme.contexts["@context:elevated"]?.background.action.primary.default).toBe(
|
||||
darkTheme.hue.interactive[400],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(
|
||||
darkTheme.hue.neutral[100],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(
|
||||
darkTheme.hue.interactive[400],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(
|
||||
darkTheme.hue.neutral[900],
|
||||
)
|
||||
expect(darkTheme.contexts["@context:elevated"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||
expect(darkTheme.contexts["@context:overlay"]?.background.action.primary.default).toBe(darkTheme.hue.interactive[400])
|
||||
expect(darkTheme.contexts["@context:overlay"]?.text.action.primary.default).toBe(darkTheme.hue.neutral[200])
|
||||
})
|
||||
|
||||
test("resolves base hue aliases and rejects circular hue aliases", () => {
|
||||
|
|
@ -65,10 +93,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => {
|
|||
...light,
|
||||
hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" },
|
||||
})
|
||||
const overridden = resolveThemeFile(
|
||||
{ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} },
|
||||
"light",
|
||||
)
|
||||
const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light")
|
||||
|
||||
expect(aliased.hue.blue).not.toBe(aliased.hue.red)
|
||||
expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue()
|
||||
|
|
@ -195,9 +220,7 @@ test("resolves elevated hover surfaces from direct colors", () => {
|
|||
)
|
||||
|
||||
expect(theme.contexts["@context:elevated"]?.background.default.toInts()).toEqual([18, 52, 86, 255])
|
||||
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([
|
||||
35, 69, 103, 255,
|
||||
])
|
||||
expect(theme.contexts["@context:elevated"]?.background.action.primary.hovered.toInts()).toEqual([35, 69, 103, 255])
|
||||
})
|
||||
|
||||
test("resolves transparent colors", () => {
|
||||
|
|
@ -268,12 +291,10 @@ test("rejects missing, base, and contextual reference cycles", () => {
|
|||
|
||||
test("validates complete hues, resolved groups, and hue-only syntax", () => {
|
||||
expect(() =>
|
||||
resolveTheme(
|
||||
{
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition,
|
||||
),
|
||||
resolveTheme({
|
||||
...light,
|
||||
hue: { ...light.hue, accent: "$hue.missing" },
|
||||
} as unknown as ThemeDefinition),
|
||||
).toThrow("$hue.missing")
|
||||
expect(() =>
|
||||
resolveTheme({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2"
|
||||
import { selectTheme, selectThemeMode } from "../../../src/theme/v2/select"
|
||||
import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||
|
||||
const hue = {} as HueDefinition
|
||||
const light = { hue, text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||
const dark = { hue, text: { default: "#eeeeee", subdued: "#dddddd" } } satisfies ThemeDefinition
|
||||
const light = { hue, categorical: ["blue"], text: { default: "#111111", subdued: "#222222" } } satisfies ThemeDefinition
|
||||
const dark = {
|
||||
hue,
|
||||
categorical: ["purple"],
|
||||
text: { default: "#eeeeee", subdued: "#dddddd" },
|
||||
} satisfies ThemeDefinition
|
||||
|
||||
test("requires and selects independent light and dark themes", () => {
|
||||
const file = { version: 2, light, dark } satisfies ThemeFile
|
||||
|
|
@ -27,6 +31,36 @@ test("merges an expanded mode override over the other mode", () => {
|
|||
expect(selected.text?.subdued).toBe("$text.default")
|
||||
})
|
||||
|
||||
test("replaces categorical order in a merge mode", () => {
|
||||
const selected = selectTheme(
|
||||
{ version: 2, light, dark: { mergeMode: true, categorical: ["accent", "cyan"] } },
|
||||
"dark",
|
||||
)
|
||||
|
||||
expect(selected.categorical).toEqual(["accent", "cyan"])
|
||||
})
|
||||
|
||||
test("selects the available mode when the requested mode is missing", () => {
|
||||
const lightOnly = { version: 2, light } satisfies ThemeFile
|
||||
const darkOnly = { version: 2, dark } satisfies ThemeFile
|
||||
|
||||
expect(themeModes(lightOnly)).toEqual(["light"])
|
||||
expect(themeModes(darkOnly)).toEqual(["dark"])
|
||||
expect(supportsThemeMode(lightOnly, "light")).toBeTrue()
|
||||
expect(supportsThemeMode(lightOnly, "dark")).toBeFalse()
|
||||
expect(selectThemeMode(lightOnly, "dark")).toEqual({ theme: light, mode: "light", expanded: false })
|
||||
expect(selectThemeMode(darkOnly, "light")).toEqual({ theme: dark, mode: "dark", expanded: false })
|
||||
})
|
||||
|
||||
test("rejects a merge mode without its base mode", () => {
|
||||
expect(() => selectThemeMode({ version: 2, light: { mergeMode: true } })).toThrow(
|
||||
"light theme cannot merge without a dark theme",
|
||||
)
|
||||
expect(() => selectThemeMode({ version: 2, dark: { mergeMode: true } })).toThrow(
|
||||
"dark theme cannot merge without a light theme",
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects mutual mode merging", () => {
|
||||
const file = {
|
||||
version: 2,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ const background = {
|
|||
|
||||
const definition = {
|
||||
hue: {} as ThemeDefinition["hue"],
|
||||
categorical: ["blue", "accent"],
|
||||
text,
|
||||
background,
|
||||
border: { default: "$hue.neutral.300" },
|
||||
|
|
@ -51,6 +52,10 @@ const definition = {
|
|||
} satisfies ThemeDefinition
|
||||
|
||||
const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile
|
||||
const lightOnly = { version: 2, light: definition } satisfies ThemeFile
|
||||
const darkOnly = { version: 2, dark: definition } satisfies ThemeFile
|
||||
// @ts-expect-error A theme file must provide at least one mode.
|
||||
const empty = { version: 2 } satisfies ThemeFile
|
||||
|
||||
test("supports property-first definitions, variants, states, and contexts", () => {
|
||||
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
|
||||
|
|
@ -62,5 +67,9 @@ test("supports property-first definitions, variants, states, and contexts", () =
|
|||
expect(background.surface.offset).toBe("$hue.neutral.200")
|
||||
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
|
||||
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
|
||||
expect(definition.categorical).toEqual(["blue", "accent"])
|
||||
expect(file.light).toBe(definition)
|
||||
expect(lightOnly.light).toBe(definition)
|
||||
expect(darkOnly.dark).toBe(definition)
|
||||
expect(empty.version).toBe(2)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,57 +1,56 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme"
|
||||
import { resolveThemeFile } from "../../../src/theme/v2/resolve"
|
||||
import { selectThemeMode, themeModes } from "../../../src/theme/v2/select"
|
||||
import { migrateV1 } from "../../../src/theme/v2/v1-migrate"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
|
||||
test("migrates resolved V1 modes into literal V2 tokens", () => {
|
||||
const migrated = migrateV1(DEFAULT_THEMES.opencode)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
const legacy = resolveV1(DEFAULT_THEMES.opencode, "light")
|
||||
const resolved = resolveThemeFile(migrated, "light")
|
||||
|
||||
expect(migrated.standalone).toBeTrue()
|
||||
expect(migrated.light.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.dark.categorical?.length).toBeGreaterThan(0)
|
||||
expect(migrated.light.hue?.accent).toBeObject()
|
||||
expect(migrated.light.hue?.interactive).toBeObject()
|
||||
if (typeof migrated.light.hue?.accent !== "object" || typeof migrated.light.hue.interactive !== "object") {
|
||||
throw new Error("Expected concrete accent and interactive scales")
|
||||
}
|
||||
expect(migrated.light.hue.accent[900]).toBe(hex(legacy.accent))
|
||||
expect(migrated.light.hue.interactive[900]).toBe(hex(legacy.primary))
|
||||
expect(migrated.light.text?.default).toBe("$hue.neutral.900")
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.700")
|
||||
expect(migrated.light.hue.accent[800]).toBe(hex(legacy.accent))
|
||||
expect(migrated.light.hue.interactive[800]).toBe(hex(legacy.primary))
|
||||
expect(migrated.light.text?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.light.text?.subdued).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.background?.action?.primary?.default).toBe("transparent")
|
||||
expect(migrated.light.background?.default).toBe("$hue.neutral.100")
|
||||
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.200")
|
||||
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.300")
|
||||
expect(migrated.dark.background?.default).toBe("$hue.neutral.900")
|
||||
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.800")
|
||||
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.700")
|
||||
expect(migrated.light.background?.default).toBe("$hue.neutral.200")
|
||||
expect(migrated.light.background?.surface?.offset).toBe("$hue.neutral.300")
|
||||
expect(migrated.light.background?.surface?.overlay).toBe("$hue.neutral.400")
|
||||
expect(migrated.dark.background?.default).toBe("$hue.neutral.800")
|
||||
expect(migrated.dark.background?.surface?.offset).toBe("$hue.neutral.700")
|
||||
expect(migrated.dark.background?.surface?.overlay).toBe("$hue.neutral.600")
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.light.background?.action?.primary?.$selected).toBe("$hue.interactive.900")
|
||||
expect(migrated.light.background?.action?.primary?.$selected).toBe("transparent")
|
||||
expect(migrated.light.scrollbar?.default).toBe(hex(legacy.borderActive))
|
||||
expect(migrated.light.diff?.lineNumber?.background?.removed).toBe(hex(legacy.diffRemovedLineNumberBg))
|
||||
expect(migrated.light.markdown?.emphasis).toBe(hex(legacy.markdownEmph))
|
||||
expect(resolved.background.surface.offset.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundMenu.toInts())
|
||||
expect(resolved.background.surface.overlay.toInts()).toEqual(legacy.backgroundElement.toInts())
|
||||
expect(resolved.background.formfield.selected.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.background.formfield.focused.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.text.formfield.default.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.text.formfield.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.text.formfield.focused.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.hue.accent[900].toInts()).toEqual(legacy.accent.toInts())
|
||||
expect(resolved.hue.interactive[900].toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.background.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.hue.accent[800].toInts()).toEqual(legacy.accent.toInts())
|
||||
expect(resolved.hue.interactive[800].toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.background.action.primary.selected.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.text.action.primary.selected.toInts()).toEqual(legacy.primary.toInts())
|
||||
expect(resolved.background.feedback.error.default.toInts()).toEqual(legacy.background.toInts())
|
||||
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(
|
||||
legacy.backgroundPanel.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:elevated"]?.background.default.toInts()).toEqual(legacy.backgroundPanel.toInts())
|
||||
expect(resolved.contexts["@context:elevated"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(
|
||||
legacy.text.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(
|
||||
legacy.backgroundMenu.toInts(),
|
||||
)
|
||||
expect(resolved.contexts["@context:elevated"]?.text.action.primary.default.toInts()).toEqual(legacy.text.toInts())
|
||||
expect(resolved.contexts["@context:overlay"]?.background.default.toInts()).toEqual(legacy.backgroundMenu.toInts())
|
||||
expect(resolved.contexts["@context:overlay"]?.background.action.primary.default.toInts()).toEqual([0, 0, 0, 0])
|
||||
})
|
||||
|
||||
|
|
@ -67,12 +66,15 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
|||
source.theme.success = { light: "#ff6666", dark: "#450000" }
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
const lightRed = migrated.light.hue?.red
|
||||
const darkRed = migrated.dark.hue?.red
|
||||
if (typeof lightRed !== "object" || typeof darkRed !== "object") throw new Error("Expected generated red scales")
|
||||
|
||||
expect(lightRed[900]).toBe("#ff6666")
|
||||
expect(darkRed[100]).toBe("#450000")
|
||||
expect(lightRed[800]).toBe("#ff6666")
|
||||
expect(darkRed[200]).toBe("#450000")
|
||||
expect(lightRed[900]).not.toBe(lightRed[800])
|
||||
expect(darkRed[100]).not.toBe(darkRed[200])
|
||||
expect(migrated.light.hue?.orange).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.yellow).toBe("$hue.gray")
|
||||
expect(migrated.light.hue?.green).toBe("$hue.gray")
|
||||
|
|
@ -85,32 +87,78 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou
|
|||
expect(() => resolveThemeFile(migrated, "dark")).not.toThrow()
|
||||
})
|
||||
|
||||
test("builds gray from V1 surfaces and text without using menus or borders", () => {
|
||||
test("orders categorical hues by V1 semantic color mapping", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
const mapped = (name: "red" | "orange" | "yellow" | "green" | "blue" | "purple") => ({
|
||||
light: DEFAULT_THEME.light.hue[name][700],
|
||||
dark: DEFAULT_THEME.dark.hue[name][300],
|
||||
})
|
||||
source.theme.secondary = mapped("purple")
|
||||
source.theme.accent = mapped("orange")
|
||||
source.theme.success = mapped("green")
|
||||
source.theme.warning = mapped("yellow")
|
||||
source.theme.primary = mapped("blue")
|
||||
source.theme.error = mapped("red")
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
expect(migrated.dark?.categorical).toEqual(["purple", "orange", "green", "yellow", "blue", "red"])
|
||||
|
||||
source.theme.accent = source.theme.secondary
|
||||
expect(migrateV1(source).light?.categorical).toEqual(["purple", "green", "yellow", "blue", "red"])
|
||||
})
|
||||
|
||||
test("uses default categorical hues when V1 semantic colors are ambiguous", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.secondary = "transparent"
|
||||
source.theme.accent = "transparent"
|
||||
source.theme.success = "transparent"
|
||||
source.theme.warning = "transparent"
|
||||
source.theme.primary = "transparent"
|
||||
source.theme.error = "transparent"
|
||||
|
||||
const migrated = migrateV1(source)
|
||||
expect(migrated.light?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
expect(migrated.dark?.categorical).toEqual(DEFAULT_CATEGORICAL)
|
||||
})
|
||||
|
||||
test("builds and extrapolates gray from V1 surfaces and text without using menus or borders", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.background = { light: "#eeeeee", dark: "#111111" }
|
||||
source.theme.backgroundPanel = { light: "#dddddd", dark: "#222222" }
|
||||
source.theme.backgroundElement = { light: "#cccccc", dark: "#333333" }
|
||||
source.theme.textMuted = { light: "#777777", dark: "#999999" }
|
||||
source.theme.text = { light: "#333333", dark: "#dddddd" }
|
||||
source.theme.backgroundMenu = { light: "#ededed", dark: "#252525" }
|
||||
const light = resolveV1(source, "light")
|
||||
const dark = resolveV1(source, "dark")
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
const lightGray = migrated.light.hue?.gray
|
||||
const darkGray = migrated.dark.hue?.gray
|
||||
if (typeof lightGray !== "object" || typeof darkGray !== "object") throw new Error("Expected concrete gray scales")
|
||||
|
||||
expect(lightGray[100]).toBe(hex(light.background))
|
||||
expect(lightGray[200]).toBe(hex(light.backgroundPanel))
|
||||
expect(lightGray[300]).toBe(hex(light.backgroundElement))
|
||||
expect(lightGray[700]).toBe(hex(light.textMuted))
|
||||
expect(lightGray[900]).toBe(hex(light.text))
|
||||
expect(darkGray[100]).toBe(hex(dark.text))
|
||||
expect(darkGray[300]).toBe(hex(dark.textMuted))
|
||||
expect(darkGray[700]).toBe(hex(dark.backgroundElement))
|
||||
expect(darkGray[800]).toBe(hex(dark.backgroundPanel))
|
||||
expect(darkGray[900]).toBe(hex(dark.background))
|
||||
expect(lightGray[100]).not.toBe(lightGray[200])
|
||||
expect(lightGray[200]).toBe(hex(light.background))
|
||||
expect(lightGray[300]).toBe(hex(light.backgroundPanel))
|
||||
expect(lightGray[400]).toBe(hex(light.backgroundElement))
|
||||
expect(lightGray[600]).toBe(hex(light.textMuted))
|
||||
expect(lightGray[800]).toBe(hex(light.text))
|
||||
expect(lightGray[900]).not.toBe(lightGray[800])
|
||||
expect(darkGray[100]).not.toBe(darkGray[200])
|
||||
expect(darkGray[200]).toBe(hex(dark.text))
|
||||
expect(darkGray[400]).toBe(hex(dark.textMuted))
|
||||
expect(darkGray[600]).toBe(hex(dark.backgroundElement))
|
||||
expect(darkGray[700]).toBe(hex(dark.backgroundPanel))
|
||||
expect(darkGray[800]).toBe(hex(dark.background))
|
||||
expect(darkGray[900]).not.toBe(darkGray[800])
|
||||
|
||||
source.theme.borderSubtle = "#ff00ff"
|
||||
source.theme.border = "#00ff00"
|
||||
source.theme.borderActive = "#00ffff"
|
||||
expect(migrateV1(source).light.hue?.gray).toEqual(lightGray)
|
||||
expect(migrateV1(source).dark.hue?.gray).toEqual(darkGray)
|
||||
const withBorders = migrateV1(source)
|
||||
expect(withBorders.light?.hue?.gray).toEqual(lightGray)
|
||||
expect(withBorders.dark?.hue?.gray).toEqual(darkGray)
|
||||
})
|
||||
|
||||
test("uses the default text reference for primary actions on transparent backgrounds", () => {
|
||||
|
|
@ -119,6 +167,7 @@ test("uses the default text reference for primary actions on transparent backgro
|
|||
source.theme.primary = { light: "#ffffff", dark: "#000000" }
|
||||
delete source.theme.selectedListItemText
|
||||
const migrated = migrateV1(source)
|
||||
if (!migrated.light || !migrated.dark) throw new Error("Expected both modes")
|
||||
|
||||
expect(migrated.light.text?.action?.primary?.default).toBe("$text.default")
|
||||
expect(migrated.dark.text?.action?.primary?.default).toBe("$text.default")
|
||||
|
|
@ -132,14 +181,44 @@ test("retains V1 circular reference errors", () => {
|
|||
expect(() => migrateV1(source)).toThrow("Circular color reference: one -> two -> one")
|
||||
})
|
||||
|
||||
test("migrates every built-in V1 theme in both modes", () => {
|
||||
test("migrates every built-in V1 theme in its supported modes", () => {
|
||||
for (const source of Object.values(DEFAULT_THEMES)) {
|
||||
const migrated = migrateV1(source)
|
||||
expect(resolveThemeFile(migrated, "light").text.default).toBeDefined()
|
||||
expect(resolveThemeFile(migrated, "dark").text.default).toBeDefined()
|
||||
for (const mode of themeModes(migrated)) {
|
||||
expect(resolveThemeFile(migrated, mode).text.default).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("collapses identical V1 backgrounds when both variants infer one mode", () => {
|
||||
const dark = structuredClone(DEFAULT_THEMES.opencode)
|
||||
dark.theme.background = "#111111"
|
||||
dark.theme.text = "#eeeeee"
|
||||
const migratedDark = migrateV1(dark)
|
||||
expect(migratedDark.light).toBeUndefined()
|
||||
expect(migratedDark.dark).toBeDefined()
|
||||
expect(themeModes(migratedDark)).toEqual(["dark"])
|
||||
expect(selectThemeMode(migratedDark, "light").mode).toBe("dark")
|
||||
|
||||
const light = structuredClone(DEFAULT_THEMES.opencode)
|
||||
light.theme.background = "#eeeeee"
|
||||
light.theme.text = "#111111"
|
||||
const migratedLight = migrateV1(light)
|
||||
expect(migratedLight.light).toBeDefined()
|
||||
expect(migratedLight.dark).toBeUndefined()
|
||||
expect(themeModes(migratedLight)).toEqual(["light"])
|
||||
expect(selectThemeMode(migratedLight, "dark").mode).toBe("light")
|
||||
})
|
||||
|
||||
test("keeps both modes when a shared background has different contrast", () => {
|
||||
const source = structuredClone(DEFAULT_THEMES.opencode)
|
||||
source.theme.background = "#808080"
|
||||
source.theme.text = { light: "#111111", dark: "#eeeeee" }
|
||||
const migrated = migrateV1(source)
|
||||
|
||||
expect(themeModes(migrated)).toEqual(["light", "dark"])
|
||||
})
|
||||
|
||||
function hex(color: { toInts(): [number, number, number, number] }) {
|
||||
const [r, g, b, a] = color.toInts()
|
||||
const byte = (value: number) => value.toString(16).padStart(2, "0")
|
||||
|
|
|
|||
35
packages/tui/test/ui/select-controller.test.ts
Normal file
35
packages/tui/test/ui/select-controller.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
moveSelection,
|
||||
moveSelectionOffset,
|
||||
reconcileSelection,
|
||||
revealSelectionOffset,
|
||||
} from "../../src/ui/select-controller"
|
||||
|
||||
test("reconciles and moves selections with explicit boundary policy", () => {
|
||||
expect([reconcileSelection(3, 0), reconcileSelection(4, 3), reconcileSelection(2, 6)]).toEqual([0, 2, 2])
|
||||
expect([
|
||||
moveSelection(0, { count: 3, delta: -1, policy: "clamp" }),
|
||||
moveSelection(2, { count: 3, delta: 1, policy: "clamp" }),
|
||||
moveSelection(0, { count: 3, delta: -1, policy: "wrap" }),
|
||||
moveSelection(2, { count: 3, delta: 1, policy: "wrap" }),
|
||||
]).toEqual([0, 2, 2, 0])
|
||||
})
|
||||
|
||||
test("reveals selections within bounded windows", () => {
|
||||
expect([
|
||||
revealSelectionOffset(5, { count: 20, limit: 8, selected: 3 }),
|
||||
revealSelectionOffset(3, { count: 20, limit: 8, selected: 11 }),
|
||||
revealSelectionOffset(3, { count: 20, limit: 8, selected: 10 }),
|
||||
revealSelectionOffset(20, { count: 20, limit: 8, selected: 19 }),
|
||||
]).toEqual([3, 4, 3, 12])
|
||||
})
|
||||
|
||||
test("keeps movement offsets and preview margins in bounds", () => {
|
||||
expect([
|
||||
moveSelectionOffset(0, { count: 20, limit: 8, selected: 6, direction: 1 }),
|
||||
moveSelectionOffset(8, { count: 20, limit: 8, selected: 9, direction: -1 }),
|
||||
moveSelectionOffset(12, { count: 20, limit: 8, selected: 19, direction: 1 }),
|
||||
moveSelectionOffset(4, { count: 4, limit: 8, selected: 3, direction: 1 }),
|
||||
]).toEqual([1, 7, 12, 0])
|
||||
})
|
||||
103
packages/tui/test/util/form.test.ts
Normal file
103
packages/tui/test/util/form.test.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { FormField, FormValue } from "@opencode-ai/client"
|
||||
import {
|
||||
formCustom,
|
||||
formDisplayValue,
|
||||
formInitialValues,
|
||||
formLabel,
|
||||
formRows,
|
||||
formSelected,
|
||||
formSetMultiselectCustom,
|
||||
formTextual,
|
||||
formToggleMultiselect,
|
||||
formValidateValue,
|
||||
isFormAnswerField,
|
||||
} from "../../src/util/form"
|
||||
import type { FormAnswerField } from "../../src/util/form"
|
||||
|
||||
const option = { key: "choice", type: "string", options: [{ value: "one", label: "One" }], custom: true } satisfies FormField
|
||||
const selection = {
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "one", label: "One" },
|
||||
{ value: "two", label: "Two" },
|
||||
],
|
||||
custom: true,
|
||||
} satisfies FormAnswerField
|
||||
|
||||
test("initializes configured and custom defaults", () => {
|
||||
expect(
|
||||
formInitialValues([
|
||||
{ key: "mode", type: "string", options: [{ value: "fast", label: "Fast" }], default: "fast" },
|
||||
{ ...option, key: "note", default: "detailed" },
|
||||
{ ...option, key: "configured", default: "one" },
|
||||
{ key: "count", type: "number", default: 0 },
|
||||
{ key: "authorize", type: "external", url: "https://example.com" },
|
||||
]),
|
||||
).toEqual({
|
||||
answers: { mode: "fast", note: "detailed", configured: "one", count: 0 },
|
||||
custom: { note: "detailed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("validates every supported field constraint", () => {
|
||||
const validate = (field: FormAnswerField, value: FormValue | undefined, error: string | undefined) =>
|
||||
expect(formValidateValue(field, value)).toBe(error)
|
||||
const string = (extra: Partial<Extract<FormAnswerField, { type: "string" }>> = {}) =>
|
||||
({ key: "value", type: "string", ...extra }) satisfies FormAnswerField
|
||||
const multi = (extra: Partial<Extract<FormAnswerField, { type: "multiselect" }>> = {}) =>
|
||||
({ key: "value", type: "multiselect", options: [], ...extra }) satisfies FormAnswerField
|
||||
|
||||
validate(string({ required: true }), undefined, "Answer required")
|
||||
validate(multi({ required: true }), [], "Select at least one option")
|
||||
validate(string(), true, "Expected text")
|
||||
validate(string({ minLength: 3 }), "ab", "Must be at least 3 characters")
|
||||
validate(string({ maxLength: 2 }), "abc", "Must be at most 2 characters")
|
||||
validate(string({ pattern: "^a+$" }), "bbb", "Must match pattern: ^a+$")
|
||||
validate(string({ pattern: "[" }), "value", "Invalid pattern: [")
|
||||
validate(string({ format: "email" }), "invalid", "Expected an email address")
|
||||
validate(string({ format: "uri" }), "not a URL", "Expected a URL")
|
||||
validate(string({ format: "date" }), "2025-02-29", "Expected a date (YYYY-MM-DD)")
|
||||
validate(string({ format: "date-time" }), "not a date", "Expected a date and time")
|
||||
validate(string({ options: [{ value: "yes", label: "Yes" }] }), "no", "Select an available option")
|
||||
validate({ key: "value", type: "number" }, Number.NaN, "Expected a number")
|
||||
validate({ key: "value", type: "integer" }, 1.5, "Expected an integer")
|
||||
validate({ key: "value", type: "number", minimum: 2 }, 1, "Must be at least 2")
|
||||
validate({ key: "value", type: "number", maximum: 2 }, 3, "Must be at most 2")
|
||||
validate({ key: "value", type: "boolean" }, "yes", "Expected yes or no")
|
||||
validate(multi(), "yes", "Expected selections")
|
||||
validate(multi({ minItems: 2 }), ["one"], "Select at least 2")
|
||||
validate(multi({ maxItems: 1 }), ["one", "two"], "Select at most 1")
|
||||
validate(multi({ options: [{ value: "one", label: "One" }] }), ["two"], "Select only available options")
|
||||
validate(multi({ custom: true }), ["custom"], undefined)
|
||||
})
|
||||
|
||||
test("shares field classification, rows, selection, and display", () => {
|
||||
const text = { key: "name", type: "string", title: "Name" } satisfies FormField
|
||||
const external = { key: "authorize", type: "external", url: "https://example.com" } satisfies FormField
|
||||
expect([isFormAnswerField(text), isFormAnswerField(external)]).toEqual([true, false])
|
||||
expect([formLabel(text), formLabel(external)]).toEqual(["Name", "https://example.com"])
|
||||
expect([formTextual(text), formTextual(option), formCustom(option)]).toEqual([true, false, true])
|
||||
expect(formRows({ key: "value", type: "boolean" })).toEqual([
|
||||
{ value: true, label: "Yes" },
|
||||
{ value: false, label: "No" },
|
||||
])
|
||||
expect(formRows({ ...option, options: [{ value: "one", label: "One", description: "First" }] })).toEqual([
|
||||
{ value: "one", label: "One", description: "First" },
|
||||
])
|
||||
expect(formRows({ key: "value", type: "number" })).toEqual([])
|
||||
expect([formSelected(selection, "two"), formSelected(selection, "custom"), formSelected(selection, undefined)]).toEqual([
|
||||
1, 2, 0,
|
||||
])
|
||||
expect(formDisplayValue(selection, ["one", "custom"], "(none)")).toBe("One, custom")
|
||||
expect([formDisplayValue(selection, [], ""), formDisplayValue(selection, [], "(none)")]).toEqual(["", "(none)"])
|
||||
})
|
||||
|
||||
test("updates multiselects without mutating their source", () => {
|
||||
const source = ["one", "custom"]
|
||||
expect(formToggleMultiselect(source, "one")).toEqual(["custom"])
|
||||
expect(formToggleMultiselect(source, "two")).toEqual(["one", "custom", "two"])
|
||||
expect(formSetMultiselectCustom(source, "custom", "replacement")).toEqual(["one", "replacement"])
|
||||
expect(source).toEqual(["one", "custom"])
|
||||
})
|
||||
17
packages/tui/test/util/path-format.test.ts
Normal file
17
packages/tui/test/util/path-format.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { formatPath } from "../../src/util/path-format"
|
||||
|
||||
test("formats relative, home, and foreign paths", () => {
|
||||
expect(formatPath(".", { base: "/work/project" })).toBe(".")
|
||||
expect(formatPath("../shared/a.ts", { base: "/work/project" })).toBe("/work/shared/a.ts")
|
||||
expect(formatPath("/home/test/project", { base: "/work", home: "/home/test" })).toBe("~/project")
|
||||
expect(formatPath("src\\a.ts", { base: "/work", forwardSlashes: true })).toBe("src/a.ts")
|
||||
expect(formatPath("C:/", { base: "/work" })).toBe("C:/")
|
||||
expect(formatPath("C:\\Users\\tester", { base: "/work", forwardSlashes: true })).toBe("C:/Users/tester")
|
||||
expect(formatPath("..\\shared\\a.ts", { base: "C:\\work\\project", forwardSlashes: true })).toBe(
|
||||
"C:/work/shared/a.ts",
|
||||
)
|
||||
expect(
|
||||
formatPath("C:\\Users\\test\\project", { base: "C:\\work", home: "C:\\Users\\test" }),
|
||||
).toBe("~/project")
|
||||
})
|
||||
19
packages/tui/test/util/permission.test.ts
Normal file
19
packages/tui/test/util/permission.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { permissionPresentation } from "../../src/util/permission"
|
||||
|
||||
test("preserves permission roots and self-contained metadata", () => {
|
||||
expect(permissionPresentation({ action: "external_directory", resources: ["/*"] }).title).toBe(
|
||||
"Access external directory /",
|
||||
)
|
||||
expect(permissionPresentation({ action: "external_directory", resources: ["C:/*"] }).title).toBe(
|
||||
"Access external directory C:/",
|
||||
)
|
||||
expect(permissionPresentation({ action: "webfetch", resources: [], metadata: { url: "https://example.com" } })).toMatchObject({
|
||||
title: "WebFetch https://example.com",
|
||||
lines: ["URL: https://example.com"],
|
||||
})
|
||||
expect(permissionPresentation({ action: "websearch", resources: [], metadata: { query: "releases" } })).toMatchObject({
|
||||
title: 'Web Search "releases"',
|
||||
lines: ["Query: releases"],
|
||||
})
|
||||
})
|
||||
|
|
@ -4,5 +4,5 @@ import { sessionEpilogue } from "../../src/util/presentation"
|
|||
test("formats session continuation summary", () => {
|
||||
const epilogue = sessionEpilogue({ title: "A session", sessionID: "ses_123" })
|
||||
expect(epilogue).toContain("A session")
|
||||
expect(epilogue).toContain("opencode -s ses_123")
|
||||
expect(epilogue).toContain("opencode2 -s ses_123")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,23 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { toolDisplayMetadata, webSearchProviderLabel } from "../../src/util/tool-display"
|
||||
import {
|
||||
canonicalToolName,
|
||||
finiteNumber,
|
||||
primitiveInputSummary,
|
||||
toolDisplayMetadata,
|
||||
webSearchProviderLabel,
|
||||
} from "../../src/util/tool-display"
|
||||
|
||||
test("normalizes shared tool primitives", () => {
|
||||
expect(["bash", "task", "apply_patch", "plugin_tool"].map(canonicalToolName)).toEqual([
|
||||
"shell",
|
||||
"subagent",
|
||||
"patch",
|
||||
"plugin_tool",
|
||||
])
|
||||
expect([finiteNumber(-1.5), finiteNumber(Number.NaN), finiteNumber("1")]).toEqual([-1.5, undefined, undefined])
|
||||
expect(primitiveInputSummary({ command: "pwd", count: 2, nested: {} })).toBe("[command=pwd, count=2]")
|
||||
expect(primitiveInputSummary({ path: "src/a.ts", line: 2 }, ["path"])).toBe("[line=2]")
|
||||
})
|
||||
|
||||
describe("webSearchProviderLabel", () => {
|
||||
test("labels known providers", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue