501 lines
13 KiB
TypeScript
501 lines
13 KiB
TypeScript
import { afterEach, expect, test } from "bun:test"
|
||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||
import { MockTreeSitterClient, createTestRenderer, type TestRenderer } from "@opentui/core/testing"
|
||
import { RunScrollbackStream } from "@/cli/cmd/run/scrollback.surface"
|
||
import { RUN_THEME_FALLBACK } from "@/cli/cmd/run/theme"
|
||
import type { StreamCommit } from "@/cli/cmd/run/types"
|
||
|
||
type ClaimedCommit = {
|
||
snapshot: {
|
||
height: number
|
||
getRealCharBytes(addLineBreaks?: boolean): Uint8Array
|
||
destroy(): void
|
||
}
|
||
trailingNewline: boolean
|
||
}
|
||
|
||
type QueueRenderer = {
|
||
externalOutputQueue: {
|
||
claim(): ClaimedCommit[]
|
||
}
|
||
}
|
||
|
||
const decoder = new TextDecoder()
|
||
const active: TestRenderer[] = []
|
||
|
||
afterEach(() => {
|
||
for (const renderer of active.splice(0)) {
|
||
renderer.destroy()
|
||
}
|
||
})
|
||
|
||
function claim(renderer: TestRenderer) {
|
||
return (renderer as unknown as QueueRenderer).externalOutputQueue.claim()
|
||
}
|
||
|
||
function renderCommit(commit: ClaimedCommit) {
|
||
return decoder.decode(commit.snapshot.getRealCharBytes(true)).replace(/ +\n/g, "\n")
|
||
}
|
||
|
||
function render(commits: ClaimedCommit[]) {
|
||
return commits.map(renderCommit).join("")
|
||
}
|
||
|
||
function renderRows(commit: ClaimedCommit, width = 80) {
|
||
const raw = decoder.decode(commit.snapshot.getRealCharBytes(true))
|
||
return Array.from({ length: commit.snapshot.height }, (_, index) =>
|
||
raw.slice(index * width, (index + 1) * width).trimEnd(),
|
||
)
|
||
}
|
||
|
||
function destroy(commits: ClaimedCommit[]) {
|
||
for (const commit of commits) {
|
||
commit.snapshot.destroy()
|
||
}
|
||
}
|
||
|
||
async function setup(input: {
|
||
width?: number
|
||
wrote?: boolean
|
||
} = {}) {
|
||
const out = await createTestRenderer({
|
||
width: input.width ?? 80,
|
||
screenMode: "split-footer",
|
||
footerHeight: 6,
|
||
externalOutputMode: "capture-stdout",
|
||
consoleMode: "disabled",
|
||
})
|
||
active.push(out.renderer)
|
||
|
||
const treeSitterClient = new MockTreeSitterClient({ autoResolveTimeout: 0 })
|
||
treeSitterClient.setMockResult({ highlights: [] })
|
||
|
||
return {
|
||
renderer: out.renderer,
|
||
scrollback: new RunScrollbackStream(out.renderer, RUN_THEME_FALLBACK, {
|
||
treeSitterClient,
|
||
wrote: input.wrote ?? false,
|
||
}),
|
||
}
|
||
}
|
||
|
||
function assistant(text: string, phase: StreamCommit["phase"] = "progress"): StreamCommit {
|
||
return {
|
||
kind: "assistant",
|
||
text,
|
||
phase,
|
||
source: "assistant",
|
||
messageID: "msg-1",
|
||
partID: "part-1",
|
||
}
|
||
}
|
||
|
||
function user(text: string): StreamCommit {
|
||
return {
|
||
kind: "user",
|
||
text,
|
||
phase: "start",
|
||
source: "system",
|
||
}
|
||
}
|
||
|
||
function toolPart(tool: string, state: Record<string, unknown>, id: string, messageID: string): ToolPart {
|
||
return {
|
||
id,
|
||
sessionID: "session-1",
|
||
messageID,
|
||
type: "tool",
|
||
callID: `call-${id}`,
|
||
tool,
|
||
state,
|
||
} as ToolPart
|
||
}
|
||
|
||
function toolCommit(input: {
|
||
tool: string
|
||
phase: StreamCommit["phase"]
|
||
toolState?: StreamCommit["toolState"]
|
||
text?: string
|
||
state?: Record<string, unknown>
|
||
id?: string
|
||
messageID?: string
|
||
}): StreamCommit {
|
||
const id = input.id ?? `${input.tool}-1`
|
||
const messageID = input.messageID ?? `msg-${input.tool}`
|
||
|
||
return {
|
||
kind: "tool",
|
||
text: input.text ?? "",
|
||
phase: input.phase,
|
||
source: "tool",
|
||
partID: id,
|
||
messageID,
|
||
tool: input.tool,
|
||
...(input.toolState ? { toolState: input.toolState } : {}),
|
||
...(input.state ? { part: toolPart(input.tool, input.state, id, messageID) } : {}),
|
||
}
|
||
}
|
||
|
||
test("finalizes markdown tables for streamed and coalesced input", async () => {
|
||
const text = "| Column 1 | Column 2 | Column 3 |\n|---|---|---|\n| Row 1 | Value 1 | Value 2 |\n| Row 2 | Value 3 | Value 4 |"
|
||
|
||
for (const chunks of [[text], [...text]]) {
|
||
const out = await setup()
|
||
|
||
try {
|
||
for (const chunk of chunks) {
|
||
await out.scrollback.append(assistant(chunk))
|
||
}
|
||
|
||
await out.scrollback.complete()
|
||
|
||
const commits = claim(out.renderer)
|
||
try {
|
||
const output = render(commits)
|
||
expect(output).toContain("Column 1")
|
||
expect(output).toContain("Row 2")
|
||
expect(output).toContain("Value 4")
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
}
|
||
})
|
||
|
||
test("holds markdown code blocks until final commit and keeps newline ownership", async () => {
|
||
const out = await setup()
|
||
|
||
try {
|
||
await out.scrollback.append(
|
||
assistant('# Markdown Sample\n\n- Item 1\n- Item 2\n\n```js\nconst message = "Hello, markdown"\nconsole.log(message)\n```'),
|
||
)
|
||
|
||
const progress = claim(out.renderer)
|
||
try {
|
||
expect(progress).toHaveLength(1)
|
||
expect(render(progress)).toContain("Markdown Sample")
|
||
expect(render(progress)).toContain("Item 2")
|
||
expect(render(progress)).not.toContain("console.log(message)")
|
||
} finally {
|
||
destroy(progress)
|
||
}
|
||
|
||
await out.scrollback.complete()
|
||
|
||
const final = claim(out.renderer)
|
||
try {
|
||
expect(final).toHaveLength(1)
|
||
expect(final[0]!.trailingNewline).toBe(false)
|
||
expect(render(final)).toContain('const message = "Hello, markdown"')
|
||
expect(render(final)).toContain("console.log(message)")
|
||
} finally {
|
||
destroy(final)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
})
|
||
|
||
test("renders todo and question summaries without boilerplate footer copy", async () => {
|
||
const cases = [
|
||
{
|
||
title: "# Todos",
|
||
include: [
|
||
"[✓] List files under `run/`",
|
||
"[•] Count functions in each `run/` file",
|
||
"[ ] Mark each tracking item complete",
|
||
],
|
||
exclude: ["Updating", "todos completed"],
|
||
start: toolCommit({
|
||
tool: "todowrite",
|
||
phase: "start",
|
||
toolState: "running",
|
||
state: {
|
||
status: "running",
|
||
input: {
|
||
todos: [
|
||
{ status: "completed", content: "List files under `run/`" },
|
||
{ status: "in_progress", content: "Count functions in each `run/` file" },
|
||
{ status: "pending", content: "Mark each tracking item complete" },
|
||
],
|
||
},
|
||
time: { start: 1 },
|
||
},
|
||
}),
|
||
final: toolCommit({
|
||
tool: "todowrite",
|
||
phase: "final",
|
||
toolState: "completed",
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
todos: [
|
||
{ status: "completed", content: "List files under `run/`" },
|
||
{ status: "in_progress", content: "Count functions in each `run/` file" },
|
||
{ status: "pending", content: "Mark each tracking item complete" },
|
||
],
|
||
},
|
||
metadata: {},
|
||
time: { start: 1, end: 4 },
|
||
},
|
||
}),
|
||
},
|
||
{
|
||
title: "# Questions",
|
||
include: ["What should I work on in the codebase next?", "Bug fix"],
|
||
exclude: ["Asked", "questions completed"],
|
||
start: toolCommit({
|
||
tool: "question",
|
||
phase: "start",
|
||
toolState: "running",
|
||
state: {
|
||
status: "running",
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: "What should I work on in the codebase next?",
|
||
header: "Next work",
|
||
options: [{ label: "bug", description: "Bug fix" }],
|
||
multiple: false,
|
||
},
|
||
],
|
||
},
|
||
time: { start: 1 },
|
||
},
|
||
}),
|
||
final: toolCommit({
|
||
tool: "question",
|
||
phase: "final",
|
||
toolState: "completed",
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
questions: [
|
||
{
|
||
question: "What should I work on in the codebase next?",
|
||
header: "Next work",
|
||
options: [{ label: "bug", description: "Bug fix" }],
|
||
multiple: false,
|
||
},
|
||
],
|
||
},
|
||
metadata: {
|
||
answers: [["Bug fix"]],
|
||
},
|
||
time: { start: 1, end: 2100 },
|
||
},
|
||
}),
|
||
},
|
||
]
|
||
|
||
for (const item of cases) {
|
||
const out = await setup()
|
||
|
||
try {
|
||
await out.scrollback.append(item.start)
|
||
expect(claim(out.renderer)).toHaveLength(0)
|
||
|
||
await out.scrollback.append(item.final)
|
||
|
||
const commits = claim(out.renderer)
|
||
try {
|
||
expect(commits).toHaveLength(1)
|
||
const rows = renderRows(commits[0]!)
|
||
const output = rows.join("\n")
|
||
expect(output).toContain(item.title)
|
||
for (const line of item.include) {
|
||
expect(output).toContain(line)
|
||
}
|
||
for (const line of item.exclude) {
|
||
expect(output).not.toContain(line)
|
||
}
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
}
|
||
})
|
||
|
||
test("inserts spacers for new visible groups", async () => {
|
||
const prior = await setup({ wrote: true })
|
||
|
||
try {
|
||
await prior.scrollback.append(user("use subagent to explore run.ts"))
|
||
|
||
const commits = claim(prior.renderer)
|
||
try {
|
||
expect(commits).toHaveLength(2)
|
||
expect(renderCommit(commits[0]!).trim()).toBe("")
|
||
expect(renderCommit(commits[1]!).trim()).toBe("› use subagent to explore run.ts")
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
prior.scrollback.destroy()
|
||
}
|
||
|
||
const grouped = await setup()
|
||
|
||
try {
|
||
await grouped.scrollback.append(assistant("hello"))
|
||
await grouped.scrollback.complete()
|
||
destroy(claim(grouped.renderer))
|
||
|
||
await grouped.scrollback.append(
|
||
toolCommit({
|
||
tool: "glob",
|
||
phase: "start",
|
||
text: "running glob",
|
||
toolState: "running",
|
||
state: {
|
||
status: "running",
|
||
input: {
|
||
pattern: "**/run.ts",
|
||
},
|
||
time: { start: 1 },
|
||
},
|
||
}),
|
||
)
|
||
|
||
const commits = claim(grouped.renderer)
|
||
try {
|
||
expect(commits).toHaveLength(2)
|
||
expect(renderCommit(commits[0]!).trim()).toBe("")
|
||
expect(renderCommit(commits[1]!).replace(/ +/g, " ").trim()).toBe('✱ Glob "**/run.ts"')
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
grouped.scrollback.destroy()
|
||
}
|
||
})
|
||
|
||
test("coalesces same-line tool progress into one snapshot", async () => {
|
||
const out = await setup()
|
||
|
||
try {
|
||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "abc" }))
|
||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "progress", text: "def" }))
|
||
await out.scrollback.append(toolCommit({ tool: "bash", phase: "final", text: "", toolState: "completed" }))
|
||
|
||
const commits = claim(out.renderer)
|
||
try {
|
||
expect(commits).toHaveLength(1)
|
||
expect(render(commits)).toContain("abcdef")
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
})
|
||
|
||
test("renders structured write finals once as code blocks", async () => {
|
||
const out = await setup()
|
||
|
||
try {
|
||
await out.scrollback.append(
|
||
toolCommit({
|
||
tool: "write",
|
||
phase: "start",
|
||
toolState: "running",
|
||
id: "tool-2",
|
||
messageID: "msg-2",
|
||
state: {
|
||
status: "running",
|
||
input: {
|
||
filePath: "src/a.ts",
|
||
content: "const x = 1\nconst y = 2\n",
|
||
},
|
||
time: { start: 1 },
|
||
},
|
||
}),
|
||
)
|
||
expect(claim(out.renderer)).toHaveLength(0)
|
||
|
||
await out.scrollback.append(
|
||
toolCommit({
|
||
tool: "write",
|
||
phase: "final",
|
||
toolState: "completed",
|
||
id: "tool-2",
|
||
messageID: "msg-2",
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
filePath: "src/a.ts",
|
||
content: "const x = 1\nconst y = 2\n",
|
||
},
|
||
metadata: {},
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
}),
|
||
)
|
||
|
||
const commits = claim(out.renderer)
|
||
try {
|
||
expect(commits).toHaveLength(1)
|
||
const output = render(commits[0] ? [commits[0]] : [])
|
||
expect(output).toContain("# Wrote src/a.ts")
|
||
expect(output).toMatch(/1\s+const x = 1/)
|
||
expect(output).toMatch(/2\s+const y = 2/)
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
})
|
||
|
||
test("renders promoted task markdown without a leading blank row", async () => {
|
||
const out = await setup()
|
||
|
||
try {
|
||
await out.scrollback.append(
|
||
toolCommit({
|
||
tool: "task",
|
||
phase: "final",
|
||
toolState: "completed",
|
||
state: {
|
||
status: "completed",
|
||
input: {
|
||
description: "Explore run.ts",
|
||
subagent_type: "explore",
|
||
},
|
||
output: [
|
||
"task_id: child-1 (for resuming to continue this task if needed)",
|
||
"",
|
||
"<task_result>",
|
||
"Location: `/tmp/run.ts`",
|
||
"",
|
||
"Summary:",
|
||
"- Local interactive mode",
|
||
"- Attach mode",
|
||
"</task_result>",
|
||
].join("\n"),
|
||
metadata: {
|
||
sessionId: "child-1",
|
||
},
|
||
time: { start: 1, end: 2 },
|
||
},
|
||
}),
|
||
)
|
||
|
||
const commits = claim(out.renderer)
|
||
try {
|
||
const output = render(commits)
|
||
expect(output.startsWith("\n")).toBe(false)
|
||
expect(output).toContain("Summary:")
|
||
expect(output).toContain("Local interactive mode")
|
||
} finally {
|
||
destroy(commits)
|
||
}
|
||
} finally {
|
||
out.scrollback.destroy()
|
||
}
|
||
})
|