session-data.test.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. import { describe, expect, test } from "bun:test"
  2. import type { Event } from "@opencode-ai/sdk/v2"
  3. import { createSessionData, flushInterrupted, reduceSessionData } from "@/cli/cmd/run/session-data"
  4. import type { StreamCommit } from "@/cli/cmd/run/types"
  5. function reduce(data: ReturnType<typeof createSessionData>, event: unknown, thinking = true) {
  6. return reduceSessionData({
  7. data,
  8. event: event as Event,
  9. sessionID: "session-1",
  10. thinking,
  11. limits: {},
  12. })
  13. }
  14. function assistant(id: string, extra: Record<string, unknown> = {}) {
  15. return {
  16. type: "message.updated",
  17. properties: {
  18. sessionID: "session-1",
  19. info: {
  20. id,
  21. role: "assistant",
  22. providerID: "openai",
  23. modelID: "gpt-5",
  24. tokens: {
  25. input: 1,
  26. output: 1,
  27. reasoning: 0,
  28. cache: { read: 0, write: 0 },
  29. },
  30. ...extra,
  31. },
  32. },
  33. }
  34. }
  35. function user(id: string) {
  36. return {
  37. type: "message.updated",
  38. properties: {
  39. sessionID: "session-1",
  40. info: {
  41. id,
  42. role: "user",
  43. },
  44. },
  45. }
  46. }
  47. function text(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
  48. return {
  49. type: "message.part.updated",
  50. properties: {
  51. part: {
  52. id: input.id,
  53. messageID: input.messageID,
  54. sessionID: "session-1",
  55. type: "text",
  56. text: input.text,
  57. ...(input.time ? { time: input.time } : {}),
  58. },
  59. },
  60. }
  61. }
  62. function reasoning(input: { id: string; messageID: string; text: string; time?: Record<string, number> }) {
  63. return {
  64. type: "message.part.updated",
  65. properties: {
  66. part: {
  67. id: input.id,
  68. messageID: input.messageID,
  69. sessionID: "session-1",
  70. type: "reasoning",
  71. text: input.text,
  72. ...(input.time ? { time: input.time } : {}),
  73. },
  74. },
  75. }
  76. }
  77. function delta(messageID: string, partID: string, value: string) {
  78. return {
  79. type: "message.part.delta",
  80. properties: {
  81. sessionID: "session-1",
  82. messageID,
  83. partID,
  84. field: "text",
  85. delta: value,
  86. },
  87. }
  88. }
  89. function tool(input: { id: string; messageID: string; tool: string; state: Record<string, unknown>; callID?: string }) {
  90. return {
  91. type: "message.part.updated",
  92. properties: {
  93. part: {
  94. id: input.id,
  95. messageID: input.messageID,
  96. sessionID: "session-1",
  97. type: "tool",
  98. tool: input.tool,
  99. ...(input.callID ? { callID: input.callID } : {}),
  100. state: input.state,
  101. },
  102. },
  103. }
  104. }
  105. describe("run session data", () => {
  106. test("buffers delayed assistant text until the role is known", () => {
  107. let data = createSessionData()
  108. data = reduce(data, delta("msg-1", "txt-1", "hello")).data
  109. data = reduce(data, assistant("msg-1")).data
  110. const out = reduce(
  111. data,
  112. text({
  113. id: "txt-1",
  114. messageID: "msg-1",
  115. text: "",
  116. time: { end: 1 },
  117. }),
  118. )
  119. expect(out.commits).toEqual([
  120. expect.objectContaining({
  121. kind: "assistant",
  122. text: "hello",
  123. partID: "txt-1",
  124. }),
  125. ])
  126. })
  127. test("keeps leading whitespace buffered until real assistant content arrives", () => {
  128. let data = createSessionData()
  129. data = reduce(data, assistant("msg-1")).data
  130. data = reduce(data, text({ id: "txt-1", messageID: "msg-1", text: "", time: { start: 1 } })).data
  131. let out = reduce(data, delta("msg-1", "txt-1", " "))
  132. expect(out.commits).toEqual([])
  133. out = reduce(out.data, delta("msg-1", "txt-1", "Found"))
  134. expect(out.commits).toEqual([
  135. expect.objectContaining({
  136. kind: "assistant",
  137. text: " Found",
  138. }),
  139. ])
  140. })
  141. test("drops delayed text once the message resolves to a user role", () => {
  142. let data = createSessionData()
  143. data = reduce(data, text({ id: "txt-user-1", messageID: "msg-user-1", text: "HELLO", time: { end: 1 } })).data
  144. const out = reduce(data, user("msg-user-1"))
  145. expect(out.commits).toEqual([])
  146. expect(out.data.ids.has("txt-user-1")).toBe(true)
  147. })
  148. test("suppresses reasoning commits when thinking is disabled", () => {
  149. const out = reduce(
  150. createSessionData(),
  151. reasoning({
  152. id: "reason-1",
  153. messageID: "msg-1",
  154. text: "hidden",
  155. time: { end: 1 },
  156. }),
  157. false,
  158. )
  159. expect(out.commits).toEqual([])
  160. expect(out.data.ids.has("reason-1")).toBe(true)
  161. })
  162. test("keeps permission precedence over queued questions", () => {
  163. let data = createSessionData()
  164. data = reduce(data, {
  165. type: "permission.asked",
  166. properties: {
  167. id: "perm-1",
  168. sessionID: "session-1",
  169. permission: "read",
  170. patterns: ["/tmp/file.txt"],
  171. metadata: {},
  172. always: [],
  173. },
  174. }).data
  175. const ask = reduce(data, {
  176. type: "question.asked",
  177. properties: {
  178. id: "question-1",
  179. sessionID: "session-1",
  180. questions: [
  181. {
  182. question: "Mode?",
  183. header: "Mode",
  184. options: [{ label: "chunked", description: "Incremental output" }],
  185. multiple: false,
  186. },
  187. ],
  188. },
  189. })
  190. expect(ask.footer).toEqual({
  191. patch: { status: "awaiting permission" },
  192. view: {
  193. type: "permission",
  194. request: expect.objectContaining({ id: "perm-1" }),
  195. },
  196. })
  197. expect(
  198. reduce(ask.data, {
  199. type: "permission.replied",
  200. properties: {
  201. sessionID: "session-1",
  202. requestID: "perm-1",
  203. reply: "reject",
  204. },
  205. }).footer,
  206. ).toEqual({
  207. patch: { status: "awaiting answer" },
  208. view: {
  209. type: "question",
  210. request: expect.objectContaining({ id: "question-1" }),
  211. },
  212. })
  213. })
  214. test("refreshes the active permission view when tool input arrives later", () => {
  215. const data = reduce(createSessionData(), {
  216. type: "permission.asked",
  217. properties: {
  218. id: "perm-1",
  219. sessionID: "session-1",
  220. permission: "bash",
  221. patterns: ["src/**/*.ts"],
  222. metadata: {},
  223. always: [],
  224. tool: {
  225. messageID: "msg-1",
  226. callID: "call-1",
  227. },
  228. },
  229. }).data
  230. const out = reduce(
  231. data,
  232. tool({
  233. id: "tool-1",
  234. messageID: "msg-1",
  235. callID: "call-1",
  236. tool: "bash",
  237. state: {
  238. status: "running",
  239. input: {
  240. command: "git status --short",
  241. },
  242. },
  243. }),
  244. )
  245. expect(out.footer).toEqual({
  246. view: {
  247. type: "permission",
  248. request: expect.objectContaining({
  249. id: "perm-1",
  250. metadata: expect.objectContaining({
  251. input: {
  252. command: "git status --short",
  253. },
  254. }),
  255. }),
  256. },
  257. })
  258. })
  259. test("strips bash echo only from the first assistant flush", () => {
  260. let data = createSessionData()
  261. data = reduce(data, assistant("msg-1")).data
  262. data = reduce(
  263. data,
  264. tool({
  265. id: "tool-1",
  266. messageID: "msg-1",
  267. tool: "bash",
  268. state: {
  269. status: "completed",
  270. input: {
  271. command: "printf hi",
  272. },
  273. output: "echoed\n",
  274. time: { start: 1, end: 2 },
  275. },
  276. }),
  277. ).data
  278. const first = reduce(
  279. data,
  280. text({
  281. id: "txt-1",
  282. messageID: "msg-1",
  283. text: "echoed\nanswer",
  284. }),
  285. )
  286. expect(first.commits).toEqual([
  287. expect.objectContaining({
  288. kind: "assistant",
  289. text: "answer",
  290. }),
  291. ])
  292. expect(reduce(first.data, delta("msg-1", "txt-1", "\nechoed\nagain")).commits).toEqual([
  293. expect.objectContaining({
  294. kind: "assistant",
  295. text: "\nechoed\nagain",
  296. }),
  297. ])
  298. })
  299. test("synthesizes a glob start before an error when the running update is missed", () => {
  300. expect(
  301. reduce(
  302. createSessionData(),
  303. tool({
  304. id: "tool-1",
  305. messageID: "msg-1",
  306. tool: "glob",
  307. state: {
  308. status: "error",
  309. input: {
  310. pattern: "**/*tool*",
  311. path: "/tmp/demo/run",
  312. },
  313. error: "No such file or directory: '/tmp/demo/run'",
  314. },
  315. }),
  316. ).commits,
  317. ).toEqual([
  318. expect.objectContaining({
  319. kind: "tool",
  320. tool: "glob",
  321. phase: "start",
  322. partID: "tool-1",
  323. text: "running glob",
  324. toolState: "running",
  325. }),
  326. expect.objectContaining({
  327. kind: "tool",
  328. tool: "glob",
  329. phase: "final",
  330. partID: "tool-1",
  331. text: "No such file or directory: '/tmp/demo/run'",
  332. toolState: "error",
  333. toolError: "No such file or directory: '/tmp/demo/run'",
  334. }),
  335. ])
  336. })
  337. test("flushInterrupted emits one interrupted final per live part", () => {
  338. const data = reduce(
  339. createSessionData(),
  340. text({
  341. id: "txt-1",
  342. messageID: "msg-1",
  343. text: "unfinished",
  344. }),
  345. ).data
  346. const first: StreamCommit[] = []
  347. flushInterrupted(data, first)
  348. expect(first).toEqual([
  349. expect.objectContaining({ kind: "assistant", text: "unfinished", phase: "progress" }),
  350. expect.objectContaining({ kind: "assistant", phase: "final", interrupted: true }),
  351. ])
  352. const next: StreamCommit[] = []
  353. flushInterrupted(data, next)
  354. expect(next).toEqual([])
  355. })
  356. test("surfaces session errors as error commits", () => {
  357. const out = reduce(createSessionData(), {
  358. type: "session.error",
  359. properties: {
  360. sessionID: "session-1",
  361. error: {
  362. name: "UnknownError",
  363. data: {
  364. message: "permission denied",
  365. },
  366. },
  367. },
  368. })
  369. expect(out.commits).toEqual([
  370. expect.objectContaining({
  371. kind: "error",
  372. text: "permission denied",
  373. }),
  374. ])
  375. })
  376. })