event.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751
  1. import { describe, expect, it } from "bun:test"
  2. import type { AgentSideConnection } from "@agentclientprotocol/sdk"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import type { Event, Message, OpencodeClient, Part, SessionMessageResponse, ToolPart } from "@kirincode-ai/sdk/v2"
  5. import { Effect, ManagedRuntime } from "effect"
  6. import { ACPEvent } from "@/acp/event"
  7. import * as ACPService from "@/acp/service"
  8. import { Directory } from "@/acp/directory"
  9. import { ACPSession } from "@/acp/session"
  10. type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
  11. type ToolSessionUpdateParams = SessionUpdateParams & {
  12. update: Extract<SessionUpdateParams["update"], { sessionUpdate: "tool_call" | "tool_call_update" }>
  13. }
  14. type GlobalEventEnvelope = {
  15. payload?: Event
  16. }
  17. type DeltaPartType = Extract<Part, { type: "text" | "reasoning" }>["type"]
  18. const pollUntil = async (
  19. check: () => boolean | Promise<boolean>,
  20. message: string,
  21. opts?: { timeoutMs?: number; intervalMs?: number },
  22. ) => {
  23. const started = Date.now()
  24. while (true) {
  25. if (await check()) return
  26. if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
  27. await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
  28. }
  29. }
  30. function makeSessionService() {
  31. return ManagedRuntime.make(LayerNode.compile(ACPSession.node)).runSync(
  32. ACPSession.Service.use((service) => Effect.succeed(service)),
  33. )
  34. }
  35. function createEventStream() {
  36. const queue: GlobalEventEnvelope[] = []
  37. const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = []
  38. const state = { closed: false }
  39. const push = (event: GlobalEventEnvelope) => {
  40. const waiter = waiters.shift()
  41. if (waiter) {
  42. waiter(event)
  43. return
  44. }
  45. queue.push(event)
  46. }
  47. const close = () => {
  48. state.closed = true
  49. for (const waiter of waiters.splice(0)) {
  50. waiter(undefined)
  51. }
  52. }
  53. const stream = async function* (signal?: AbortSignal) {
  54. while (true) {
  55. if (signal?.aborted) return
  56. const next = queue.shift()
  57. if (next) {
  58. yield next
  59. continue
  60. }
  61. if (state.closed) return
  62. const value = await new Promise<GlobalEventEnvelope | undefined>((resolve) => {
  63. waiters.push(resolve)
  64. signal?.addEventListener("abort", () => resolve(undefined), { once: true })
  65. })
  66. if (!value) return
  67. yield value
  68. }
  69. }
  70. return { push, close, stream }
  71. }
  72. function createHarness(messages: Record<string, SessionMessageResponse> = {}) {
  73. const updates: SessionUpdateParams[] = []
  74. const calls = {
  75. eventSubscribe: 0,
  76. message: 0,
  77. }
  78. const events = createEventStream()
  79. const sdk = {
  80. global: {
  81. event: (options?: { signal?: AbortSignal }) => {
  82. calls.eventSubscribe++
  83. return Promise.resolve({ stream: events.stream(options?.signal) })
  84. },
  85. },
  86. session: {
  87. message: (input: { messageID: string }) => {
  88. calls.message++
  89. return Promise.resolve({ data: messages[input.messageID] })
  90. },
  91. get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
  92. messages: () => Promise.resolve({ data: [] }),
  93. },
  94. } as unknown as OpencodeClient
  95. const connection = {
  96. sessionUpdate: (params: SessionUpdateParams) => {
  97. updates.push(params)
  98. return Promise.resolve()
  99. },
  100. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  101. const session = makeSessionService()
  102. const subscription = new ACPEvent.Subscription({ sdk, connection, session })
  103. return { calls, connection, events, sdk, session, subscription, updates }
  104. }
  105. function textDelta(sessionID: string, messageID: string, partID: string, delta: string): Event {
  106. return {
  107. id: `evt_${sessionID}_${messageID}_${partID}_${delta}`,
  108. type: "message.part.delta",
  109. properties: {
  110. sessionID,
  111. messageID,
  112. partID,
  113. field: "text",
  114. delta,
  115. },
  116. }
  117. }
  118. function partUpdated(sessionID: string, messageID: string, partID: string, type: DeltaPartType): Event {
  119. return {
  120. id: `evt_${sessionID}_${messageID}_${partID}`,
  121. type: "message.part.updated",
  122. properties: {
  123. sessionID,
  124. time: Date.now(),
  125. part:
  126. type === "text"
  127. ? {
  128. id: partID,
  129. sessionID,
  130. messageID,
  131. type: "text",
  132. text: "",
  133. }
  134. : {
  135. id: partID,
  136. sessionID,
  137. messageID,
  138. type: "reasoning",
  139. text: "",
  140. time: { start: Date.now() },
  141. },
  142. },
  143. }
  144. }
  145. function toolUpdated(part: ToolPart): Event {
  146. return {
  147. id: `evt_${part.sessionID}_${part.messageID}_${part.id}_${part.state.status}`,
  148. type: "message.part.updated",
  149. properties: {
  150. sessionID: part.sessionID,
  151. time: Date.now(),
  152. part,
  153. },
  154. }
  155. }
  156. function assistantMessage(sessionID: string, messageID: string, partID: string, type: DeltaPartType) {
  157. return {
  158. info: {
  159. id: messageID,
  160. sessionID,
  161. role: "assistant",
  162. time: { created: Date.now() },
  163. parentID: "msg_parent",
  164. modelID: "model",
  165. providerID: "provider",
  166. mode: "build",
  167. agent: "build",
  168. path: { cwd: "/workspace", root: "/workspace" },
  169. cost: 0,
  170. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  171. },
  172. parts: [
  173. type === "text"
  174. ? {
  175. id: partID,
  176. sessionID,
  177. messageID,
  178. type: "text",
  179. text: "",
  180. }
  181. : {
  182. id: partID,
  183. sessionID,
  184. messageID,
  185. type: "reasoning",
  186. text: "",
  187. time: { start: Date.now() },
  188. },
  189. ],
  190. } satisfies SessionMessageResponse
  191. }
  192. function assistantToolMessage(part: ToolPart) {
  193. return {
  194. info: {
  195. id: part.messageID,
  196. sessionID: part.sessionID,
  197. role: "assistant",
  198. time: { created: Date.now() },
  199. parentID: "msg_parent",
  200. modelID: "model",
  201. providerID: "provider",
  202. mode: "build",
  203. agent: "build",
  204. path: { cwd: "/workspace", root: "/workspace" },
  205. cost: 0,
  206. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  207. },
  208. parts: [part],
  209. } satisfies SessionMessageResponse
  210. }
  211. function runningTool(
  212. sessionID: string,
  213. callID: string,
  214. output?: string,
  215. input: Record<string, unknown> = { cmd: "printf hello" },
  216. ) {
  217. return {
  218. id: `part_${callID}`,
  219. sessionID,
  220. messageID: `msg_${callID}`,
  221. type: "tool",
  222. callID,
  223. tool: "bash",
  224. state: {
  225. status: "running",
  226. input,
  227. title: "bash",
  228. ...(output !== undefined ? { metadata: { output } } : {}),
  229. time: { start: Date.now() },
  230. },
  231. } satisfies ToolPart
  232. }
  233. function completedTool(
  234. sessionID: string,
  235. callID: string,
  236. output = "done",
  237. attachments: Extract<ToolPart["state"], { status: "completed" }>["attachments"] = [],
  238. options: {
  239. readonly tool?: string
  240. readonly input?: Record<string, unknown>
  241. readonly metadata?: Record<string, unknown>
  242. } = {},
  243. ) {
  244. return {
  245. id: `part_${callID}`,
  246. sessionID,
  247. messageID: `msg_${callID}`,
  248. type: "tool",
  249. callID,
  250. tool: options.tool ?? "bash",
  251. state: {
  252. status: "completed",
  253. input: options.input ?? { cmd: "printf done" },
  254. output,
  255. title: "bash",
  256. metadata: options.metadata ?? { exit: 0 },
  257. time: { start: Date.now() - 1, end: Date.now() },
  258. ...(attachments.length ? { attachments } : {}),
  259. },
  260. } satisfies ToolPart
  261. }
  262. function errorTool(sessionID: string, callID: string) {
  263. return {
  264. id: `part_${callID}`,
  265. sessionID,
  266. messageID: `msg_${callID}`,
  267. type: "tool",
  268. callID,
  269. tool: "bash",
  270. state: {
  271. status: "error",
  272. input: { cmd: "exit 1" },
  273. error: "failed hard",
  274. metadata: { exit: 1 },
  275. time: { start: Date.now() - 1, end: Date.now() },
  276. },
  277. } satisfies ToolPart
  278. }
  279. function toolUpdates(updates: SessionUpdateParams[]) {
  280. return updates.filter((item): item is ToolSessionUpdateParams => {
  281. return item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
  282. })
  283. }
  284. async function createKnownSession(
  285. session: ACPSession.Interface,
  286. sessionId: string,
  287. part: { messageId: string; partId: string; partType: Part["type"]; role?: Message["role"] },
  288. ) {
  289. await Effect.runPromise(session.create({ id: sessionId, cwd: "/workspace" }))
  290. await Effect.runPromise(
  291. session.recordPartMetadata({
  292. sessionId,
  293. messageId: part.messageId,
  294. partId: part.partId,
  295. partType: part.partType,
  296. role: part.role ?? "assistant",
  297. }),
  298. )
  299. }
  300. describe("acp event routing", () => {
  301. it("routes message.part.delta by sessionID without cross-session pollution", async () => {
  302. const harness = createHarness()
  303. await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
  304. await createKnownSession(harness.session, "ses_b", { messageId: "msg_b", partId: "part_b", partType: "text" })
  305. await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "hello"))
  306. expect(harness.updates.map((update) => update.sessionId)).toEqual(["ses_b"])
  307. expect(harness.updates[0]?.update.sessionUpdate).toBe("agent_message_chunk")
  308. })
  309. it("keeps interleaved sessions isolated for text and reasoning deltas", async () => {
  310. const harness = createHarness()
  311. await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
  312. await createKnownSession(harness.session, "ses_b", {
  313. messageId: "msg_b",
  314. partId: "part_b",
  315. partType: "reasoning",
  316. })
  317. await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A1"))
  318. await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B1"))
  319. await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "A2"))
  320. await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "B2"))
  321. expect(
  322. harness.updates.filter((update) => update.sessionId === "ses_a").map((update) => update.update.sessionUpdate),
  323. ).toEqual(["agent_message_chunk", "agent_message_chunk"])
  324. expect(
  325. harness.updates.filter((update) => update.sessionId === "ses_b").map((update) => update.update.sessionUpdate),
  326. ).toEqual(["agent_thought_chunk", "agent_thought_chunk"])
  327. })
  328. it("does not create extra subscriptions on repeated loadSession", async () => {
  329. const harness = createHarness()
  330. let subscription: ACPEvent.Subscription | undefined
  331. const service = ACPService.make({
  332. sdk: harness.sdk,
  333. connection: harness.connection,
  334. directory: {
  335. get: () =>
  336. Effect.succeed(
  337. Directory.build({
  338. directory: "/workspace",
  339. providers: {},
  340. modes: [],
  341. defaultModeID: "build",
  342. commands: [],
  343. }),
  344. ),
  345. refresh: () =>
  346. Effect.succeed(
  347. Directory.build({
  348. directory: "/workspace",
  349. providers: {},
  350. modes: [],
  351. defaultModeID: "build",
  352. commands: [],
  353. }),
  354. ),
  355. variants: Directory.variants,
  356. },
  357. session: harness.session,
  358. eventSubscription: (started) => {
  359. subscription = started
  360. },
  361. })
  362. await pollUntil(() => harness.calls.eventSubscribe === 1, "event subscription did not start")
  363. await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
  364. await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
  365. await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
  366. expect(harness.calls.eventSubscribe).toBe(1)
  367. subscription?.stop()
  368. harness.events.close()
  369. })
  370. it("does not call sdk.session.message repeatedly when metadata is known", async () => {
  371. const harness = createHarness()
  372. await createKnownSession(harness.session, "ses_a", { messageId: "msg_a", partId: "part_a", partType: "text" })
  373. for (const delta of ["a", "b", "c", "d", "e"]) {
  374. await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", delta))
  375. }
  376. expect(harness.calls.message).toBe(0)
  377. expect(harness.updates).toHaveLength(5)
  378. })
  379. it("fetches unknown part metadata once and reuses it for later deltas", async () => {
  380. const harness = createHarness({
  381. msg_a: assistantMessage("ses_a", "msg_a", "part_a", "text"),
  382. })
  383. await Effect.runPromise(harness.session.create({ id: "ses_a", cwd: "/workspace" }))
  384. await harness.subscription.handle(partUpdated("ses_a", "msg_a", "part_a", "text"))
  385. await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "a"))
  386. await harness.subscription.handle(textDelta("ses_a", "msg_a", "part_a", "b"))
  387. expect(harness.calls.message).toBe(1)
  388. expect(harness.updates).toHaveLength(2)
  389. })
  390. it("replays loaded session messages sequentially and continues after update failures", async () => {
  391. const events = createEventStream()
  392. const updates: SessionUpdateParams[] = []
  393. const connection = {
  394. sessionUpdate: (params: SessionUpdateParams) => {
  395. if (params.update.sessionUpdate === "tool_call" && params.update.toolCallId === "call_slow") {
  396. return new Promise<void>((resolve) => {
  397. setTimeout(() => {
  398. updates.push(params)
  399. resolve()
  400. }, 20)
  401. })
  402. }
  403. if (params.update.sessionUpdate === "tool_call_update" && params.update.toolCallId === "call_slow") {
  404. return Promise.reject(new Error("replay send failed"))
  405. }
  406. updates.push(params)
  407. return Promise.resolve()
  408. },
  409. } satisfies Pick<AgentSideConnection, "sessionUpdate">
  410. let subscription: ACPEvent.Subscription | undefined
  411. const service = ACPService.make({
  412. sdk: {
  413. global: {
  414. event: (options?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(options?.signal) }),
  415. },
  416. session: {
  417. get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
  418. messages: () =>
  419. Promise.resolve({
  420. data: [
  421. assistantToolMessage(completedTool("ses_loaded", "call_slow", "slow")),
  422. assistantToolMessage(completedTool("ses_loaded", "call_after", "after")),
  423. ],
  424. }),
  425. },
  426. } as unknown as OpencodeClient,
  427. connection,
  428. directory: {
  429. get: () =>
  430. Effect.succeed(
  431. Directory.build({
  432. directory: "/workspace",
  433. providers: {},
  434. modes: [],
  435. defaultModeID: "build",
  436. commands: [],
  437. }),
  438. ),
  439. refresh: () =>
  440. Effect.succeed(
  441. Directory.build({
  442. directory: "/workspace",
  443. providers: {},
  444. modes: [],
  445. defaultModeID: "build",
  446. commands: [],
  447. }),
  448. ),
  449. variants: Directory.variants,
  450. },
  451. eventSubscription: (started) => {
  452. subscription = started
  453. },
  454. })
  455. await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
  456. expect(toolUpdates(updates).map((item) => item.update.toolCallId)).toEqual([
  457. "call_slow",
  458. "call_after",
  459. "call_after",
  460. ])
  461. subscription?.stop()
  462. events.close()
  463. })
  464. it("ignores unknown sessions and live user parts without user_message_chunk duplication", async () => {
  465. const harness = createHarness()
  466. await createKnownSession(harness.session, "ses_user", {
  467. messageId: "msg_user",
  468. partId: "part_user",
  469. partType: "text",
  470. role: "user",
  471. })
  472. await harness.subscription.handle(textDelta("ses_missing", "msg_missing", "part_missing", "ignored"))
  473. await harness.subscription.handle(partUpdated("ses_user", "msg_user", "part_live", "text"))
  474. await harness.subscription.handle(textDelta("ses_user", "msg_user", "part_user", "hello"))
  475. expect(harness.updates).toHaveLength(0)
  476. })
  477. it("exposes the shell command on the synthetic pending tool call", async () => {
  478. const harness = createHarness()
  479. await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" }))
  480. await harness.subscription.handle(toolUpdated(runningTool("ses_tool", "call_1", "hello")))
  481. expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
  482. "tool_call",
  483. "tool_call_update",
  484. ])
  485. expect(harness.updates[0]?.update).toMatchObject({
  486. status: "pending",
  487. toolCallId: "call_1",
  488. title: "printf hello",
  489. kind: "execute",
  490. locations: [{ path: "/workspace" }],
  491. rawInput: { cmd: "printf hello", cwd: "/workspace" },
  492. })
  493. expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" })
  494. })
  495. it("includes available input in the synthetic pending tool call", async () => {
  496. const harness = createHarness()
  497. await Effect.runPromise(harness.session.create({ id: "ses_pending_input", cwd: "/workspace" }))
  498. await harness.subscription.handle(
  499. toolUpdated({
  500. id: "part_call_read",
  501. sessionID: "ses_pending_input",
  502. messageID: "msg_call_read",
  503. type: "tool",
  504. callID: "call_read",
  505. tool: "read",
  506. state: {
  507. status: "running",
  508. input: { filePath: "/workspace/file.ts" },
  509. title: "Read file.ts",
  510. time: { start: Date.now() },
  511. },
  512. } satisfies ToolPart),
  513. )
  514. expect(harness.updates[0]?.update).toMatchObject({
  515. sessionUpdate: "tool_call",
  516. toolCallId: "call_read",
  517. status: "pending",
  518. title: "Read file.ts",
  519. kind: "read",
  520. rawInput: { filePath: "/workspace/file.ts" },
  521. locations: [{ path: "/workspace/file.ts" }],
  522. })
  523. })
  524. it("does not emit duplicate synthetic pending after a replayed running tool", async () => {
  525. const harness = createHarness()
  526. await Effect.runPromise(harness.session.create({ id: "ses_replay", cwd: "/workspace" }))
  527. await harness.subscription.replayMessage(assistantToolMessage(runningTool("ses_replay", "call_replay", "first")))
  528. await harness.subscription.handle(toolUpdated(runningTool("ses_replay", "call_replay", "second")))
  529. expect(toolUpdates(harness.updates).filter((item) => item.update.sessionUpdate === "tool_call")).toHaveLength(1)
  530. expect(toolUpdates(harness.updates).map((item) => item.update.sessionUpdate)).toEqual([
  531. "tool_call",
  532. "tool_call_update",
  533. "tool_call_update",
  534. ])
  535. })
  536. it("dedupes shell output snapshots while still sending status-only running updates", async () => {
  537. const harness = createHarness()
  538. await Effect.runPromise(harness.session.create({ id: "ses_shell", cwd: "/workspace" }))
  539. await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
  540. await harness.subscription.handle(toolUpdated(runningTool("ses_shell", "call_shell", "same")))
  541. const updates = toolUpdates(harness.updates)
  542. expect(updates).toHaveLength(3)
  543. expect(updates[1]?.update).toMatchObject({
  544. sessionUpdate: "tool_call_update",
  545. content: [{ type: "content", content: { type: "text", text: "same" } }],
  546. })
  547. expect(updates[2]?.update).toMatchObject({ sessionUpdate: "tool_call_update", status: "in_progress" })
  548. expect("content" in updates[2]!.update).toBe(false)
  549. })
  550. it("clears shell snapshot marker when a tool returns to pending", async () => {
  551. const harness = createHarness()
  552. await Effect.runPromise(harness.session.create({ id: "ses_pending", cwd: "/workspace" }))
  553. await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
  554. await harness.subscription.handle(
  555. toolUpdated({
  556. id: "part_call_pending",
  557. sessionID: "ses_pending",
  558. messageID: "msg_call_pending",
  559. type: "tool",
  560. callID: "call_pending",
  561. tool: "bash",
  562. state: {
  563. status: "pending",
  564. input: { cmd: "printf repeat" },
  565. raw: '{"cmd":"printf repeat"}',
  566. },
  567. }),
  568. )
  569. await harness.subscription.handle(toolUpdated(runningTool("ses_pending", "call_pending", "repeat")))
  570. expect(
  571. toolUpdates(harness.updates)
  572. .filter((item) => item.update.sessionUpdate === "tool_call_update")
  573. .map((item) => ("content" in item.update ? item.update.content : undefined)),
  574. ).toEqual([
  575. [{ type: "content", content: { type: "text", text: "repeat" } }],
  576. [{ type: "content", content: { type: "text", text: "repeat" } }],
  577. ])
  578. })
  579. it("emits completed tool output and rawOutput", async () => {
  580. const harness = createHarness()
  581. await Effect.runPromise(harness.session.create({ id: "ses_done", cwd: "/workspace" }))
  582. await harness.subscription.handle(toolUpdated(completedTool("ses_done", "call_done", "finished")))
  583. expect(harness.updates.at(-1)?.update).toMatchObject({
  584. sessionUpdate: "tool_call_update",
  585. toolCallId: "call_done",
  586. status: "completed",
  587. content: [{ type: "content", content: { type: "text", text: "finished" } }],
  588. rawOutput: { output: "finished", metadata: { exit: 0 } },
  589. })
  590. })
  591. it("emits clean read display content and preserves rawOutput", async () => {
  592. const harness = createHarness()
  593. await Effect.runPromise(harness.session.create({ id: "ses_read", cwd: "/workspace" }))
  594. const output = [
  595. "<path>/workspace/file.ts</path>",
  596. "<type>file</type>",
  597. "<content>",
  598. "1: import { value } from './value'",
  599. "2: export { value }",
  600. "",
  601. "(End of file - total 2 lines)",
  602. "</content>",
  603. ].join("\n")
  604. const metadata = {
  605. display: {
  606. type: "file",
  607. path: "/workspace/file.ts",
  608. text: "import { value } from './value'\nexport { value }",
  609. lineStart: 1,
  610. lineEnd: 2,
  611. totalLines: 2,
  612. truncated: false,
  613. },
  614. }
  615. await harness.subscription.handle(
  616. toolUpdated(
  617. completedTool("ses_read", "call_read", output, [], {
  618. tool: "read",
  619. input: { filePath: "/workspace/file.ts" },
  620. metadata,
  621. }),
  622. ),
  623. )
  624. expect(harness.updates.at(-1)?.update).toMatchObject({
  625. sessionUpdate: "tool_call_update",
  626. toolCallId: "call_read",
  627. status: "completed",
  628. content: [
  629. {
  630. type: "content",
  631. content: { type: "text", text: "import { value } from './value'\nexport { value }" },
  632. },
  633. ],
  634. rawOutput: { output, metadata },
  635. })
  636. })
  637. it("emits error tool output", async () => {
  638. const harness = createHarness()
  639. await Effect.runPromise(harness.session.create({ id: "ses_error", cwd: "/workspace" }))
  640. await harness.subscription.handle(toolUpdated(errorTool("ses_error", "call_error")))
  641. expect(harness.updates.at(-1)?.update).toMatchObject({
  642. sessionUpdate: "tool_call_update",
  643. toolCallId: "call_error",
  644. status: "failed",
  645. content: [{ type: "content", content: { type: "text", text: "failed hard" } }],
  646. rawOutput: { error: "failed hard", metadata: { exit: 1 } },
  647. })
  648. })
  649. it("emits image attachments as ACP image content for live and replayed completed tool updates", async () => {
  650. const harness = createHarness()
  651. const image = Buffer.from("image-data").toString("base64")
  652. const attachment = {
  653. id: "file_image",
  654. sessionID: "ses_image",
  655. messageID: "msg_image",
  656. type: "file",
  657. mime: "image/png",
  658. filename: "image.png",
  659. url: `data:image/png;base64,${image}`,
  660. } as const
  661. await Effect.runPromise(harness.session.create({ id: "ses_image", cwd: "/workspace" }))
  662. await harness.subscription.handle(toolUpdated(completedTool("ses_image", "call_live", "live", [attachment])))
  663. await harness.subscription.replayMessage(
  664. assistantToolMessage(completedTool("ses_image", "call_replayed", "replayed", [attachment])),
  665. )
  666. expect(
  667. toolUpdates(harness.updates)
  668. .filter((item) => item.update.sessionUpdate === "tool_call_update" && item.update.status === "completed")
  669. .map((item) => ("content" in item.update ? item.update.content : [])),
  670. ).toEqual([
  671. [
  672. { type: "content", content: { type: "text", text: "live" } },
  673. { type: "content", content: { type: "image", mimeType: "image/png", data: image } },
  674. ],
  675. [
  676. { type: "content", content: { type: "text", text: "replayed" } },
  677. { type: "content", content: { type: "image", mimeType: "image/png", data: image } },
  678. ],
  679. ])
  680. })
  681. })