session-replay.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. import { describe, expect, test } from "bun:test"
  2. import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
  3. import type { SessionMessages } from "@/cli/cmd/run/session.shared"
  4. import type { RunProvider } from "@/cli/cmd/run/types"
  5. function userMessage(id: string, text: string): SessionMessages[number] {
  6. return {
  7. info: {
  8. id,
  9. sessionID: "session-1",
  10. role: "user",
  11. time: {
  12. created: 1,
  13. },
  14. agent: "build",
  15. model: {
  16. providerID: "openai",
  17. modelID: "gpt-5",
  18. },
  19. },
  20. parts: [
  21. {
  22. id: `${id}-text`,
  23. sessionID: "session-1",
  24. messageID: id,
  25. type: "text",
  26. text,
  27. },
  28. ],
  29. }
  30. }
  31. function assistantInfo(
  32. id: string,
  33. input: {
  34. parentID?: string
  35. modelID?: string
  36. providerID?: string
  37. time?: { created: number; completed?: number }
  38. } = {},
  39. ) {
  40. return {
  41. id,
  42. sessionID: "session-1",
  43. role: "assistant" as const,
  44. time: input.time ?? { created: 2 },
  45. parentID: input.parentID ?? "msg-user-1",
  46. modelID: input.modelID ?? "gpt-5",
  47. providerID: input.providerID ?? "openai",
  48. mode: "chat",
  49. agent: "build",
  50. path: {
  51. cwd: "/tmp",
  52. root: "/tmp",
  53. },
  54. cost: 0,
  55. tokens: {
  56. input: 1,
  57. output: 1,
  58. reasoning: 0,
  59. cache: {
  60. read: 0,
  61. write: 0,
  62. },
  63. },
  64. }
  65. }
  66. function assistantMessage(
  67. id: string,
  68. text: string,
  69. input: {
  70. parentID?: string
  71. modelID?: string
  72. providerID?: string
  73. time?: { created: number; completed?: number }
  74. } = {},
  75. ): SessionMessages[number] {
  76. const time = input.time ?? {
  77. created: 200,
  78. completed: 3000,
  79. }
  80. return {
  81. info: assistantInfo(id, {
  82. ...input,
  83. time,
  84. }),
  85. parts: [
  86. {
  87. id: `${id}-text`,
  88. sessionID: "session-1",
  89. messageID: id,
  90. type: "text",
  91. text,
  92. time: {
  93. start: time.created,
  94. end: time.completed,
  95. },
  96. },
  97. ],
  98. }
  99. }
  100. const provider = (name: string): RunProvider => ({
  101. id: "openai",
  102. name: "OpenAI",
  103. source: "api",
  104. env: [],
  105. options: {},
  106. models: {
  107. "gpt-5": {
  108. id: "gpt-5",
  109. providerID: "openai",
  110. api: {
  111. id: "openai",
  112. url: "https://openai.test",
  113. npm: "@ai-sdk/openai",
  114. },
  115. name,
  116. capabilities: {
  117. temperature: true,
  118. reasoning: true,
  119. attachment: true,
  120. toolcall: true,
  121. input: {
  122. text: true,
  123. audio: false,
  124. image: false,
  125. video: false,
  126. pdf: false,
  127. },
  128. output: {
  129. text: true,
  130. audio: false,
  131. image: false,
  132. video: false,
  133. pdf: false,
  134. },
  135. interleaved: false,
  136. },
  137. cost: {
  138. input: 0,
  139. output: 0,
  140. cache: {
  141. read: 0,
  142. write: 0,
  143. },
  144. },
  145. limit: {
  146. context: 128000,
  147. output: 8192,
  148. },
  149. status: "active",
  150. options: {},
  151. headers: {},
  152. release_date: "2026-01-01",
  153. },
  154. },
  155. })
  156. function runningToolMessage(id: string): SessionMessages[number] {
  157. return {
  158. info: assistantInfo(id),
  159. parts: [
  160. {
  161. id: `${id}-tool`,
  162. sessionID: "session-1",
  163. messageID: id,
  164. type: "tool",
  165. callID: `${id}-call`,
  166. tool: "bash",
  167. state: {
  168. status: "running",
  169. input: {
  170. command: "pwd",
  171. },
  172. time: {
  173. start: 2,
  174. },
  175. },
  176. },
  177. ],
  178. }
  179. }
  180. function shellUserMessage(id: string): SessionMessages[number] {
  181. return {
  182. info: {
  183. id,
  184. sessionID: "session-1",
  185. role: "user",
  186. time: {
  187. created: 1,
  188. },
  189. agent: "build",
  190. model: {
  191. providerID: "openai",
  192. modelID: "gpt-5",
  193. },
  194. },
  195. parts: [
  196. {
  197. id: `${id}-text`,
  198. sessionID: "session-1",
  199. messageID: id,
  200. type: "text",
  201. text: "The following tool was executed by the user",
  202. synthetic: true,
  203. },
  204. ],
  205. }
  206. }
  207. function shellAssistantMessage(id: string, parentID: string): SessionMessages[number] {
  208. return {
  209. info: assistantInfo(id, {
  210. parentID,
  211. time: {
  212. created: 200,
  213. completed: 3000,
  214. },
  215. }),
  216. parts: [
  217. {
  218. id: `${id}-tool`,
  219. sessionID: "session-1",
  220. messageID: id,
  221. type: "tool",
  222. callID: `${id}-call`,
  223. tool: "bash",
  224. state: {
  225. status: "completed",
  226. input: {
  227. command: "ls",
  228. },
  229. output: "account.ts\n",
  230. title: "",
  231. metadata: {
  232. output: "account.ts\n",
  233. },
  234. time: {
  235. start: 200,
  236. end: 3000,
  237. },
  238. },
  239. },
  240. ],
  241. }
  242. }
  243. describe("run session replay", () => {
  244. test("replays persisted user, assistant, and turn summary history into scrollback commits", () => {
  245. const out = replaySession({
  246. messages: [
  247. userMessage("msg-user-1", "Hello, whats the weather today?"),
  248. assistantMessage("msg-1", "What city or ZIP code should I check?"),
  249. ],
  250. permissions: [],
  251. questions: [],
  252. thinking: true,
  253. limits: {},
  254. })
  255. expect(out.commits).toEqual([
  256. expect.objectContaining({
  257. kind: "user",
  258. text: "Hello, whats the weather today?",
  259. phase: "start",
  260. source: "system",
  261. messageID: "msg-user-1",
  262. }),
  263. expect.objectContaining({
  264. kind: "assistant",
  265. text: "What city or ZIP code should I check?",
  266. phase: "progress",
  267. source: "assistant",
  268. messageID: "msg-1",
  269. }),
  270. expect.objectContaining({
  271. kind: "system",
  272. text: "▣ Build · gpt-5 · 2.8s",
  273. phase: "final",
  274. source: "system",
  275. messageID: "msg-1",
  276. summary: {
  277. agent: "Build",
  278. model: "gpt-5",
  279. duration: "2.8s",
  280. },
  281. }),
  282. ])
  283. expect(out.patch).toEqual(
  284. expect.objectContaining({
  285. phase: "idle",
  286. status: "",
  287. }),
  288. )
  289. })
  290. test("uses provider model names for replayed turn summaries when available", () => {
  291. const out = replaySession({
  292. messages: [
  293. userMessage("msg-user-1", "Hello, whats the weather today?"),
  294. assistantMessage("msg-1", "What city or ZIP code should I check?"),
  295. ],
  296. permissions: [],
  297. questions: [],
  298. thinking: true,
  299. limits: {},
  300. providers: [provider("Little Frank")],
  301. })
  302. expect(out.commits.at(-1)).toEqual(
  303. expect.objectContaining({
  304. kind: "system",
  305. text: "▣ Build · Little Frank · 2.8s",
  306. summary: {
  307. agent: "Build",
  308. model: "Little Frank",
  309. duration: "2.8s",
  310. },
  311. }),
  312. )
  313. })
  314. test("replays one turn summary for the final assistant in a multi-step turn", () => {
  315. const out = replaySession({
  316. messages: [
  317. userMessage("msg-user-1", "Plan and then answer"),
  318. assistantMessage("msg-step-1", "Working", {
  319. parentID: "msg-user-1",
  320. time: { created: 200, completed: 900 },
  321. }),
  322. assistantMessage("msg-step-2", "Done", {
  323. parentID: "msg-user-1",
  324. time: { created: 1000, completed: 3000 },
  325. }),
  326. ],
  327. permissions: [],
  328. questions: [],
  329. thinking: true,
  330. limits: {},
  331. })
  332. expect(out.commits.filter((commit) => commit.summary)).toEqual([
  333. expect.objectContaining({
  334. kind: "system",
  335. text: "▣ Build · gpt-5 · 2.0s",
  336. messageID: "msg-step-2",
  337. }),
  338. ])
  339. })
  340. test("keeps the footer in a running state for resumed active tools", () => {
  341. const out = replaySession({
  342. messages: [runningToolMessage("msg-1")],
  343. permissions: [],
  344. questions: [],
  345. thinking: true,
  346. limits: {},
  347. })
  348. expect(out.patch).toEqual(
  349. expect.objectContaining({
  350. phase: "running",
  351. status: "running bash",
  352. }),
  353. )
  354. })
  355. test("does not replay turn summaries for shell-mode commands", () => {
  356. const out = replaySession({
  357. messages: [
  358. shellUserMessage("msg-shell-user-1"),
  359. shellAssistantMessage("msg-shell-assistant-1", "msg-shell-user-1"),
  360. ],
  361. permissions: [],
  362. questions: [],
  363. thinking: true,
  364. limits: {},
  365. })
  366. expect(out.commits.some((commit) => commit.summary)).toBe(false)
  367. expect(out.commits).toContainEqual(
  368. expect.objectContaining({
  369. kind: "tool",
  370. text: "account.ts\n",
  371. tool: "bash",
  372. toolState: "completed",
  373. }),
  374. )
  375. })
  376. test("merges failed local rows ahead of later persisted prompts", () => {
  377. const persisted = {
  378. kind: "user",
  379. text: "successful",
  380. phase: "start",
  381. source: "system",
  382. messageID: "msg-user-2",
  383. } as const
  384. const failed = {
  385. kind: "user",
  386. text: "failed",
  387. phase: "start",
  388. source: "system",
  389. messageID: "msg-user-1",
  390. } as const
  391. const error = {
  392. kind: "error",
  393. text: "network unavailable",
  394. phase: "start",
  395. source: "system",
  396. messageID: "msg-user-1",
  397. } as const
  398. expect(
  399. replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }]),
  400. ).toEqual([failed, error, persisted])
  401. })
  402. test("retains local errors but not duplicate local prompts once a prompt persists", () => {
  403. const persisted = {
  404. kind: "user",
  405. text: "failed after persistence",
  406. phase: "start",
  407. source: "system",
  408. messageID: "msg-user-1",
  409. } as const
  410. const error = {
  411. kind: "error",
  412. text: "connection closed",
  413. phase: "start",
  414. source: "system",
  415. messageID: "msg-user-1",
  416. } as const
  417. expect(
  418. replayLocalRows(
  419. [userMessage("msg-user-1", "failed after persistence")],
  420. [persisted],
  421. [{ commit: persisted }, { commit: error }],
  422. ),
  423. ).toEqual([persisted, error])
  424. })
  425. test("keeps a local turn failure below assistant output already visible for that turn", () => {
  426. const first = {
  427. kind: "user",
  428. text: "start",
  429. phase: "start",
  430. source: "system",
  431. messageID: "msg-user-1",
  432. } as const
  433. const answer = {
  434. kind: "assistant",
  435. text: "partial answer",
  436. phase: "progress",
  437. source: "assistant",
  438. messageID: "msg-assistant-1",
  439. } as const
  440. const error = {
  441. kind: "error",
  442. text: "stream failed",
  443. phase: "start",
  444. source: "system",
  445. messageID: "msg-user-1",
  446. } as const
  447. const second = {
  448. kind: "user",
  449. text: "retry",
  450. phase: "start",
  451. source: "system",
  452. messageID: "msg-user-2",
  453. } as const
  454. expect(
  455. replayLocalRows(
  456. [userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
  457. [first, answer, second],
  458. [
  459. {
  460. commit: error,
  461. after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
  462. },
  463. ],
  464. ),
  465. ).toEqual([first, answer, error, second])
  466. })
  467. test("keeps a local failure above assistant output received after the failure", () => {
  468. const first = {
  469. kind: "user",
  470. text: "start",
  471. phase: "start",
  472. source: "system",
  473. messageID: "msg-user-1",
  474. } as const
  475. const error = {
  476. kind: "error",
  477. text: "request failed",
  478. phase: "start",
  479. source: "system",
  480. messageID: "msg-user-1",
  481. } as const
  482. const late = {
  483. kind: "assistant",
  484. text: "late answer",
  485. phase: "progress",
  486. source: "assistant",
  487. messageID: "msg-assistant-1",
  488. } as const
  489. expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
  490. first,
  491. error,
  492. late,
  493. ])
  494. })
  495. test("inserts a local failure between persisted output chunks spanning that failure", () => {
  496. const first = {
  497. kind: "user",
  498. text: "start",
  499. phase: "start",
  500. source: "system",
  501. messageID: "msg-user-1",
  502. } as const
  503. const complete = {
  504. kind: "assistant",
  505. text: "before after",
  506. phase: "progress",
  507. source: "assistant",
  508. messageID: "msg-assistant-1",
  509. partID: "part-1",
  510. } as const
  511. const error = {
  512. kind: "error",
  513. text: "stream failed",
  514. phase: "start",
  515. source: "system",
  516. messageID: "msg-user-1",
  517. } as const
  518. expect(
  519. replayLocalRows(
  520. [userMessage("msg-user-1", "start")],
  521. [first, complete],
  522. [
  523. {
  524. commit: error,
  525. after: {
  526. kind: "assistant",
  527. text: "before ",
  528. phase: "progress",
  529. messageID: "msg-assistant-1",
  530. partID: "part-1",
  531. visible: "before ",
  532. },
  533. },
  534. ],
  535. ),
  536. ).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
  537. })
  538. test("places an unpersisted failed prompt before live output from that turn", () => {
  539. const prompt = {
  540. kind: "user",
  541. text: "start",
  542. phase: "start",
  543. source: "system",
  544. messageID: "msg-1",
  545. } as const
  546. const answer = {
  547. kind: "assistant",
  548. text: "partial answer",
  549. phase: "progress",
  550. source: "assistant",
  551. messageID: "msg-2",
  552. } as const
  553. const error = {
  554. kind: "error",
  555. text: "stream failed",
  556. phase: "start",
  557. source: "system",
  558. messageID: "msg-1",
  559. } as const
  560. expect(
  561. replayLocalRows(
  562. [],
  563. [answer],
  564. [
  565. { commit: prompt },
  566. {
  567. commit: error,
  568. after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
  569. },
  570. ],
  571. ),
  572. ).toEqual([prompt, answer, error])
  573. })
  574. test("anchors a failure after the visible start of a tool that later completes", () => {
  575. const prompt = {
  576. kind: "user",
  577. text: "run ls",
  578. phase: "start",
  579. source: "system",
  580. messageID: "msg-user-1",
  581. } as const
  582. const running = {
  583. kind: "tool",
  584. text: "running bash",
  585. phase: "start",
  586. source: "tool",
  587. messageID: "msg-assistant-1",
  588. partID: "part-tool-1",
  589. toolState: "running",
  590. } as const
  591. const completed = {
  592. kind: "tool",
  593. text: "file.txt",
  594. phase: "final",
  595. source: "tool",
  596. messageID: "msg-assistant-1",
  597. partID: "part-tool-1",
  598. toolState: "completed",
  599. } as const
  600. const error = {
  601. kind: "error",
  602. text: "connection lost",
  603. phase: "start",
  604. source: "system",
  605. messageID: "msg-user-1",
  606. } as const
  607. expect(
  608. replayLocalRows(
  609. [userMessage("msg-user-1", "run ls")],
  610. [prompt, running, completed],
  611. [
  612. {
  613. commit: error,
  614. after: {
  615. kind: "tool",
  616. text: "running bash",
  617. phase: "start",
  618. messageID: "msg-assistant-1",
  619. partID: "part-tool-1",
  620. toolState: "running",
  621. },
  622. },
  623. ],
  624. ),
  625. ).toEqual([prompt, running, error, completed])
  626. })
  627. test("retains an unpersisted local diagnostic before later persisted prompts", () => {
  628. const first = {
  629. kind: "user",
  630. text: "before",
  631. phase: "start",
  632. source: "system",
  633. messageID: "msg-user-1",
  634. } as const
  635. const error = {
  636. kind: "error",
  637. text: "failed to start new session",
  638. phase: "start",
  639. source: "system",
  640. messageID: "msg-user-2",
  641. } as const
  642. const second = {
  643. kind: "user",
  644. text: "after",
  645. phase: "start",
  646. source: "system",
  647. messageID: "msg-user-3",
  648. } as const
  649. expect(
  650. replayLocalRows(
  651. [userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")],
  652. [first, second],
  653. [{ commit: error }],
  654. ),
  655. ).toEqual([first, error, second])
  656. })
  657. })