event-behavior.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  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 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("streams tool pending, progress, success, and failure updates", async () => {
  185. const updates: SessionUpdateParams[] = []
  186. const fixture = createSseFixture({
  187. onPrompt({ id, send }) {
  188. send(durableEvent("session.input.promoted", { sessionID: "ses_tools", inputID: id }))
  189. send(
  190. durableEvent("session.tool.input.started", {
  191. sessionID: "ses_tools",
  192. assistantMessageID: "msg_tools",
  193. callID: "call_ok",
  194. name: "shell",
  195. }),
  196. )
  197. send(
  198. durableEvent("session.tool.called", {
  199. sessionID: "ses_tools",
  200. assistantMessageID: "msg_tools",
  201. callID: "call_ok",
  202. input: { command: "printf done", workdir: "sub" },
  203. executed: false,
  204. }),
  205. )
  206. send(
  207. ephemeralEvent("session.tool.progress", {
  208. sessionID: "ses_tools",
  209. assistantMessageID: "msg_tools",
  210. callID: "call_ok",
  211. metadata: { phase: 1 },
  212. }),
  213. )
  214. send(
  215. durableEvent("session.tool.success", {
  216. sessionID: "ses_tools",
  217. assistantMessageID: "msg_tools",
  218. callID: "call_ok",
  219. metadata: { exit: 0 },
  220. content: [{ type: "text", text: "done" }],
  221. executed: true,
  222. }),
  223. )
  224. send(
  225. durableEvent("session.tool.input.started", {
  226. sessionID: "ses_tools",
  227. assistantMessageID: "msg_tools",
  228. callID: "call_fail",
  229. name: "read",
  230. }),
  231. )
  232. send(
  233. durableEvent("session.tool.called", {
  234. sessionID: "ses_tools",
  235. assistantMessageID: "msg_tools",
  236. callID: "call_fail",
  237. input: { path: "/workspace/missing.ts" },
  238. executed: false,
  239. }),
  240. )
  241. send(
  242. ephemeralEvent("session.tool.progress", {
  243. sessionID: "ses_tools",
  244. assistantMessageID: "msg_tools",
  245. callID: "call_fail",
  246. metadata: { bytes: 0 },
  247. }),
  248. )
  249. send(
  250. durableEvent("session.tool.failed", {
  251. sessionID: "ses_tools",
  252. assistantMessageID: "msg_tools",
  253. callID: "call_fail",
  254. error: { type: "tool.error", message: "not found" },
  255. metadata: { bytes: 0 },
  256. content: [{ type: "text", text: "opening" }],
  257. executed: true,
  258. }),
  259. )
  260. send(
  261. durableEvent("session.step.ended", {
  262. sessionID: "ses_tools",
  263. assistantMessageID: "msg_tools",
  264. finish: "stop",
  265. cost: 0,
  266. tokens: tokens(),
  267. }),
  268. )
  269. send(durableEvent("session.execution.succeeded", { sessionID: "ses_tools" }))
  270. },
  271. })
  272. try {
  273. const response = await turn({
  274. fixture,
  275. connection: recordingConnection(updates),
  276. sessionID: "ses_tools",
  277. inputID: "input_tools",
  278. })
  279. expect(
  280. updates.map((item) => [
  281. item.update.sessionUpdate,
  282. "status" in item.update ? item.update.status : undefined,
  283. "toolCallId" in item.update ? item.update.toolCallId : undefined,
  284. ]),
  285. ).toEqual([
  286. ["tool_call", "pending", "call_ok"],
  287. ["tool_call_update", "in_progress", "call_ok"],
  288. ["tool_call_update", "in_progress", "call_ok"],
  289. ["tool_call_update", "completed", "call_ok"],
  290. ["tool_call", "pending", "call_fail"],
  291. ["tool_call_update", "in_progress", "call_fail"],
  292. ["tool_call_update", "in_progress", "call_fail"],
  293. ["tool_call_update", "failed", "call_fail"],
  294. ])
  295. expect(updates[1]?.update).toMatchObject({
  296. title: "printf done",
  297. kind: "execute",
  298. locations: [{ path: resolve("/workspace", "sub") }],
  299. rawInput: { command: "printf done", workdir: "sub" },
  300. })
  301. expect(updates[2]?.update).not.toHaveProperty("content")
  302. expect(updates[3]?.update).toMatchObject({
  303. content: [{ type: "content", content: { type: "text", text: "done" } }],
  304. rawOutput: { metadata: { exit: 0 } },
  305. })
  306. expect(updates[7]?.update).toMatchObject({
  307. kind: "read",
  308. locations: [{ path: "/workspace/missing.ts" }],
  309. content: [
  310. { type: "content", content: { type: "text", text: "opening" } },
  311. { type: "content", content: { type: "text", text: "not found" } },
  312. ],
  313. rawOutput: { metadata: { bytes: 0 }, error: "not found" },
  314. })
  315. expect(response.stopReason).toBe("end_turn")
  316. } finally {
  317. await fixture.stop()
  318. }
  319. })
  320. test("replays user, text, reasoning, and tool messages in order", async () => {
  321. const updates: SessionUpdateParams[] = []
  322. const messages = replayFixtureMessages()
  323. const connection = {
  324. sessionUpdate: async (update) => {
  325. updates.push(update)
  326. },
  327. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  328. await replayMessages(connection, "ses_replay", "/workspace", messages)
  329. expect(updates.every((update) => update.sessionId === "ses_replay")).toBe(true)
  330. expect(updates.map((item) => item.update.sessionUpdate)).toEqual([
  331. "user_message_chunk",
  332. "user_message_chunk",
  333. "user_message_chunk",
  334. "agent_message_chunk",
  335. "agent_thought_chunk",
  336. "tool_call",
  337. "tool_call_update",
  338. "tool_call",
  339. "tool_call_update",
  340. "tool_call",
  341. "tool_call_update",
  342. "tool_call",
  343. ])
  344. expect(updates[1]?.update).toMatchObject({
  345. content: {
  346. type: "resource_link",
  347. uri: "file:///workspace/note.md",
  348. name: "note.md",
  349. mimeType: "text/markdown",
  350. },
  351. })
  352. expect(updates[2]?.update).toMatchObject({
  353. content: { type: "resource", resource: { mimeType: "text/plain", text: "hello" } },
  354. })
  355. expect(updates[6]?.update).toMatchObject({
  356. toolCallId: "call_done",
  357. status: "completed",
  358. content: [
  359. { type: "content", content: { type: "text", text: "done" } },
  360. { type: "content", content: { type: "image", mimeType: "image/png", data: "AAAA" } },
  361. ],
  362. rawOutput: { metadata: { exit: 0 } },
  363. })
  364. expect(updates[8]?.update).toMatchObject({
  365. toolCallId: "call_running",
  366. status: "in_progress",
  367. title: "pwd",
  368. locations: [{ path: "/workspace" }],
  369. })
  370. expect(updates[10]?.update).toMatchObject({
  371. toolCallId: "call_failed",
  372. status: "failed",
  373. content: [
  374. { type: "content", content: { type: "text", text: "partial" } },
  375. { type: "content", content: { type: "text", text: "failed hard" } },
  376. ],
  377. })
  378. })
  379. test("continues replay after a session update callback rejects", async () => {
  380. const attempts: Array<[string, string]> = []
  381. const connection = {
  382. sessionUpdate: async (params) => {
  383. if (params.update.sessionUpdate !== "tool_call" && params.update.sessionUpdate !== "tool_call_update") return
  384. attempts.push([params.update.toolCallId, params.update.sessionUpdate])
  385. if (params.update.toolCallId === "call_first" && params.update.sessionUpdate === "tool_call_update") {
  386. throw new Error("replay send failed")
  387. }
  388. },
  389. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  390. await replayMessages(connection, "ses_replay_failure", "/workspace", [
  391. replayToolMessage("call_first"),
  392. replayToolMessage("call_after"),
  393. ])
  394. expect(attempts).toEqual([
  395. ["call_first", "tool_call"],
  396. ["call_first", "tool_call_update"],
  397. ["call_after", "tool_call"],
  398. ["call_after", "tool_call_update"],
  399. ])
  400. })
  401. test("returns cancelled after an admitted turn is interrupted", async () => {
  402. const submitted = Promise.withResolvers<void>()
  403. const control: TurnControl = { cancelled: false, admission: new AbortController() }
  404. const fixture = createSseFixture({
  405. onPrompt({ id, send }) {
  406. send(durableEvent("session.input.promoted", { sessionID: "ses_cancel", inputID: id }))
  407. },
  408. onInterrupt({ sessionID, send }) {
  409. send(durableEvent("session.execution.interrupted", { sessionID, reason: "user" }))
  410. },
  411. })
  412. const result = streamTurn({
  413. client: fixture.client,
  414. connection: recordingConnection([]),
  415. sessionID: "ses_cancel",
  416. cwd: "/workspace",
  417. start: { type: "input", id: "input_cancel" },
  418. writeTextFile: false,
  419. control,
  420. submit: async (signal) => {
  421. await fixture.client.session.prompt(
  422. { sessionID: "ses_cancel", id: "input_cancel", text: "cancel me" },
  423. { signal },
  424. )
  425. submitted.resolve()
  426. },
  427. })
  428. try {
  429. await withTimeout(submitted.promise, "cancel test prompt was not admitted")
  430. control.cancelled = true
  431. control.admission.abort()
  432. await fixture.client.session.interrupt({ sessionID: "ses_cancel" })
  433. const response = await withTimeout(result, "cancelled turn did not terminate")
  434. expect(response).toMatchObject({ stopReason: "cancelled" })
  435. expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
  436. } finally {
  437. await fixture.stop()
  438. }
  439. })
  440. test("returns cancelled when admission is aborted before promotion", async () => {
  441. const submitted = Promise.withResolvers<void>()
  442. const control: TurnControl = { cancelled: false, admission: new AbortController() }
  443. const fixture = createSseFixture({
  444. onPrompt({ signal }) {
  445. submitted.resolve()
  446. return new Promise<void>((resolve) => {
  447. if (signal.aborted) return resolve()
  448. signal.addEventListener("abort", () => resolve(), { once: true })
  449. })
  450. },
  451. })
  452. const result = streamTurn({
  453. client: fixture.client,
  454. connection: recordingConnection([]),
  455. sessionID: "ses_cancel_admission",
  456. cwd: "/workspace",
  457. start: { type: "input", id: "input_cancel_admission" },
  458. writeTextFile: false,
  459. control,
  460. submit: (signal) =>
  461. fixture.client.session.prompt(
  462. { sessionID: "ses_cancel_admission", id: "input_cancel_admission", text: "cancel me" },
  463. { signal },
  464. ),
  465. })
  466. try {
  467. await withTimeout(submitted.promise, "cancel test prompt was not submitted")
  468. control.cancelled = true
  469. control.admission.abort()
  470. const response = await withTimeout(result, "pre-admission cancellation did not terminate")
  471. expect(response).toMatchObject({ stopReason: "cancelled" })
  472. expect(fixture.requests.filter((request) => request.path.endsWith("/interrupt"))).toHaveLength(1)
  473. } finally {
  474. control.cancelled = true
  475. control.admission.abort()
  476. await result.catch(() => undefined)
  477. await fixture.stop()
  478. }
  479. })
  480. test("cancels unsupported session forms so execution can continue", async () => {
  481. const fixture = createSseFixture({
  482. onPrompt({ id, send }) {
  483. send(durableEvent("session.input.promoted", { sessionID: "ses_form", inputID: id }))
  484. send(
  485. ephemeralEvent("form.created", {
  486. form: {
  487. id: "frm_question",
  488. sessionID: "ses_form",
  489. title: "Questions",
  490. metadata: { kind: "question" },
  491. fields: [{ key: "q0", title: "Choice", type: "string" }],
  492. },
  493. }),
  494. )
  495. },
  496. onFormCancel({ sessionID, formID, send }) {
  497. send(ephemeralEvent("form.cancelled", { sessionID, id: formID }))
  498. send(durableEvent("session.execution.succeeded", { sessionID }))
  499. },
  500. })
  501. try {
  502. const response = await turn({
  503. fixture,
  504. connection: recordingConnection([]),
  505. sessionID: "ses_form",
  506. inputID: "input_form",
  507. })
  508. expect(response.stopReason).toBe("end_turn")
  509. expect(
  510. fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
  511. ).toBe(true)
  512. } finally {
  513. await fixture.stop()
  514. }
  515. })
  516. })
  517. function recordingConnection(updates: SessionUpdateParams[]) {
  518. return {
  519. sessionUpdate: async (update) => {
  520. updates.push(update)
  521. },
  522. requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
  523. } satisfies Connection
  524. }
  525. function turn(input: {
  526. readonly fixture: Fixture
  527. readonly connection: Connection
  528. readonly sessionID: string
  529. readonly inputID: string
  530. }) {
  531. return streamTurn({
  532. client: input.fixture.client,
  533. connection: input.connection,
  534. sessionID: input.sessionID,
  535. cwd: "/workspace",
  536. start: { type: "input", id: input.inputID },
  537. writeTextFile: false,
  538. control: { cancelled: false, admission: new AbortController() },
  539. submit: (signal) =>
  540. input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
  541. })
  542. }
  543. function tokens() {
  544. return { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }
  545. }
  546. function replayFixtureMessages(): SessionMessageInfo[] {
  547. return [
  548. {
  549. id: "msg_user",
  550. type: "user",
  551. text: "hello",
  552. time: { created: 1 },
  553. files: [
  554. {
  555. data: "",
  556. mime: "text/markdown",
  557. name: "note.md",
  558. source: { type: "uri", uri: "file:///workspace/note.md" },
  559. },
  560. {
  561. data: "aGVsbG8=",
  562. mime: "text/plain",
  563. name: "inline.txt",
  564. source: { type: "inline" },
  565. },
  566. ],
  567. },
  568. {
  569. id: "msg_assistant",
  570. type: "assistant",
  571. agent: "build",
  572. model: { providerID: "test", id: "test-model" },
  573. time: { created: 2, completed: 3 },
  574. content: [
  575. { type: "text", text: "answer" },
  576. { type: "reasoning", text: "thinking" },
  577. {
  578. type: "tool",
  579. id: "call_done",
  580. name: "shell",
  581. time: { created: 2, completed: 3 },
  582. state: {
  583. status: "completed",
  584. input: { command: "printf done" },
  585. metadata: { exit: 0 },
  586. content: [
  587. { type: "text", text: "done" },
  588. { type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "image.png" },
  589. ],
  590. },
  591. },
  592. {
  593. type: "tool",
  594. id: "call_running",
  595. name: "shell",
  596. time: { created: 2, ran: 2 },
  597. state: {
  598. status: "running",
  599. input: { command: "pwd" },
  600. metadata: {},
  601. },
  602. },
  603. {
  604. type: "tool",
  605. id: "call_failed",
  606. name: "read",
  607. time: { created: 2, completed: 3 },
  608. state: {
  609. status: "error",
  610. input: { path: "/workspace/missing.ts" },
  611. metadata: { bytes: 0 },
  612. content: [{ type: "text", text: "partial" }],
  613. error: { type: "tool.error", message: "failed hard" },
  614. },
  615. },
  616. {
  617. type: "tool",
  618. id: "call_streaming",
  619. name: "shell",
  620. time: { created: 2 },
  621. state: { status: "streaming", input: '{"command":' },
  622. },
  623. ],
  624. },
  625. ]
  626. }
  627. function replayToolMessage(id: string) {
  628. return {
  629. id: `msg_${id}`,
  630. type: "assistant",
  631. agent: "build",
  632. model: { providerID: "test", id: "test-model" },
  633. time: { created: 1, completed: 2 },
  634. content: [
  635. {
  636. type: "tool",
  637. id,
  638. name: "shell",
  639. time: { created: 1, completed: 2 },
  640. state: {
  641. status: "completed",
  642. input: { command: "printf done" },
  643. metadata: { exit: 0 },
  644. content: [{ type: "text", text: "done" }],
  645. },
  646. },
  647. ],
  648. } satisfies SessionMessageInfo
  649. }