app-lifecycle.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { expect, test } from "bun:test"
  2. import { createTestRenderer } from "@opentui/core/testing"
  3. import { Effect, FileSystem } from "effect"
  4. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  5. import { Global } from "@opencode-ai/util/global"
  6. import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
  7. test("SIGHUP clears title and disposes scoped resources once", async () => {
  8. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  9. const titles: string[] = []
  10. let started!: () => void
  11. const ready = new Promise<void>((resolve) => {
  12. started = resolve
  13. })
  14. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  15. setup.renderer.setTerminalTitle = (title) => {
  16. titles.push(title)
  17. if (title === "OpenCode") started()
  18. setTitle(title)
  19. }
  20. const listeners = new Set(process.listeners("SIGHUP"))
  21. const events = createEventStream()
  22. const calls = createFetch(undefined, events)
  23. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  24. try {
  25. const { run } = await import("../src/app")
  26. const task = Effect.runPromise(
  27. run({
  28. app: { name: "test", version: "test", channel: "test" },
  29. server: { endpoint: { url: server.url.toString() } },
  30. config: { get: async () => ({}), update: async () => ({}) },
  31. packages: { resolve: async () => undefined },
  32. terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
  33. args: {},
  34. log: () => {},
  35. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  36. )
  37. await ready
  38. process.emit("SIGHUP")
  39. await task
  40. expect(setup.renderer.isDestroyed).toBe(true)
  41. expect(titles.at(-1)).toBe("")
  42. expect(process.listeners("SIGHUP").every((listener) => listeners.has(listener))).toBe(true)
  43. } finally {
  44. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  45. await server.stop()
  46. }
  47. })
  48. test("session lifecycle updates the terminal title and prints the epilogue after cleanup", async () => {
  49. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  50. let initialTitle!: () => void
  51. const initialTitleSet = new Promise<void>((resolve) => {
  52. initialTitle = resolve
  53. })
  54. let renamedTitle!: () => void
  55. const renamedTitleSet = new Promise<void>((resolve) => {
  56. renamedTitle = resolve
  57. })
  58. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  59. setup.renderer.setTerminalTitle = (title) => {
  60. if (title === "OC | Demo session") initialTitle()
  61. if (title === "OC | Renamed session") renamedTitle()
  62. setTitle(title)
  63. }
  64. const events = createEventStream()
  65. let promptRequests = 0
  66. const calls = createFetch((url) => {
  67. const session = {
  68. id: "dummy",
  69. title: "Demo session",
  70. projectID: "project",
  71. location: { directory },
  72. cost: 0,
  73. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  74. time: { created: 0, updated: 0 },
  75. }
  76. if (url.pathname === "/api/session")
  77. return json({
  78. data: [session],
  79. cursor: {},
  80. })
  81. if (url.pathname === "/api/session/dummy") return json({ data: session })
  82. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  83. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  84. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  85. if (url.pathname === "/api/session/dummy/prompt") {
  86. promptRequests++
  87. return json({ data: {} })
  88. }
  89. }, events)
  90. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  91. const originalWrite = process.stdout.write.bind(process.stdout)
  92. let stdout = ""
  93. process.stdout.write = ((chunk: string | Uint8Array) => {
  94. stdout += String(chunk)
  95. return true
  96. }) as typeof process.stdout.write
  97. try {
  98. const { run } = await import("../src/app")
  99. const task = Effect.runPromise(
  100. run({
  101. app: { name: "test", version: "test", channel: "test" },
  102. server: { endpoint: { url: server.url.toString() } },
  103. config: { get: async () => ({}), update: async () => ({}) },
  104. packages: { resolve: async () => undefined },
  105. terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
  106. args: { sessionID: "dummy" },
  107. log: () => {},
  108. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  109. )
  110. await initialTitleSet
  111. events.emit({
  112. id: "evt_renamed",
  113. created: 1,
  114. type: "session.renamed",
  115. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  116. data: { sessionID: "dummy", title: "Renamed session" },
  117. })
  118. await renamedTitleSet
  119. setup.renderer.destroy()
  120. await task
  121. expect(stdout).toContain("Renamed session")
  122. expect(stdout).toContain("opencode2 -s dummy")
  123. expect(promptRequests).toBe(0)
  124. } finally {
  125. process.stdout.write = originalWrite
  126. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  127. await server.stop()
  128. }
  129. })
  130. test("session title generated while an untitled session is loading remains visible", async () => {
  131. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  132. const titles: string[] = []
  133. const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
  134. const generatedTitle = Promise.withResolvers<void>()
  135. setup.renderer.setTerminalTitle = (title) => {
  136. titles.push(title)
  137. if (title === "OC | Generated title") generatedTitle.resolve()
  138. setTitle(title)
  139. }
  140. const sessionRequested = Promise.withResolvers<void>()
  141. const renameSyncRequested = Promise.withResolvers<void>()
  142. const releaseSession = Promise.withResolvers<void>()
  143. let sessionRequests = 0
  144. const session = {
  145. id: "dummy",
  146. projectID: "project",
  147. location: { directory },
  148. cost: 0,
  149. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  150. time: { created: 0, updated: 0 },
  151. }
  152. const events = createEventStream()
  153. const calls = createFetch(async (url) => {
  154. if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
  155. if (url.pathname === "/api/session/dummy") {
  156. sessionRequests++
  157. sessionRequested.resolve()
  158. if (sessionRequests === 2) renameSyncRequested.resolve()
  159. await releaseSession.promise
  160. return json({ data: session })
  161. }
  162. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  163. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  164. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  165. }, events)
  166. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  167. try {
  168. const { run } = await import("../src/app")
  169. const task = Effect.runPromise(
  170. run({
  171. app: { name: "test", version: "test", channel: "test" },
  172. server: { endpoint: { url: server.url.toString() } },
  173. config: { get: async () => ({}), update: async () => ({}) },
  174. packages: { resolve: async () => undefined },
  175. terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
  176. args: { sessionID: "dummy" },
  177. log: () => {},
  178. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  179. )
  180. await sessionRequested.promise
  181. events.emit({
  182. id: "evt_renamed",
  183. created: 1,
  184. type: "session.renamed",
  185. durable: { aggregateID: "dummy", seq: 1, version: 1 },
  186. data: { sessionID: "dummy", title: "Generated title" },
  187. })
  188. await Promise.race([
  189. renameSyncRequested.promise,
  190. Bun.sleep(2_000).then(() => {
  191. throw new Error("rename sync did not start")
  192. }),
  193. ])
  194. releaseSession.resolve()
  195. await Promise.race([
  196. generatedTitle.promise,
  197. Bun.sleep(2_000).then(() => {
  198. throw new Error("generated title was not shown")
  199. }),
  200. ])
  201. await Bun.sleep(20)
  202. const generated = titles.lastIndexOf("OC | Generated title")
  203. expect(generated).toBeGreaterThan(-1)
  204. expect(titles.slice(generated + 1)).not.toContain("OpenCode")
  205. setup.renderer.destroy()
  206. await task
  207. } finally {
  208. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  209. await server.stop()
  210. }
  211. })
  212. test("session startup prompt is submitted exactly once", async () => {
  213. const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
  214. const events = createEventStream()
  215. const cwd = process.cwd()
  216. const location = { directory: cwd, project: { id: "project", directory: cwd } }
  217. const session = {
  218. id: "dummy",
  219. title: "Demo session",
  220. projectID: "project",
  221. location: { directory: cwd },
  222. agent: "build",
  223. model: { providerID: "provider", id: "model" },
  224. cost: 0,
  225. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  226. time: { created: 0, updated: 0 },
  227. }
  228. const bodies: unknown[] = []
  229. const promptSubmitted = Promise.withResolvers<void>()
  230. const calls = createFetch(async (url, request) => {
  231. if (url.pathname === "/api/location") return json(location)
  232. if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
  233. if (url.pathname === "/api/session/dummy") return json({ data: session })
  234. if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
  235. if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
  236. if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
  237. if (url.pathname === "/api/agent")
  238. return json({
  239. location,
  240. data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
  241. })
  242. if (url.pathname === "/api/model")
  243. return json({
  244. location,
  245. data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
  246. })
  247. if (url.pathname === "/api/session/dummy/prompt") {
  248. bodies.push(await request.json())
  249. promptSubmitted.resolve()
  250. return json({ data: {} })
  251. }
  252. }, events)
  253. const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
  254. try {
  255. const { run } = await import("../src/app")
  256. const task = Effect.runPromise(
  257. run({
  258. app: { name: "test", version: "test", channel: "test" },
  259. server: { endpoint: { url: server.url.toString() } },
  260. config: { get: async () => ({}), update: async () => ({}) },
  261. packages: { resolve: async () => undefined },
  262. terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
  263. args: { sessionID: "dummy", prompt: "RESUME_READY" },
  264. log: () => {},
  265. }).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
  266. )
  267. await Promise.race([
  268. promptSubmitted.promise,
  269. Bun.sleep(2000).then(() => {
  270. throw new Error("startup prompt was not submitted")
  271. }),
  272. ])
  273. await Bun.sleep(20)
  274. setup.renderer.destroy()
  275. await task
  276. expect(bodies).toHaveLength(1)
  277. expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
  278. } finally {
  279. if (!setup.renderer.isDestroyed) setup.renderer.destroy()
  280. await server.stop()
  281. }
  282. })