subagent-child-navigation.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import { base64Encode } from "@opencode-ai/core/util/encode"
  2. import { expect, test, type Page } from "@playwright/test"
  3. import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
  4. import { expectSessionTitle } from "../utils/waits"
  5. const directory = "C:/OpenCode/SubagentNavigation"
  6. const projectID = "proj_subagent_navigation"
  7. const parentID = "ses_subagent_parent"
  8. const childID = "ses_subagent_child"
  9. const parentTitle = "Parent session"
  10. const childTitle = "Subagent child session"
  11. // Child session pages derive their heading from the task part that spawned them.
  12. const taskDescription = "Inspect child navigation"
  13. type EventPayload = { directory: string; payload: Record<string, unknown> }
  14. test.use({ viewport: { width: 1440, height: 900 } })
  15. test("navigates to a subagent child session missing from the session list", async ({ page }) => {
  16. await setup(page)
  17. await openChildFromParent(page)
  18. await expectSessionTitle(page, taskDescription)
  19. await expect(page.getByRole("heading", { name: parentTitle })).toHaveCount(0)
  20. const titlebarRight = page.locator("#opencode-titlebar-right")
  21. await expect(titlebarRight.getByRole("button", { name: "Toggle review" })).toHaveCount(1)
  22. })
  23. test("keeps the parent visible while child lineage resolves", async ({ page }) => {
  24. await setup(page)
  25. const requested = Promise.withResolvers<void>()
  26. const release = Promise.withResolvers<void>()
  27. await page.route(
  28. (url) => url.pathname === `/api/session/${childID}` && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
  29. async (route) => {
  30. requested.resolve()
  31. await release.promise
  32. await route.fallback()
  33. },
  34. )
  35. await page.goto(sessionHref(parentID))
  36. await expectSessionTitle(page, parentTitle)
  37. await page.locator(`a[href="${sessionHref(childID)}"]`).click()
  38. await requested.promise
  39. await Promise.all([expect(page).toHaveURL(sessionHref(parentID)), expectSessionTitle(page, parentTitle)]).finally(
  40. () => release.resolve(),
  41. )
  42. await expectSessionTitle(page, taskDescription)
  43. })
  44. test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
  45. const events: EventPayload[] = []
  46. await setup(page, () => events.splice(0, 1))
  47. await openChildFromParent(page)
  48. await expectSessionTitle(page, taskDescription)
  49. events.push({
  50. directory,
  51. payload: { type: "session.deleted", properties: { info: childSession() } },
  52. })
  53. await expect(page.getByText("This session cannot be found")).toBeVisible()
  54. await expect(page.getByRole("button", { name: "Close Tab" })).toBeVisible()
  55. await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
  56. })
  57. async function setup(page: Page, events?: () => EventPayload[]) {
  58. await mockOpenCodeServer(page, {
  59. directory,
  60. project: {
  61. id: projectID,
  62. worktree: directory,
  63. vcs: "git",
  64. name: "subagent-navigation",
  65. time: { created: 1700000000000, updated: 1700000000000 },
  66. sandboxes: [],
  67. },
  68. provider: {
  69. all: [
  70. {
  71. id: "opencode",
  72. name: "OpenCode",
  73. models: {
  74. "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6", limit: { context: 200_000 } },
  75. },
  76. },
  77. ],
  78. connected: ["opencode"],
  79. default: { providerID: "opencode", modelID: "claude-opus-4-6" },
  80. },
  81. sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
  82. pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
  83. events,
  84. eventRetry: events ? 16 : undefined,
  85. })
  86. // The child session resolves by ID but is absent from the session list,
  87. // matching a subagent session that has not been loaded into the list cache yet.
  88. await page.route(
  89. (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
  90. (route) =>
  91. route.fulfill({
  92. status: 200,
  93. contentType: "application/json",
  94. headers: { "access-control-allow-origin": "*" },
  95. body: JSON.stringify({
  96. data: [currentSession(session(parentID, parentTitle, 1700000000000))],
  97. cursor: {},
  98. }),
  99. }),
  100. )
  101. await configurePage(page)
  102. }
  103. async function openChildFromParent(page: Page) {
  104. await page.goto(sessionHref(parentID))
  105. await expectSessionTitle(page, parentTitle)
  106. const card = page.locator(`a[href="${sessionHref(childID)}"]`)
  107. await expect(card).toBeVisible()
  108. await card.click()
  109. await expect(page).toHaveURL(new RegExp(`/server/.+/session/${childID}$`), { timeout: 15_000 })
  110. }
  111. function session(id: string, title: string, created: number, extra?: Record<string, unknown>) {
  112. return {
  113. id,
  114. slug: id,
  115. projectID,
  116. directory,
  117. title,
  118. version: "dev",
  119. time: { created, updated: created },
  120. ...extra,
  121. }
  122. }
  123. function childSession() {
  124. return session(childID, childTitle, 1700000001000, { parentID })
  125. }
  126. function parentMessages() {
  127. const userID = "msg_user_0001"
  128. const assistantID = "msg_assistant_0001"
  129. return [
  130. {
  131. info: {
  132. id: userID,
  133. sessionID: parentID,
  134. role: "user",
  135. time: { created: 1700000000000 },
  136. agent: "build",
  137. model: { providerID: "opencode", modelID: "claude-opus-4-6" },
  138. },
  139. parts: [
  140. {
  141. id: "prt_user_text_0001",
  142. sessionID: parentID,
  143. messageID: userID,
  144. type: "text",
  145. text: "Delegate work to a subagent",
  146. },
  147. ],
  148. },
  149. {
  150. info: {
  151. id: assistantID,
  152. sessionID: parentID,
  153. role: "assistant",
  154. time: { created: 1700000001000, completed: 1700000002000 },
  155. parentID: userID,
  156. modelID: "claude-opus-4-6",
  157. providerID: "opencode",
  158. mode: "build",
  159. agent: "build",
  160. path: { cwd: directory, root: directory },
  161. cost: 0.01,
  162. tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
  163. finish: "stop",
  164. },
  165. parts: [
  166. {
  167. id: "prt_tool_task_0001",
  168. sessionID: parentID,
  169. messageID: assistantID,
  170. type: "tool",
  171. callID: "call_task_0001",
  172. tool: "task",
  173. state: {
  174. status: "completed",
  175. input: { description: taskDescription, subagent_type: "explore" },
  176. output: "Subagent finished",
  177. title: taskDescription,
  178. metadata: { sessionId: childID },
  179. time: { start: 1700000001000, end: 1700000002000 },
  180. },
  181. },
  182. ],
  183. },
  184. ]
  185. }
  186. async function configurePage(page: Page) {
  187. const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
  188. await page.addInitScript(
  189. ({ directory, server, sessionId }) => {
  190. localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
  191. localStorage.setItem(
  192. "opencode.global.dat:server",
  193. JSON.stringify({
  194. projects: { local: [{ worktree: directory, expanded: true }] },
  195. lastProject: { local: directory },
  196. }),
  197. )
  198. localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId }]))
  199. },
  200. { directory, server, sessionId: parentID },
  201. )
  202. }
  203. function sessionHref(sessionID: string) {
  204. const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
  205. return `/server/${base64Encode(server)}/session/${sessionID}`
  206. }