event-behavior.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
  1. import { describe, expect, test } from "bun:test"
  2. import type { AgentSideConnection } from "@agentclientprotocol/sdk"
  3. import type { SessionMessageInfo } from "@opencode-ai/client/promise"
  4. import { resolve } from "node:path"
  5. import { replayMessages, streamTurn, type ChildSessionUpdate, type TurnControl } from "../../src/acp/event"
  6. import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
  7. type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
  8. type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
  9. type Fixture = ReturnType<typeof createSseFixture>
  10. describe("acp event behavior", () => {
  11. test("subscribes before admission and isolates sessions and input IDs", async () => {
  12. const updates: SessionUpdateParams[] = []
  13. const fixture = createSseFixture({
  14. onPrompt({ id, send }) {
  15. send(
  16. ephemeralEvent("session.text.delta", {
  17. sessionID: "ses_a",
  18. assistantMessageID: "msg_before",
  19. ordinal: 0,
  20. delta: "before admission",
  21. }),
  22. )
  23. send(durableEvent("session.input.promoted", { sessionID: "ses_b", inputID: id }))
  24. send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: "input_other" }))
  25. send(
  26. ephemeralEvent("session.text.delta", {
  27. sessionID: "ses_a",
  28. assistantMessageID: "msg_wrong_input",
  29. ordinal: 0,
  30. delta: "wrong input",
  31. }),
  32. )
  33. send(durableEvent("session.input.promoted", { sessionID: "ses_a", inputID: id }))
  34. send(
  35. ephemeralEvent("session.text.delta", {
  36. sessionID: "ses_b",
  37. assistantMessageID: "msg_b",
  38. ordinal: 0,
  39. delta: "other session",
  40. }),
  41. )
  42. send(
  43. ephemeralEvent("session.text.delta", {
  44. sessionID: "ses_a",
  45. assistantMessageID: "msg_a",
  46. ordinal: 0,
  47. delta: "accepted",
  48. }),
  49. )
  50. send(
  51. durableEvent("session.step.ended", {
  52. sessionID: "ses_a",
  53. assistantMessageID: "msg_a",
  54. finish: "stop",
  55. cost: 0,
  56. tokens: tokens(),
  57. }),
  58. )
  59. send(durableEvent("session.execution.succeeded", { sessionID: "ses_b" }))
  60. send(durableEvent("session.execution.succeeded", { sessionID: "ses_a" }))
  61. },
  62. })
  63. try {
  64. const response = await turn({
  65. fixture,
  66. connection: recordingConnection(updates),
  67. sessionID: "ses_a",
  68. inputID: "input_a",
  69. })
  70. expect(fixture.requests.slice(0, 2).map((request) => request.path)).toEqual([
  71. "/api/event",
  72. "/api/session/ses_a/prompt",
  73. ])
  74. expect(updates).toEqual([
  75. {
  76. sessionId: "ses_a",
  77. update: {
  78. sessionUpdate: "agent_message_chunk",
  79. messageId: "msg_a",
  80. content: { type: "text", text: "accepted" },
  81. },
  82. },
  83. ])
  84. expect(response.stopReason).toBe("end_turn")
  85. } finally {
  86. await fixture.stop()
  87. }
  88. })
  89. test("preserves text and reasoning order before returning the terminal response", async () => {
  90. const firstUpdate = Promise.withResolvers<void>()
  91. const releaseUpdate = Promise.withResolvers<void>()
  92. const allUpdates = Promise.withResolvers<void>()
  93. const releaseSubmit = Promise.withResolvers<void>()
  94. const updates: SessionUpdateParams[] = []
  95. const fixture = createSseFixture({
  96. async onPrompt({ id, send }) {
  97. send(durableEvent("session.input.promoted", { sessionID: "ses_order", inputID: id }))
  98. send(
  99. ephemeralEvent("session.reasoning.delta", {
  100. sessionID: "ses_order",
  101. assistantMessageID: "msg_order",
  102. ordinal: 0,
  103. delta: "think-1",
  104. }),
  105. )
  106. send(
  107. ephemeralEvent("session.text.delta", {
  108. sessionID: "ses_order",
  109. assistantMessageID: "msg_order",
  110. ordinal: 1,
  111. delta: "answer",
  112. }),
  113. )
  114. send(
  115. ephemeralEvent("session.reasoning.delta", {
  116. sessionID: "ses_order",
  117. assistantMessageID: "msg_order",
  118. ordinal: 2,
  119. delta: "think-2",
  120. }),
  121. )
  122. send(
  123. durableEvent("session.step.ended", {
  124. sessionID: "ses_order",
  125. assistantMessageID: "msg_order",
  126. finish: "stop",
  127. cost: 0,
  128. tokens: tokens(),
  129. }),
  130. )
  131. send(durableEvent("session.execution.succeeded", { sessionID: "ses_order" }))
  132. await releaseSubmit.promise
  133. },
  134. })
  135. const connection = {
  136. sessionUpdate: async (update) => {
  137. updates.push(update)
  138. if (updates.length === 1) {
  139. firstUpdate.resolve()
  140. await releaseUpdate.promise
  141. }
  142. if (updates.length === 3) allUpdates.resolve()
  143. },
  144. requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
  145. } satisfies Connection
  146. const result = turn({ fixture, connection, sessionID: "ses_order", inputID: "input_order" })
  147. try {
  148. await withTimeout(firstUpdate.promise, "first ordered update was not delivered")
  149. expect(updates).toHaveLength(1)
  150. expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
  151. releaseUpdate.resolve()
  152. await withTimeout(allUpdates.promise, "ordered updates did not finish")
  153. expect(await Promise.race([result.then(() => "resolved"), Promise.resolve("pending")])).toBe("pending")
  154. expect(fixture.requests.some((request) => request.path.includes("/message/"))).toBe(false)
  155. releaseSubmit.resolve()
  156. const response = await withTimeout(result, "turn did not resolve after admission returned")
  157. expect(
  158. updates.map((item) => {
  159. if (
  160. item.update.sessionUpdate === "agent_message_chunk" ||
  161. item.update.sessionUpdate === "agent_thought_chunk"
  162. ) {
  163. return [
  164. item.update.sessionUpdate,
  165. item.update.content.type === "text" ? item.update.content.text : undefined,
  166. ]
  167. }
  168. return [item.update.sessionUpdate, undefined]
  169. }),
  170. ).toEqual([
  171. ["agent_thought_chunk", "think-1"],
  172. ["agent_message_chunk", "answer"],
  173. ["agent_thought_chunk", "think-2"],
  174. ])
  175. expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
  176. expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
  177. } finally {
  178. releaseUpdate.resolve()
  179. releaseSubmit.resolve()
  180. await result.catch(() => undefined)
  181. await fixture.stop()
  182. }
  183. })
  184. test("projects foreground child session updates onto the parent turn", async () => {
  185. const updates: SessionUpdateParams[] = []
  186. const fixture = createSseFixture({
  187. onPrompt({ id, send }) {
  188. send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
  189. send(
  190. durableEvent("session.created", {
  191. sessionID: "ses_child",
  192. ...childSession("ses_child", "ses_parent", "Explore code"),
  193. }),
  194. )
  195. send(durableEvent("session.execution.started", { sessionID: "ses_child" }))
  196. send(
  197. durableEvent("session.tool.input.started", {
  198. sessionID: "ses_child",
  199. assistantMessageID: "msg_child",
  200. id: "call_read",
  201. name: "read",
  202. }),
  203. )
  204. send(
  205. durableEvent("session.tool.called", {
  206. sessionID: "ses_child",
  207. assistantMessageID: "msg_child",
  208. id: "call_read",
  209. input: { path: "/workspace/src/index.ts" },
  210. executed: false,
  211. }),
  212. )
  213. send(
  214. durableEvent("session.tool.success", {
  215. sessionID: "ses_child",
  216. assistantMessageID: "msg_child",
  217. id: "call_read",
  218. metadata: {},
  219. content: [{ type: "text", text: "source" }],
  220. executed: true,
  221. }),
  222. )
  223. send(durableEvent("session.execution.succeeded", { sessionID: "ses_child" }))
  224. send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
  225. },
  226. })
  227. try {
  228. const response = await turn({
  229. fixture,
  230. connection: recordingConnection(updates),
  231. sessionID: "ses_parent",
  232. inputID: "input_parent",
  233. })
  234. expect(updates.map((item) => [item.sessionId, item.update.sessionUpdate])).toEqual([
  235. ["ses_parent", "tool_call"],
  236. ["ses_parent", "tool_call_update"],
  237. ["ses_parent", "tool_call_update"],
  238. ])
  239. expect(updates.map((item) => ("toolCallId" in item.update ? item.update.toolCallId : undefined))).toEqual([
  240. "ses_child:call_read",
  241. "ses_child:call_read",
  242. "ses_child:call_read",
  243. ])
  244. expect(updates[0]?.update).toMatchObject({
  245. title: "Explore code: read",
  246. _meta: {
  247. "opencode/child-session": {
  248. id: "ses_child",
  249. parentID: "ses_parent",
  250. depth: 1,
  251. title: "Explore code",
  252. },
  253. },
  254. })
  255. expect(response.stopReason).toBe("end_turn")
  256. } finally {
  257. await fixture.stop()
  258. }
  259. })
  260. test("continues child extension updates after the parent turn ends", async () => {
  261. const updates: SessionUpdateParams[] = []
  262. const childUpdates: ChildSessionUpdate[] = []
  263. const completed = Promise.withResolvers<void>()
  264. const fixture = createSseFixture({
  265. onPrompt({ id, send }) {
  266. send(durableEvent("session.input.promoted", { sessionID: "ses_parent", inputID: id }))
  267. send(
  268. durableEvent("session.created", {
  269. sessionID: "ses_background",
  270. ...childSession("ses_background", "ses_parent", "Background research"),
  271. }),
  272. )
  273. send(durableEvent("session.execution.succeeded", { sessionID: "ses_parent" }))
  274. },
  275. })
  276. try {
  277. const response = await turn({
  278. fixture,
  279. connection: recordingConnection(updates),
  280. sessionID: "ses_parent",
  281. inputID: "input_parent",
  282. childSessionUpdate: async (update) => {
  283. childUpdates.push(update)
  284. if (update.type === "status" && update.status === "completed") completed.resolve()
  285. },
  286. })
  287. expect(response.stopReason).toBe("end_turn")
  288. fixture.send(
  289. durableEvent("session.created", {
  290. sessionID: "ses_future",
  291. ...childSession("ses_future", "ses_parent", "Later turn child"),
  292. }),
  293. )
  294. fixture.send(durableEvent("session.execution.started", { sessionID: "ses_future" }))
  295. fixture.send(durableEvent("session.execution.started", { sessionID: "ses_background" }))
  296. fixture.send(
  297. durableEvent("session.tool.input.started", {
  298. sessionID: "ses_background",
  299. assistantMessageID: "msg_background",
  300. id: "call_shell",
  301. name: "shell",
  302. }),
  303. )
  304. fixture.send(
  305. durableEvent("session.tool.called", {
  306. sessionID: "ses_background",
  307. assistantMessageID: "msg_background",
  308. id: "call_shell",
  309. input: { command: "pwd" },
  310. executed: false,
  311. }),
  312. )
  313. fixture.send(
  314. durableEvent("session.tool.success", {
  315. sessionID: "ses_background",
  316. assistantMessageID: "msg_background",
  317. id: "call_shell",
  318. metadata: { exit: 0 },
  319. content: [{ type: "text", text: "/workspace" }],
  320. executed: true,
  321. }),
  322. )
  323. fixture.send(durableEvent("session.execution.succeeded", { sessionID: "ses_background" }))
  324. await withTimeout(completed.promise, "background child completion was not delivered")
  325. expect(updates).toEqual([])
  326. expect(
  327. childUpdates.map((update) =>
  328. update.type === "status" ? [update.type, update.status] : [update.type, update.update.sessionUpdate],
  329. ),
  330. ).toEqual([
  331. ["status", "created"],
  332. ["status", "running"],
  333. ["update", "tool_call"],
  334. ["update", "tool_call_update"],
  335. ["update", "tool_call_update"],
  336. ["status", "completed"],
  337. ])
  338. expect(childUpdates[2]).toMatchObject({
  339. rootSessionId: "ses_parent",
  340. childSessionId: "ses_background",
  341. parentSessionId: "ses_parent",
  342. depth: 1,
  343. title: "Background research",
  344. type: "update",
  345. update: { toolCallId: "ses_background:call_shell" },
  346. })
  347. expect(childUpdates.some((update) => update.childSessionId === "ses_future")).toBe(false)
  348. } finally {
  349. await fixture.stop()
  350. }
  351. })
  352. test("streams tool pending, progress, success, and failure updates", async () => {
  353. const updates: SessionUpdateParams[] = []
  354. const fixture = createSseFixture({
  355. onPrompt({ id, send }) {
  356. send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
  357. send(
  358. durableEvent("session.tool.input.started", {
  359. sessionID: "ses_tools",
  360. assistantMessageID: "msg_tools",
  361. id: "call_ok",
  362. name: "shell",
  363. }),
  364. )
  365. send(
  366. durableEvent("session.tool.called", {
  367. sessionID: "ses_tools",
  368. assistantMessageID: "msg_tools",
  369. id: "call_ok",
  370. input: { command: "printf done", workdir: "sub" },
  371. executed: false,
  372. }),
  373. )
  374. send(
  375. ephemeralEvent("session.tool.progress", {
  376. sessionID: "ses_tools",
  377. assistantMessageID: "msg_tools",
  378. id: "call_ok",
  379. metadata: { phase: 1 },
  380. }),
  381. )
  382. send(
  383. durableEvent("session.tool.success", {
  384. sessionID: "ses_tools",
  385. assistantMessageID: "msg_tools",
  386. id: "call_ok",
  387. metadata: { exit: 0 },
  388. content: [{ type: "text", text: "done" }],
  389. executed: true,
  390. }),
  391. )
  392. send(
  393. durableEvent("session.tool.input.started", {
  394. sessionID: "ses_tools",
  395. assistantMessageID: "msg_tools",
  396. id: "call_fail",
  397. name: "read",
  398. }),
  399. )
  400. send(
  401. durableEvent("session.tool.called", {
  402. sessionID: "ses_tools",
  403. assistantMessageID: "msg_tools",
  404. id: "call_fail",
  405. input: { path: "/workspace/missing.ts" },
  406. executed: false,
  407. }),
  408. )
  409. send(
  410. ephemeralEvent("session.tool.progress", {
  411. sessionID: "ses_tools",
  412. assistantMessageID: "msg_tools",
  413. id: "call_fail",
  414. metadata: { bytes: 0 },
  415. }),
  416. )
  417. send(
  418. durableEvent("session.tool.failed", {
  419. sessionID: "ses_tools",
  420. assistantMessageID: "msg_tools",
  421. id: "call_fail",
  422. error: { type: "tool.error", message: "not found" },
  423. metadata: { bytes: 0 },
  424. content: [{ type: "text", text: "opening" }],
  425. executed: true,
  426. }),
  427. )
  428. send(
  429. durableEvent("session.step.ended", {
  430. sessionID: "ses_tools",
  431. assistantMessageID: "msg_tools",
  432. finish: "stop",
  433. cost: 0,
  434. tokens: tokens(),
  435. }),
  436. )
  437. send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
  438. },
  439. })
  440. try {
  441. const response = await turn({
  442. fixture,
  443. connection: recordingConnection(updates),
  444. sessionID: "ses_tools",
  445. inputID: "input_tools",
  446. })
  447. expect(
  448. updates.map((item) => [
  449. item.update.sessionUpdate,
  450. "status" in item.update ? item.update.status : undefined,
  451. "toolCallId" in item.update ? item.update.toolCallId : undefined,
  452. ]),
  453. ).toEqual([
  454. ["tool_call", "pending", "call_ok"],
  455. ["tool_call_update", "in_progress", "call_ok"],
  456. ["tool_call_update", "in_progress", "call_ok"],
  457. ["tool_call_update", "completed", "call_ok"],
  458. ["tool_call", "pending", "call_fail"],
  459. ["tool_call_update", "in_progress", "call_fail"],
  460. ["tool_call_update", "in_progress", "call_fail"],
  461. ["tool_call_update", "failed", "call_fail"],
  462. ])
  463. expect(updates[1]?.update).toMatchObject({
  464. title: "printf done",
  465. kind: "execute",
  466. locations: [{ path: resolve("/workspace", "sub") }],
  467. rawInput: { command: "printf done", workdir: "sub" },
  468. })
  469. expect(updates[2]?.update).not.toHaveProperty("content")
  470. expect(updates[3]?.update).toMatchObject({
  471. content: [{ type: "content", content: { type: "text", text: "done" } }],
  472. rawOutput: { metadata: { exit: 0 } },
  473. })
  474. expect(updates[7]?.update).toMatchObject({
  475. kind: "read",
  476. locations: [{ path: "/workspace/missing.ts" }],
  477. content: [
  478. { type: "content", content: { type: "text", text: "opening" } },
  479. { type: "content", content: { type: "text", text: "not found" } },
  480. ],
  481. rawOutput: { metadata: { bytes: 0 }, error: "not found" },
  482. })
  483. expect(response.stopReason).toBe("end_turn")
  484. } finally {
  485. await fixture.stop()
  486. }
  487. })
  488. test("replays user, text, reasoning, and tool messages in order", async () => {
  489. const updates: SessionUpdateParams[] = []
  490. const messages = replayFixtureMessages()
  491. const connection = {
  492. sessionUpdate: async (update) => {
  493. updates.push(update)
  494. },
  495. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  496. await replayMessages(connection, "ses_replay", "/workspace", messages)
  497. expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
  498. expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
  499. "user_message_chunk",
  500. "user_message_chunk",
  501. "user_message_chunk",
  502. "agent_message_chunk",
  503. "agent_thought_chunk",
  504. "tool_call",
  505. "tool_call_update",
  506. "tool_call",
  507. "tool_call_update",
  508. "tool_call",
  509. "tool_call_update",
  510. "tool_call",
  511. ])
  512. expect(updates[1]?.update).toMatchObject({
  513. content: {
  514. type: "resource_link",
  515. uri: "file:///workspace/note.md",
  516. name: "note.md",
  517. mimeType: "text/markdown",
  518. },
  519. })
  520. expect(updates[2]?.update).toMatchObject({
  521. content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
  522. })
  523. expect(updates[6]?.update).toMatchObject({
  524. toolCallId: "call_done",
  525. status: "completed",
  526. content: [
  527. { type: "content", content: { type: "text", text: "done" } },
  528. { type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
  529. ],
  530. rawOutput: { metadata: { exit: 0 } },
  531. })
  532. expect(updates[8]?.update).toMatchObject({
  533. toolCallId: "call_running",
  534. status: "in_progress",
  535. title: "pwd",
  536. locations: [{ path: "/workspace" }],
  537. })
  538. expect(updates[10]?.update).toMatchObject({
  539. toolCallId: "call_failed",
  540. status: "failed",
  541. content: [
  542. { type: "content", content: { type: "text", text: "partial" } },
  543. { type: "content", content: { type: "text", text: "failed hard" } },
  544. ],
  545. })
  546. })
  547. test("continues replay after a session update callback rejects", async () => {
  548. const attempts: Array<[string, string]> = []
  549. const connection = {
  550. sessionUpdate: async (params) => {
  551. if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
  552. attempts.push([params.update.toolCallId, params.update.sessionUpdate])
  553. if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
  554. throw new Error("replay send failed")
  555. }
  556. },
  557. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  558. await replayMessages(connection, "ses_replay_failure", "/workspace", [
  559. replayToolMessage("call_first"),
  560. replayToolMessage("call_after"),
  561. ])
  562. expect(attempts).toEqual([
  563. ["call_first", "tool_call"],
  564. ["call_first", "tool_call_update"],
  565. ["call_after", "tool_call"],
  566. ["call_after", "tool_call_update"],
  567. ])
  568. })
  569. test("returns cancelled after an admitted turn is interrupted", async () => {
  570. const submitted = Promise.withResolvers<void>()
  571. const control: TurnControl = { cancelled: false, admission: new AbortController() }
  572. const fixture = createSseFixture({
  573. onPrompt({ id, send }) {
  574. send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
  575. },
  576. onInterrupt({ sessionID, send }) {
  577. send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
  578. },
  579. })
  580. const result = streamTurn({
  581. client: fixture.client,
  582. connection: recordingConnection([]),
  583. sessionID: "ses_cancel",
  584. cwd: "/workspace",
  585. start: { type: "input", id: "input_cancel" },
  586. writeTextFile: false,
  587. control,
  588. submit: async (signal) => {
  589. await fixture.client.session.prompt(
  590. { sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
  591. { signal },
  592. )
  593. submitted.resolve()
  594. },
  595. })
  596. try {
  597. await withTimeout(submitted.promise, "cancel test prompt was not admitted")
  598. control.cancelled = true
  599. control.admission.abort()
  600. await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
  601. const response = await withTimeout(result, "cancelled turn did not terminate")
  602. expect(response).toMatchObject({ stopReason: "cancelled" })
  603. expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
  604. } finally {
  605. await fixture.stop()
  606. }
  607. })
  608. test("returns cancelled when admission is aborted before promotion", async () => {
  609. const submitted = Promise.withResolvers<void>()
  610. const control: TurnControl = { cancelled: false, admission: new AbortController() }
  611. const fixture = createSseFixture({
  612. onPrompt({ signal }) {
  613. submitted.resolve()
  614. return new Promise<void>((resolve) => {
  615. if (signal.aborted) return resolve()
  616. signal.addEventListener("abort", () => resolve(), { once: true })
  617. })
  618. },
  619. })
  620. const result = streamTurn({
  621. client: fixture.client,
  622. connection: recordingConnection([]),
  623. sessionID: "ses_cancel_admission",
  624. cwd: "/workspace",
  625. start: { type: "input", id: "input_cancel_admission" },
  626. writeTextFile: false,
  627. control,
  628. submit: (signal) =>
  629. fixture.client.session.prompt(
  630. { sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
  631. { signal },
  632. ),
  633. })
  634. try {
  635. await withTimeout(submitted.promise, "cancel test prompt was not submitted")
  636. control.cancelled = true
  637. control.admission.abort()
  638. const response = await withTimeout(result, "pre-admission cancellation did not terminate")
  639. expect(response).toMatchObject({ stopReason: "cancelled" })
  640. expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
  641. } finally {
  642. control.cancelled = true
  643. control.admission.abort()
  644. await result.catch(() => undefined)
  645. await fixture.stop()
  646. }
  647. })
  648. test("cancels unsupported session forms so execution can continue", async () => {
  649. const fixture = createSseFixture({
  650. onPrompt({ id, send }) {
  651. send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
  652. send(
  653. ephemeralEvent("form.created", {
  654. form: {
  655. id: "frm_question",
  656. sessionID: "ses_form",
  657. title: "Questions",
  658. metadata: { kind: "question" },
  659. fields: [{ key: "q0", title: "Choice", type: "string" }],
  660. },
  661. }),
  662. )
  663. },
  664. onFormCancel({ sessionID, formID, send }) {
  665. send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
  666. send(durableEvent("session.execution.succeeded", { sessionID }))
  667. },
  668. })
  669. try {
  670. const response = await turn({
  671. fixture,
  672. connection: recordingConnection([]),
  673. sessionID: "ses_form",
  674. inputID: "input_form",
  675. })
  676. expect(response.stopReason).toBe("end_turn")
  677. expect(
  678. fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
  679. ).toBe(true)
  680. } finally {
  681. await fixture.stop()
  682. }
  683. })
  684. })
  685. function recordingConnection(updates: SessionUpdateParams[]) {
  686. return {
  687. sessionUpdate: async (update) => {
  688. updates.push(update)
  689. },
  690. requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
  691. } satisfies Connection
  692. }
  693. function turn(input: {
  694. readonly fixture: Fixture
  695. readonly connection: Connection
  696. readonly sessionID: string
  697. readonly inputID: string
  698. readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
  699. }) {
  700. return streamTurn({
  701. client: input.fixture.client,
  702. connection: input.connection,
  703. sessionID: input.sessionID,
  704. cwd: "/workspace",
  705. start: { type: "input", id: input.inputID },
  706. writeTextFile: false,
  707. control: { cancelled: false, admission: new AbortController() },
  708. childSessionUpdate: input.childSessionUpdate,
  709. submit: (signal) =>
  710. input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
  711. })
  712. }
  713. function childSession(id: string, parentID: string, title: string) {
  714. return {
  715. slug: id,
  716. projectID: "project",
  717. location: { directory: "/workspace" },
  718. parentID,
  719. title,
  720. version: "test",
  721. }
  722. }
  723. function tokens() {
  724. return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
  725. }
  726. function replayFixtureMessages(): SessionMessageInfo[] {
  727. return [
  728. {
  729. id: "msg_user",
  730. type: "user",
  731. text: "hello",
  732. time: { created: 1 },
  733. files: [
  734. {
  735. data: "",
  736. mime: "text/markdown",
  737. name: "note.md",
  738. source: { type: "uri", uri: "file:///workspace/note.md" },
  739. },
  740. {
  741. data: "aGVsbG8=",
  742. mime: "text/plain",
  743. name: "inline.txt",
  744. source: { type: "inline" },
  745. },
  746. ],
  747. },
  748. {
  749. id: "msg_assistant",
  750. type: "assistant",
  751. agent: "build",
  752. model: { providerID: "test", id: "test-model" },
  753. time: { created: 2, completed: 3 },
  754. content: [
  755. { type: "text", text: "answer" },
  756. { type: "reasoning", text: "thinking" },
  757. {
  758. type: "tool",
  759. id: "call_done",
  760. name: "shell",
  761. time: { created: 2, completed: 3 },
  762. state: {
  763. status: "completed",
  764. input: { command: "printf done" },
  765. metadata: { exit: 0 },
  766. content: [
  767. { type: "text", text: "done" },
  768. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
  769. ],
  770. },
  771. },
  772. {
  773. type: "tool",
  774. id: "call_running",
  775. name: "shell",
  776. time: { created: 2, ran: 2 },
  777. state: {
  778. status: "running",
  779. input: { command: "pwd" },
  780. metadata: {},
  781. },
  782. },
  783. {
  784. type: "tool",
  785. id: "call_failed",
  786. name: "read",
  787. time: { created: 2, completed: 3 },
  788. state: {
  789. status: "error",
  790. input: { path: "/workspace/missing.ts" },
  791. metadata: { bytes: 0 },
  792. content: [{ type: "text", text: "partial" }],
  793. error: { type: "tool.error", message: "failed hard" },
  794. },
  795. },
  796. {
  797. type: "tool",
  798. id: "call_streaming",
  799. name: "shell",
  800. time: { created: 2 },
  801. state: { status: "streaming", input: '{"command":' },
  802. },
  803. ],
  804. },
  805. ]
  806. }
  807. function replayToolMessage(id: string) {
  808. return {
  809. id: `msg_${id}`,
  810. type: "assistant",
  811. agent: "build",
  812. model: { providerID: "test", id: "test-model" },
  813. time: { created: 1, completed: 2 },
  814. content: [
  815. {
  816. type: "tool",
  817. id,
  818. name: "shell",
  819. time: { created: 1, completed: 2 },
  820. state: {
  821. status: "completed",
  822. input: { command: "printf done" },
  823. metadata: { exit: 0 },
  824. content: [{ type: "text", text: "done" }],
  825. },
  826. },
  827. ],
  828. } satisfies SessionMessageInfo
  829. }