diff --git a/packages/tui/test/message-trailer.view.test.tsx b/packages/tui/test/message-trailer.view.test.tsx new file mode 100644 index 0000000000..48e720d17d --- /dev/null +++ b/packages/tui/test/message-trailer.view.test.tsx @@ -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>, 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 ", 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((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)