| 1 | // Run: tsx src/__tests__/transcript-process-fold.test.ts |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React from "react"; |
| 5 | import { renderToStaticMarkup } from "react-dom/server"; |
| 6 | import { createServer, type ViteDevServer } from "vite"; |
| 7 | import type { Item } from "../lib/useController"; |
| 8 | |
| 9 | let passed = 0; |
| 10 | let failed = 0; |
| 11 | |
| 12 | function ok(value: unknown, label: string) { |
| 13 | if (value) { |
| 14 | process.stdout.write(` PASS ${label}\n`); |
| 15 | passed += 1; |
| 16 | } else { |
| 17 | process.stdout.write(` FAIL ${label}\n`); |
| 18 | failed += 1; |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | console.log("\ntranscript process fold"); |
| 23 | |
| 24 | let displayMode = "standard"; |
| 25 | let processFoldPref = "auto"; |
| 26 | Object.defineProperty(globalThis, "localStorage", { |
| 27 | configurable: true, |
| 28 | value: { |
| 29 | getItem(key: string) { |
| 30 | if (key === "reasonix-display-mode") return displayMode; |
| 31 | if (key === "reasonix-process-fold") return processFoldPref; |
| 32 | return null; |
| 33 | }, |
| 34 | setItem() {}, |
| 35 | removeItem() {}, |
| 36 | clear() {}, |
| 37 | key() { return null; }, |
| 38 | length: 0, |
| 39 | }, |
| 40 | }); |
| 41 | |
| 42 | let server: ViteDevServer | undefined; |
| 43 | try { |
| 44 | server = await createServer({ |
| 45 | appType: "custom", |
| 46 | logLevel: "silent", |
| 47 | server: { middlewareMode: true }, |
| 48 | }); |
| 49 | const { Transcript } = await server.ssrLoadModule("/src/components/Transcript.tsx"); |
| 50 | const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx"); |
| 51 | |
| 52 | function render(items: Item[], options: { mode?: "standard" | "compact"; running?: boolean; turnStartAt?: number; foldPref?: "auto" | "expanded" } = {}) { |
| 53 | displayMode = options.mode ?? "standard"; |
| 54 | processFoldPref = options.foldPref ?? "auto"; |
| 55 | const markup = renderToStaticMarkup( |
| 56 | React.createElement( |
| 57 | LocaleProvider, |
| 58 | null, |
| 59 | React.createElement(Transcript, { |
| 60 | items, |
| 61 | onPrompt: () => {}, |
| 62 | questionNavigator: false, |
| 63 | running: options.running ?? false, |
| 64 | turnStartAt: options.turnStartAt, |
| 65 | }), |
| 66 | ), |
| 67 | ); |
| 68 | return new JSDOM(markup).window.document; |
| 69 | } |
| 70 | |
| 71 | const warningTurn: Item[] = [ |
| 72 | { kind: "user", id: "u1", text: "inspect" }, |
| 73 | { kind: "assistant", id: "a1", text: "", reasoning: "first thought", streaming: false }, |
| 74 | { kind: "tool", id: "t1", name: "read_file", args: "{}", readOnly: true, status: "done", durationMs: 400 }, |
| 75 | { kind: "notice", id: "n1", level: "warn", text: "gateway warning" }, |
| 76 | { kind: "assistant", id: "a2", text: "", reasoning: "second thought", streaming: false }, |
| 77 | { kind: "tool", id: "t2", name: "bash", args: "{}", readOnly: false, status: "done", durationMs: 600 }, |
| 78 | { kind: "assistant", id: "a3", text: "final answer", reasoning: "final thought", streaming: false, workDurationMs: 24_000 }, |
| 79 | ]; |
| 80 | |
| 81 | for (const mode of ["standard", "compact"] as const) { |
| 82 | const doc = render(warningTurn, { mode }); |
| 83 | const warning = doc.querySelector(".notice-line--warn"); |
| 84 | const finalAnswer = Array.from(doc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("final answer")); |
| 85 | ok(doc.querySelectorAll(".turn-collapse").length === 1, `${mode} mode renders one work fold for the turn`); |
| 86 | ok(warning && !warning.closest(".turn-collapse"), `${mode} warning remains visible without splitting the fold`); |
| 87 | ok(finalAnswer && !finalAnswer.closest(".turn-collapse"), `${mode} final answer renders outside the work fold`); |
| 88 | } |
| 89 | |
| 90 | // Assistant content is model output addressed to the user — every message |
| 91 | // with answer text stays outside the fold, not just the last one (#4092), |
| 92 | // and process that ran AFTER an answer opens a new fold so the transcript |
| 93 | // keeps the real timeline: plan → answer → tool work → answer. |
| 94 | const intermediateDoc = render([ |
| 95 | { kind: "user", id: "u2", text: "continue" }, |
| 96 | { kind: "assistant", id: "a4", text: "I will inspect the files", reasoning: "plan", streaming: false }, |
| 97 | { kind: "tool", id: "t3", name: "read_file", args: "{}", readOnly: true, status: "done" }, |
| 98 | { kind: "assistant", id: "a5", text: "all done", reasoning: "verify", streaming: false }, |
| 99 | ]); |
| 100 | const intermediate = Array.from(intermediateDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("I will inspect the files")); |
| 101 | const final = Array.from(intermediateDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("all done")); |
| 102 | const folds = Array.from(intermediateDoc.querySelectorAll(".turn-collapse")); |
| 103 | ok(folds.length === 2, "work after an intermediate answer opens a second fold"); |
| 104 | ok(intermediate && !intermediate.closest(".turn-collapse"), "intermediate assistant text renders outside the work fold"); |
| 105 | ok(final && !final.closest(".turn-collapse"), "final assistant answer renders outside the work fold"); |
| 106 | const inOrder = (a: Element | null | undefined, b: Element | null | undefined) => Boolean( |
| 107 | a && b && (a.compareDocumentPosition(b) & intermediateDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING), |
| 108 | ); |
| 109 | ok( |
| 110 | inOrder(folds[0], intermediate) && inOrder(intermediate, folds[1]) && inOrder(folds[1], final), |
| 111 | "folds and answers keep the turn's real timeline", |
| 112 | ); |
| 113 | ok(folds[0]?.textContent?.includes("plan") && !folds[0]?.textContent?.includes("verify"), "first fold holds only the work before the first answer"); |
| 114 | ok(folds[1]?.textContent?.includes("verify") && folds[1]?.textContent?.includes("read_file"), "second fold holds the work after the first answer"); |
| 115 | ok(folds[0]?.querySelector(".turn-collapse__label")?.textContent === "1 thoughts", "earlier folds carry a counts-only label"); |
| 116 | ok(folds[1]?.querySelector(".turn-collapse__label")?.textContent?.startsWith("Worked"), "the closing fold carries the turn's work label"); |
| 117 | |
| 118 | // A mid-turn steer is the user's own message (#6238): it renders on the |
| 119 | // user side, outside the fold, at its real position — work that followed |
| 120 | // the steer folds after it, not ahead of it. Ordinary info notices keep |
| 121 | // folding. |
| 122 | const steerDoc = render([ |
| 123 | { kind: "user", id: "u-steer", text: "start" }, |
| 124 | { kind: "assistant", id: "a-steer-1", text: "", reasoning: "thinking", streaming: false }, |
| 125 | { kind: "notice", id: "s1", level: "info", text: "↪ use plan B instead" }, |
| 126 | { kind: "notice", id: "i1", level: "info", text: "plain info notice" }, |
| 127 | { kind: "assistant", id: "a-steer-2", text: "done via plan B", reasoning: "", streaming: false }, |
| 128 | ]); |
| 129 | const steer = steerDoc.querySelector(".steer-line"); |
| 130 | ok(steer && !steer.closest(".turn-collapse"), "steer notice renders outside the work fold"); |
| 131 | ok(steer?.textContent?.includes("use plan B instead"), "steer bubble carries the user's guidance text"); |
| 132 | const plainInfo = Array.from(steerDoc.querySelectorAll(".notice-line")).find((node) => node.textContent?.includes("plain info notice")); |
| 133 | ok(plainInfo && plainInfo.closest(".turn-collapse"), "plain info notices keep folding"); |
| 134 | const steerFolds = Array.from(steerDoc.querySelectorAll(".turn-collapse")); |
| 135 | const steerInOrder = (a: Element | null | undefined, b: Element | null | undefined) => Boolean( |
| 136 | a && b && (a.compareDocumentPosition(b) & steerDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING), |
| 137 | ); |
| 138 | ok( |
| 139 | steerFolds.length === 2 && steerInOrder(steerFolds[0], steer) && steerInOrder(steer, steerFolds[1]), |
| 140 | "work after the steer folds after it, keeping the steer's position", |
| 141 | ); |
| 142 | |
| 143 | const errorDoc = render([ |
| 144 | { kind: "user", id: "u-error", text: "finish" }, |
| 145 | { kind: "assistant", id: "a-error", text: "partial result", reasoning: "worked", streaming: false }, |
| 146 | { kind: "notice", id: "n-error", level: "warn", text: "turn stopped" }, |
| 147 | ]); |
| 148 | const errorAnswer = Array.from(errorDoc.querySelectorAll(".msg--assistant")).find((node) => node.textContent?.includes("partial result")); |
| 149 | const trailingWarning = errorDoc.querySelector(".notice-line--warn"); |
| 150 | const followsAnswer = Boolean( |
| 151 | errorAnswer && |
| 152 | trailingWarning && |
| 153 | (errorAnswer.compareDocumentPosition(trailingWarning) & errorDoc.defaultView!.Node.DOCUMENT_POSITION_FOLLOWING), |
| 154 | ); |
| 155 | ok(followsAnswer, "warnings outside the fold preserve their order relative to the final answer"); |
| 156 | |
| 157 | // A delivery pause is a decision point addressed to the user: the status |
| 158 | // card and its continue action must stay visible when the turn's process |
| 159 | // fold closes, unlike plain info notices. |
| 160 | const deliveryDoc = render([ |
| 161 | { kind: "user", id: "u-delivery", text: "ship it" }, |
| 162 | { kind: "assistant", id: "a-delivery", text: "", reasoning: "attempted delivery", streaming: false }, |
| 163 | { |
| 164 | kind: "notice", |
| 165 | id: "n-delivery", |
| 166 | level: "info", |
| 167 | variant: "delivery", |
| 168 | title: "Delivery checks are not complete", |
| 169 | text: "The response was generated, but verification and review still need to be completed.", |
| 170 | detail: "final-answer readiness failed 3 times: missing verification", |
| 171 | action: "continue_delivery", |
| 172 | }, |
| 173 | ]); |
| 174 | const deliveryCard = deliveryDoc.querySelector(".notice-line--delivery"); |
| 175 | ok(deliveryCard && !deliveryCard.closest(".turn-collapse"), "delivery status card renders outside the work fold"); |
| 176 | ok(Boolean(deliveryCard?.querySelector("button")), "delivery status card keeps its continue action reachable"); |
| 177 | |
| 178 | const originalNow = Date.now; |
| 179 | Date.now = () => 25_000; |
| 180 | try { |
| 181 | const runningDoc = render([ |
| 182 | { kind: "user", id: "u3", text: "run" }, |
| 183 | { kind: "assistant", id: "a6", text: "", reasoning: "working", streaming: false, workDurationMs: 5_000 }, |
| 184 | ], { running: true, turnStartAt: 1_000 }); |
| 185 | ok(runningDoc.querySelector(".turn-collapse__label")?.textContent === "Working 24s · 1 thoughts", "active turn stays Working and counts its process items"); |
| 186 | } finally { |
| 187 | Date.now = originalNow; |
| 188 | } |
| 189 | |
| 190 | const completedDoc = render([ |
| 191 | { kind: "user", id: "u4", text: "finish" }, |
| 192 | { kind: "assistant", id: "a7", text: "done", reasoning: "worked", streaming: false, workDurationMs: 24_000 }, |
| 193 | ]); |
| 194 | ok(completedDoc.querySelector(".turn-collapse__label")?.textContent === "Worked 24s · 1 thoughts", "completed turn keeps the persisted wall-clock duration and counts"); |
| 195 | |
| 196 | const countsDoc = render(warningTurn); |
| 197 | const countsLabel = countsDoc.querySelector(".turn-collapse__label")?.textContent ?? ""; |
| 198 | ok(countsLabel.includes("2 tools") && countsLabel.includes("3 thoughts"), "fold label surfaces tool and thought counts"); |
| 199 | |
| 200 | // A turn whose fold is the only content (e.g. cancelled before any answer) |
| 201 | // must not collapse into a bare label — nothing would remain visible. |
| 202 | const aloneDoc = render([ |
| 203 | { kind: "user", id: "u5", text: "cancelled" }, |
| 204 | { kind: "assistant", id: "a8", text: "", reasoning: "got cut off", streaming: false, workDurationMs: 3_000 }, |
| 205 | ]); |
| 206 | ok(aloneDoc.querySelector(".turn-collapse--open"), "fold with nothing outside stays expanded"); |
| 207 | const answeredDoc = render([ |
| 208 | { kind: "user", id: "u6", text: "ask" }, |
| 209 | { kind: "assistant", id: "a9", text: "answered", reasoning: "quick", streaming: false, workDurationMs: 3_000 }, |
| 210 | ]); |
| 211 | ok(!answeredDoc.querySelector(".turn-collapse--open"), "fold with an answer outside starts collapsed"); |
| 212 | |
| 213 | // settings.processFold = expanded keeps completed folds open (#4233, #2278). |
| 214 | const expandedDoc = render([ |
| 215 | { kind: "user", id: "u7", text: "ask" }, |
| 216 | { kind: "assistant", id: "a10", text: "answered", reasoning: "quick", streaming: false, workDurationMs: 3_000 }, |
| 217 | ], { foldPref: "expanded" }); |
| 218 | ok(expandedDoc.querySelector(".turn-collapse--open"), "keep-expanded preference leaves the fold open"); |
| 219 | |
| 220 | // Each reasoning segment inside the fold is independently collapsible (#6340). |
| 221 | const segmentDoc = render(warningTurn); |
| 222 | const segmentHeads = segmentDoc.querySelectorAll("button.turn-collapse__reasoning-head"); |
| 223 | ok(segmentHeads.length === 3, "every reasoning segment gets its own toggle"); |
| 224 | ok(Array.from(segmentHeads).every((head) => head.getAttribute("aria-expanded") === "true"), "reasoning segments default to expanded"); |
| 225 | } finally { |
| 226 | await server?.close(); |
| 227 | delete (globalThis as { localStorage?: Storage }).localStorage; |
| 228 | } |
| 229 | |
| 230 | console.log(`\n${passed} passed, ${failed} failed`); |
| 231 | if (failed > 0) process.exit(1); |
| 232 |