subagent-data.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import { describe, expect, test } from "bun:test"
  2. import type { Event } from "@kirincode-ai/sdk/v2"
  3. import { entryBody } from "@/cli/cmd/run/entry.body"
  4. import {
  5. bootstrapSubagentCalls,
  6. bootstrapSubagentData,
  7. createSubagentData,
  8. reduceSubagentData,
  9. snapshotSubagentData,
  10. } from "@/cli/cmd/run/subagent-data"
  11. type SessionMessage = Parameters<typeof bootstrapSubagentData>[0]["messages"][number]
  12. type ChildMessage = Parameters<typeof bootstrapSubagentCalls>[0]["messages"][number]
  13. function visible(commits: Array<Parameters<typeof entryBody>[0]>) {
  14. return commits.flatMap((item) => {
  15. const body = entryBody(item)
  16. if (body.type === "none") {
  17. return []
  18. }
  19. if (body.type === "structured") {
  20. if (body.snapshot.kind === "code" || body.snapshot.kind === "task") {
  21. return [body.snapshot.title]
  22. }
  23. if (body.snapshot.kind === "diff") {
  24. return body.snapshot.items.map((item) => item.title)
  25. }
  26. if (body.snapshot.kind === "todo") {
  27. return ["# Todos"]
  28. }
  29. return ["# Questions"]
  30. }
  31. return [body.content]
  32. })
  33. }
  34. function reduce(data: ReturnType<typeof createSubagentData>, event: unknown) {
  35. return reduceSubagentData({
  36. data,
  37. event: event as Event,
  38. sessionID: "parent-1",
  39. thinking: true,
  40. limits: {},
  41. })
  42. }
  43. function taskMessage(sessionID: string, status: "running" | "completed" | "interrupted" = "completed"): SessionMessage {
  44. if (status === "running") {
  45. return {
  46. parts: [
  47. {
  48. id: `part-${sessionID}`,
  49. sessionID: "parent-1",
  50. messageID: `msg-${sessionID}`,
  51. type: "tool",
  52. callID: `call-${sessionID}`,
  53. tool: "task",
  54. state: {
  55. status: "running",
  56. input: {
  57. description: "Scan reducer paths",
  58. subagent_type: "explore",
  59. },
  60. title: "Reducer touchpoints",
  61. metadata: {
  62. sessionId: sessionID,
  63. toolcalls: 4,
  64. },
  65. time: { start: 1 },
  66. },
  67. },
  68. ],
  69. }
  70. }
  71. if (status === "interrupted") {
  72. return {
  73. parts: [
  74. {
  75. id: `part-${sessionID}`,
  76. sessionID: "parent-1",
  77. messageID: `msg-${sessionID}`,
  78. type: "tool",
  79. callID: `call-${sessionID}`,
  80. tool: "task",
  81. state: {
  82. status: "error",
  83. input: {
  84. description: "Scan reducer paths",
  85. subagent_type: "explore",
  86. },
  87. error: "Tool execution aborted",
  88. metadata: {
  89. sessionId: sessionID,
  90. toolcalls: 4,
  91. interrupted: true,
  92. },
  93. time: { start: 1, end: 2 },
  94. },
  95. },
  96. ],
  97. }
  98. }
  99. return {
  100. parts: [
  101. {
  102. id: `part-${sessionID}`,
  103. sessionID: "parent-1",
  104. messageID: `msg-${sessionID}`,
  105. type: "tool",
  106. callID: `call-${sessionID}`,
  107. tool: "task",
  108. state: {
  109. status: "completed",
  110. input: {
  111. description: "Scan reducer paths",
  112. subagent_type: "explore",
  113. },
  114. output: "",
  115. title: "Reducer touchpoints",
  116. metadata: {
  117. sessionId: sessionID,
  118. toolcalls: 4,
  119. },
  120. time: { start: 1, end: 2 },
  121. },
  122. },
  123. ],
  124. }
  125. }
  126. function question(id: string, sessionID: string) {
  127. return {
  128. id,
  129. sessionID,
  130. questions: [
  131. {
  132. question: "Mode?",
  133. header: "Mode",
  134. options: [{ label: "Fast", description: "Quick pass" }],
  135. multiple: false,
  136. },
  137. ],
  138. }
  139. }
  140. function childMessage(input: {
  141. messageID: string
  142. sessionID: string
  143. role: "user" | "assistant"
  144. parts: ChildMessage["parts"]
  145. }) {
  146. if (input.role === "user") {
  147. return {
  148. info: {
  149. id: input.messageID,
  150. sessionID: input.sessionID,
  151. role: "user",
  152. time: {
  153. created: 1,
  154. },
  155. agent: "test",
  156. model: {
  157. providerID: "openai",
  158. modelID: "gpt-5",
  159. },
  160. },
  161. parts: input.parts,
  162. } satisfies ChildMessage
  163. }
  164. return {
  165. info: {
  166. id: input.messageID,
  167. sessionID: input.sessionID,
  168. role: "assistant",
  169. time: {
  170. created: 2,
  171. completed: 3,
  172. },
  173. parentID: "msg-user-1",
  174. providerID: "openai",
  175. modelID: "gpt-5",
  176. mode: "default",
  177. agent: "explore",
  178. path: {
  179. cwd: "/tmp",
  180. root: "/tmp",
  181. },
  182. cost: 0,
  183. tokens: {
  184. input: 1,
  185. output: 1,
  186. reasoning: 0,
  187. cache: {
  188. read: 0,
  189. write: 0,
  190. },
  191. },
  192. finish: "stop",
  193. },
  194. parts: input.parts,
  195. } satisfies ChildMessage
  196. }
  197. describe("run subagent data", () => {
  198. test("bootstraps tabs and child blockers from parent task parts", () => {
  199. const data = createSubagentData()
  200. expect(
  201. bootstrapSubagentData({
  202. data,
  203. messages: [taskMessage("child-1")],
  204. children: [{ id: "child-1" }, { id: "child-2" }],
  205. permissions: [
  206. {
  207. id: "perm-1",
  208. sessionID: "child-1",
  209. permission: "read",
  210. patterns: ["src/**/*.ts"],
  211. metadata: {},
  212. always: [],
  213. },
  214. {
  215. id: "perm-2",
  216. sessionID: "other",
  217. permission: "read",
  218. patterns: ["src/**/*.ts"],
  219. metadata: {},
  220. always: [],
  221. },
  222. ],
  223. questions: [question("question-1", "child-1"), question("question-2", "other")],
  224. }),
  225. ).toBe(true)
  226. const snapshot = snapshotSubagentData(data)
  227. expect(snapshot.tabs).toEqual([
  228. expect.objectContaining({
  229. sessionID: "child-1",
  230. label: "Explore",
  231. description: "Scan reducer paths",
  232. title: "Reducer touchpoints",
  233. status: "completed",
  234. toolCalls: 4,
  235. }),
  236. ])
  237. expect(snapshot.details).toEqual({
  238. "child-1": {
  239. sessionID: "child-1",
  240. commits: [],
  241. },
  242. })
  243. expect(snapshot.permissions.map((item) => item.id)).toEqual(["perm-1"])
  244. expect(snapshot.questions.map((item) => item.id)).toEqual(["question-1"])
  245. })
  246. test("marks interrupted task tabs as cancelled during bootstrap", () => {
  247. const data = createSubagentData()
  248. bootstrapSubagentData({
  249. data,
  250. messages: [taskMessage("child-1", "interrupted")],
  251. children: [{ id: "child-1" }],
  252. permissions: [],
  253. questions: [],
  254. })
  255. expect(snapshotSubagentData(data).tabs).toEqual([
  256. expect.objectContaining({
  257. sessionID: "child-1",
  258. status: "cancelled",
  259. }),
  260. ])
  261. })
  262. test("captures child activity and blocker metadata in the footer detail state", () => {
  263. const data = createSubagentData()
  264. bootstrapSubagentData({
  265. data,
  266. messages: [taskMessage("child-1", "running")],
  267. children: [{ id: "child-1" }],
  268. permissions: [],
  269. questions: [],
  270. })
  271. reduce(data, {
  272. type: "message.part.updated",
  273. properties: {
  274. part: {
  275. id: "txt-user-1",
  276. messageID: "msg-user-1",
  277. sessionID: "child-1",
  278. type: "text",
  279. text: "Inspect footer tabs",
  280. },
  281. },
  282. })
  283. reduce(data, {
  284. type: "message.updated",
  285. properties: {
  286. sessionID: "child-1",
  287. info: {
  288. id: "msg-user-1",
  289. role: "user",
  290. },
  291. },
  292. })
  293. reduce(data, {
  294. type: "message.updated",
  295. properties: {
  296. sessionID: "child-1",
  297. info: {
  298. id: "msg-assistant-1",
  299. role: "assistant",
  300. },
  301. },
  302. })
  303. reduce(data, {
  304. type: "message.part.updated",
  305. properties: {
  306. part: {
  307. id: "reason-1",
  308. messageID: "msg-assistant-1",
  309. sessionID: "child-1",
  310. type: "reasoning",
  311. text: "planning next steps",
  312. time: { start: 1 },
  313. },
  314. },
  315. })
  316. reduce(data, {
  317. type: "message.part.updated",
  318. properties: {
  319. part: {
  320. id: "tool-1",
  321. messageID: "msg-assistant-1",
  322. sessionID: "child-1",
  323. type: "tool",
  324. callID: "call-1",
  325. tool: "bash",
  326. state: {
  327. status: "running",
  328. input: {
  329. command: "git status --short",
  330. },
  331. time: { start: 1 },
  332. },
  333. },
  334. },
  335. })
  336. reduce(data, {
  337. type: "permission.asked",
  338. properties: {
  339. id: "perm-1",
  340. sessionID: "child-1",
  341. permission: "bash",
  342. patterns: ["git status --short"],
  343. metadata: {},
  344. always: [],
  345. tool: {
  346. messageID: "msg-assistant-1",
  347. callID: "call-1",
  348. },
  349. },
  350. })
  351. reduce(data, {
  352. type: "message.part.updated",
  353. properties: {
  354. part: {
  355. id: "txt-1",
  356. messageID: "msg-assistant-1",
  357. sessionID: "child-1",
  358. type: "text",
  359. text: "hello",
  360. },
  361. },
  362. })
  363. reduce(data, {
  364. type: "message.part.delta",
  365. properties: {
  366. sessionID: "child-1",
  367. messageID: "msg-assistant-1",
  368. partID: "txt-1",
  369. field: "text",
  370. delta: " world",
  371. },
  372. })
  373. const snapshot = snapshotSubagentData(data)
  374. expect(snapshot.tabs).toEqual([expect.objectContaining({ sessionID: "child-1", status: "running" })])
  375. expect(visible(snapshot.details["child-1"]?.commits ?? [])).toEqual([
  376. "› Inspect footer tabs",
  377. "_Thinking:_ planning next steps",
  378. "$ git status --short",
  379. "hello world",
  380. ])
  381. expect(snapshot.permissions).toEqual([
  382. expect.objectContaining({
  383. id: "perm-1",
  384. metadata: {
  385. input: {
  386. command: "git status --short",
  387. },
  388. },
  389. }),
  390. ])
  391. expect(snapshot.questions).toEqual([])
  392. })
  393. test("replays bootstrapped child session messages into inspector commits", () => {
  394. const data = createSubagentData()
  395. bootstrapSubagentData({
  396. data,
  397. messages: [taskMessage("child-1", "completed")],
  398. children: [{ id: "child-1" }],
  399. permissions: [],
  400. questions: [],
  401. })
  402. expect(
  403. bootstrapSubagentCalls({
  404. data,
  405. sessionID: "child-1",
  406. messages: [
  407. childMessage({
  408. messageID: "msg-user-1",
  409. sessionID: "child-1",
  410. role: "user",
  411. parts: [
  412. {
  413. id: "txt-user-1",
  414. messageID: "msg-user-1",
  415. sessionID: "child-1",
  416. type: "text",
  417. text: "Inspect footer tabs",
  418. time: { start: 1, end: 1 },
  419. },
  420. ],
  421. }),
  422. childMessage({
  423. messageID: "msg-assistant-1",
  424. sessionID: "child-1",
  425. role: "assistant",
  426. parts: [
  427. {
  428. id: "reason-1",
  429. messageID: "msg-assistant-1",
  430. sessionID: "child-1",
  431. type: "reasoning",
  432. text: "planning next steps",
  433. time: { start: 2, end: 2 },
  434. },
  435. {
  436. id: "txt-1",
  437. messageID: "msg-assistant-1",
  438. sessionID: "child-1",
  439. type: "text",
  440. text: "hello world",
  441. time: { start: 2, end: 3 },
  442. },
  443. ],
  444. }),
  445. ],
  446. thinking: true,
  447. limits: {},
  448. }),
  449. ).toBe(true)
  450. expect(visible(snapshotSubagentData(data).details["child-1"]?.commits ?? [])).toEqual([
  451. "› Inspect footer tabs",
  452. "_Thinking:_ planning next steps",
  453. "hello world",
  454. ])
  455. })
  456. test("marks a running tab cancelled when the child session aborts", () => {
  457. const data = createSubagentData()
  458. bootstrapSubagentData({
  459. data,
  460. messages: [taskMessage("child-1", "running")],
  461. children: [{ id: "child-1" }],
  462. permissions: [],
  463. questions: [],
  464. })
  465. reduce(data, {
  466. type: "message.updated",
  467. properties: {
  468. sessionID: "child-1",
  469. info: {
  470. id: "msg-assistant-1",
  471. sessionID: "child-1",
  472. role: "assistant",
  473. time: {
  474. created: 1,
  475. completed: 2,
  476. },
  477. error: {
  478. name: "MessageAbortedError",
  479. data: {
  480. message: "Aborted",
  481. },
  482. },
  483. parentID: "msg-user-1",
  484. providerID: "openai",
  485. modelID: "gpt-5",
  486. mode: "default",
  487. agent: "explore",
  488. path: {
  489. cwd: "/tmp",
  490. root: "/tmp",
  491. },
  492. cost: 0,
  493. tokens: {
  494. input: 1,
  495. output: 1,
  496. reasoning: 0,
  497. cache: {
  498. read: 0,
  499. write: 0,
  500. },
  501. },
  502. finish: "error",
  503. },
  504. },
  505. })
  506. expect(snapshotSubagentData(data).tabs).toEqual([
  507. expect.objectContaining({
  508. sessionID: "child-1",
  509. status: "cancelled",
  510. }),
  511. ])
  512. })
  513. })