Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions packages/tui/test/message-trailer.view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { expect, mock, test } from "bun:test"
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createTestRenderer } from "@opentui/core/testing"
import { Effect } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/core/global"
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
import { createEventSource, createFetch, directory, json } from "./fixture/tui-sdk"

async function waitForFrame(setup: Awaited<ReturnType<typeof createTestRenderer>>, fn: (frame: string) => boolean) {
const start = Date.now()
while (true) {
await setup.renderOnce()
const frame = setup.captureCharFrame()
if (fn(frame)) return frame
if (Date.now() - start > 15_000) throw new Error("timed out waiting for frame:\n" + frame)
await Bun.sleep(25)
}
}

// The last assistant message's trailer normally repeats the agent and model the
// prompt footer already shows; when they match the current selection the fork
// collapses it to "Baked for <duration>", and cycling to another agent brings
// the full trailer back (see DIVERGENCES.md).
test("last-message trailer collapses to Baked for and reappears on agent switch", async () => {
// Wide enough that the footer keeps agent · model on one line.
const setup = await createTestRenderer({ width: 120, height: 30, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventSource()
const session = {
id: "ses_test",
title: "Demo session",
slug: "test",
projectID: "proj_test",
directory,
version: "0.0.0-test",
time: { created: 1, updated: 1 },
}
const calls = createFetch((url) => {
if (url.pathname === "/session") return json([session])
if (url.pathname === "/session/ses_test") return json(session)
// Two primary agents so agent.cycle has somewhere to go.
if (url.pathname === "/agent")
return json([
{ name: "build", mode: "primary" },
{ name: "plan", mode: "primary" },
])
if (url.pathname === "/session/ses_test/message")
return json([
{
info: {
id: "msg_1",
sessionID: "ses_test",
role: "user",
agent: "build",
model: { providerID: "test", modelID: "m1" },
time: { created: 1000 },
},
parts: [{ id: "prt_1", sessionID: "ses_test", messageID: "msg_1", type: "text", text: "hello there" }],
},
{
// Completed turn by the currently selected agent and model, so the
// trailer's agent · model would duplicate the footer.
info: {
id: "msg_2",
sessionID: "ses_test",
role: "assistant",
agent: "build",
mode: "build",
parentID: "msg_1",
modelID: "m1",
providerID: "test",
finish: "stop",
path: { cwd: directory, root: directory },
cost: 0,
tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 2000, completed: 2600 },
},
parts: [{ id: "prt_2", sessionID: "ses_test", messageID: "msg_2", type: "text", text: "All good." }],
},
])
if (url.pathname === "/session/ses_test/todo") return json([])
if (url.pathname === "/session/ses_test/diff") return json([])
if (url.pathname === "/project/proj_test/directories") return json([])
// A paid model keeps the fork's first-run configure dialog closed; it is
// also the fallback selection every agent resolves to.
if (url.pathname === "/config/providers")
return json({
providers: [
{
id: "test",
name: "Test",
models: { m1: { name: "M1", cost: { input: 1 }, limit: { context: 200_000, output: 8_192 } } },
},
],
default: { test: "m1" },
})
return undefined
})
let api: TuiPluginApi | undefined
let started!: () => void
const ready = new Promise<void>((resolve) => {
started = resolve
})

try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
url: "http://test",
directory,
config: createTuiResolvedConfig({ plugin_enabled: {} }),
fetch: calls.fetch,
events: events.source,
args: { continue: true },
pluginHost: {
// The session prompt is a slot default; the slot view renders
// nothing until the slot registry is set up.
async start(input) {
input.runtime.setupSlots(input.api)
api = input.api
started()
},
async dispose() {},
},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node))),
)
await ready

// Suppressed: duration only, no marker, no agent · model copy.
const suppressed = await waitForFrame(setup, (frame) => frame.includes("Baked for 1.6s"))
expect(suppressed).not.toContain("▣")
expect(suppressed).not.toContain("Build · M1")

// Switching agents breaks the match, so the full trailer comes back.
api?.keymap.dispatchCommand("agent.cycle")
const full = await waitForFrame(setup, (frame) => frame.includes("Build · M1"))
expect(full).toContain("▣")
expect(full).toContain("Build · M1 · 1.6s")
expect(full).not.toContain("Baked for")

// And cycling back restores the suppression.
api?.keymap.dispatchCommand("agent.cycle")
const again = await waitForFrame(setup, (frame) => frame.includes("Baked for 1.6s"))
expect(again).not.toContain("▣")

process.emit("SIGHUP")
await task
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
}
}, 30_000)
Loading