kimaki 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (302) hide show
  1. package/dist/agent-model.e2e.test.js +250 -164
  2. package/dist/anthropic-account-identity.js +9 -1
  3. package/dist/anthropic-auth-plugin.js +10 -13
  4. package/dist/anthropic-auth-plugin.test.js +17 -0
  5. package/dist/anthropic-auth-state.js +1 -0
  6. package/dist/bash-tool-schema-plugin.js +32 -0
  7. package/dist/bash-tool-schema-plugin.test.js +49 -0
  8. package/dist/bin.js +73 -19
  9. package/dist/cli-commands/maintenance.js +46 -7
  10. package/dist/cli-commands/project.js +11 -27
  11. package/dist/cli-commands/send.js +30 -1
  12. package/dist/cli-commands/session.js +322 -90
  13. package/dist/cli-commands/task.js +34 -15
  14. package/dist/cli-parsing.test.js +83 -4
  15. package/dist/cli-runner.js +44 -8
  16. package/dist/cli-runner.test.js +27 -0
  17. package/dist/cli-send-thread.e2e.test.js +9 -9
  18. package/dist/cli.js +32 -0
  19. package/dist/commands/abort.js +5 -1
  20. package/dist/commands/action-buttons.js +2 -1
  21. package/dist/commands/add-project.js +4 -7
  22. package/dist/commands/agent.js +164 -32
  23. package/dist/commands/ask-question.js +23 -33
  24. package/dist/commands/ask-question.test.js +43 -1
  25. package/dist/commands/btw.js +28 -6
  26. package/dist/commands/file-upload.js +2 -1
  27. package/dist/commands/fork-subagent.js +2 -4
  28. package/dist/commands/fork.js +4 -4
  29. package/dist/commands/last-sessions.js +17 -10
  30. package/dist/commands/login.js +6 -0
  31. package/dist/commands/merge-worktree.js +47 -14
  32. package/dist/commands/model-variant.js +8 -1
  33. package/dist/commands/model.js +16 -9
  34. package/dist/commands/multioauth.js +1 -0
  35. package/dist/commands/new-worktree.js +170 -102
  36. package/dist/commands/queue.js +3 -2
  37. package/dist/commands/resume.js +4 -4
  38. package/dist/commands/session.js +78 -63
  39. package/dist/commands/user-command.js +6 -8
  40. package/dist/commands/worktrees.js +5 -13
  41. package/dist/config.js +16 -1
  42. package/dist/cpu-profiler.js +211 -0
  43. package/dist/cpu-profiler.test.js +160 -0
  44. package/dist/database.js +152 -3
  45. package/dist/db.js +33 -0
  46. package/dist/db.test.js +267 -1
  47. package/dist/default-channel-provisioning.test.js +148 -0
  48. package/dist/discord-bot.js +517 -488
  49. package/dist/discord-command-registration.js +25 -10
  50. package/dist/discord-command-registration.test.js +17 -0
  51. package/dist/discord-utils.js +97 -5
  52. package/dist/discord-utils.test.js +151 -1
  53. package/dist/errors.js +10 -0
  54. package/dist/exec-async.js +17 -5
  55. package/dist/external-directory-permissions.e2e.test.js +11 -7
  56. package/dist/external-directory-project-deny.e2e.test.js +5 -3
  57. package/dist/external-opencode-sync.js +94 -17
  58. package/dist/external-opencode-sync.test.js +206 -0
  59. package/dist/extract-sdk-error-message.test.js +13 -0
  60. package/dist/file-edit-log.js +197 -0
  61. package/dist/file-edit-log.test.js +258 -0
  62. package/dist/format-tables.js +52 -0
  63. package/dist/format-tables.test.js +93 -0
  64. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  65. package/dist/gateway-proxy.e2e.test.js +18 -18
  66. package/dist/git-worktree-core.js +182 -17
  67. package/dist/interaction-handler.js +416 -398
  68. package/dist/ipc-tools-plugin.js +68 -3
  69. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  70. package/dist/kimaki-opencode-plugin.js +9 -3
  71. package/dist/kimaki-workspace-adaptor.js +33 -10
  72. package/dist/logger.js +11 -1
  73. package/dist/markdown.js +3 -1
  74. package/dist/markdown.test.js +11 -3
  75. package/dist/message-formatting.js +181 -51
  76. package/dist/message-formatting.test.js +529 -22
  77. package/dist/message-preprocessing.js +105 -1
  78. package/dist/oauth-rotation-shared.js +10 -0
  79. package/dist/openai-auth-plugin.js +1 -0
  80. package/dist/openai-auth-state.js +1 -0
  81. package/dist/opencode-command.js +28 -0
  82. package/dist/opencode-command.test.js +109 -1
  83. package/dist/opencode-interrupt-plugin.js +4 -0
  84. package/dist/opencode-interrupt-plugin.test.js +24 -0
  85. package/dist/opencode.js +82 -15
  86. package/dist/project-add.test.js +62 -0
  87. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  88. package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +150 -0
  89. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  90. package/dist/queue-advanced-abort.e2e.test.js +8 -10
  91. package/dist/queue-advanced-action-buttons.e2e.test.js +8 -8
  92. package/dist/queue-advanced-e2e-setup.js +78 -5
  93. package/dist/queue-advanced-footer.e2e.test.js +60 -269
  94. package/dist/queue-advanced-model-switch.e2e.test.js +9 -11
  95. package/dist/queue-advanced-permissions-typing.e2e.test.js +15 -15
  96. package/dist/queue-advanced-question.e2e.test.js +32 -27
  97. package/dist/queue-advanced-typing-interrupt.e2e.test.js +10 -12
  98. package/dist/queue-advanced-typing.e2e.test.js +7 -9
  99. package/dist/queue-delete-message.e2e.test.js +3 -3
  100. package/dist/queue-drain-after-interactive-ui.e2e.test.js +5 -5
  101. package/dist/queue-interrupt-drain.e2e.test.js +122 -12
  102. package/dist/queue-question-select-drain.e2e.test.js +34 -132
  103. package/dist/runtime-lifecycle.e2e.test.js +21 -51
  104. package/dist/schema.js +35 -1
  105. package/dist/session-handler/event-stream-state.js +391 -50
  106. package/dist/session-handler/event-stream-state.test.js +736 -8
  107. package/dist/session-handler/model-utils.js +172 -5
  108. package/dist/session-handler/model-utils.test.js +445 -0
  109. package/dist/session-handler/opencode-session-event-log.js +30 -1
  110. package/dist/session-handler/thread-ingress.test.js +41 -0
  111. package/dist/session-handler/thread-runtime-state.js +2 -0
  112. package/dist/session-handler/thread-session-runtime-summary.test.js +121 -0
  113. package/dist/session-handler/thread-session-runtime.js +574 -299
  114. package/dist/session-search.js +123 -4
  115. package/dist/session-search.test.js +248 -1
  116. package/dist/session-sleep.test.js +224 -0
  117. package/dist/session-title-rename.test.js +37 -39
  118. package/dist/sleep.e2e.test.js +494 -0
  119. package/dist/startup-time.e2e.test.js +2 -1
  120. package/dist/store.js +3 -0
  121. package/dist/system-message.js +102 -16
  122. package/dist/system-message.test.js +192 -15
  123. package/dist/task-runner.js +96 -5
  124. package/dist/task-schedule.js +87 -1
  125. package/dist/task-schedule.test.js +149 -1
  126. package/dist/test-utils.js +17 -9
  127. package/dist/thinking-utils.js +10 -0
  128. package/dist/thinking-utils.test.js +35 -0
  129. package/dist/thread-message-queue.e2e.test.js +65 -213
  130. package/dist/undo-redo.e2e.test.js +17 -9
  131. package/dist/voice-attachment.js +23 -1
  132. package/dist/voice-handler.js +21 -4
  133. package/dist/voice-message.e2e.test.js +144 -30
  134. package/dist/voice-routing-input.e2e.test.js +195 -0
  135. package/dist/voice-routing-question.e2e.test.js +125 -0
  136. package/dist/voice.js +28 -8
  137. package/dist/voice.test.js +96 -30
  138. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  139. package/dist/worktree-lifecycle.e2e.test.js +55 -36
  140. package/dist/worktrees.js +226 -443
  141. package/dist/worktrees.test.js +435 -58
  142. package/dist/xai-auth-plugin.js +1 -0
  143. package/dist/xai-auth-state.js +1 -0
  144. package/package.json +8 -6
  145. package/skills/bump-holocron-consumers/SKILL.md +137 -0
  146. package/skills/critique/SKILL.md +4 -4
  147. package/skills/errore/SKILL.md +60 -218
  148. package/skills/goke/SKILL.md +150 -10
  149. package/skills/holocron/SKILL.md +154 -17
  150. package/skills/npm-package/SKILL.md +55 -35
  151. package/skills/profano/SKILL.md +17 -2
  152. package/skills/spiceflow/SKILL.md +39 -3
  153. package/skills/strada/SKILL.md +39 -0
  154. package/skills/zele/SKILL.md +19 -0
  155. package/src/agent-model.e2e.test.ts +287 -186
  156. package/src/anthropic-account-identity.ts +20 -1
  157. package/src/anthropic-auth-plugin.test.ts +19 -0
  158. package/src/anthropic-auth-plugin.ts +12 -16
  159. package/src/anthropic-auth-state.ts +1 -0
  160. package/src/bash-tool-schema-plugin.test.ts +55 -0
  161. package/src/bash-tool-schema-plugin.ts +46 -0
  162. package/src/bin.ts +78 -26
  163. package/src/cli-commands/maintenance.ts +59 -13
  164. package/src/cli-commands/project.ts +14 -35
  165. package/src/cli-commands/send.ts +31 -1
  166. package/src/cli-commands/session.ts +374 -114
  167. package/src/cli-commands/task.ts +34 -15
  168. package/src/cli-parsing.test.ts +103 -4
  169. package/src/cli-runner.test.ts +49 -0
  170. package/src/cli-runner.ts +44 -7
  171. package/src/cli-send-thread.e2e.test.ts +9 -11
  172. package/src/cli.ts +53 -0
  173. package/src/commands/abort.ts +6 -1
  174. package/src/commands/action-buttons.ts +2 -1
  175. package/src/commands/add-project.ts +4 -8
  176. package/src/commands/agent.ts +205 -29
  177. package/src/commands/ask-question.test.ts +48 -1
  178. package/src/commands/ask-question.ts +23 -33
  179. package/src/commands/btw.ts +40 -5
  180. package/src/commands/file-upload.ts +2 -1
  181. package/src/commands/fork-subagent.ts +2 -3
  182. package/src/commands/fork.ts +4 -3
  183. package/src/commands/last-sessions.ts +10 -4
  184. package/src/commands/login.ts +6 -0
  185. package/src/commands/merge-worktree.ts +54 -16
  186. package/src/commands/model-variant.ts +9 -1
  187. package/src/commands/model.ts +20 -9
  188. package/src/commands/multioauth.ts +1 -0
  189. package/src/commands/new-worktree.ts +138 -55
  190. package/src/commands/queue.ts +3 -2
  191. package/src/commands/resume.ts +4 -3
  192. package/src/commands/session.ts +94 -70
  193. package/src/commands/user-command.ts +6 -6
  194. package/src/commands/worktrees.ts +5 -12
  195. package/src/config.ts +18 -1
  196. package/src/cpu-profiler.test.ts +183 -0
  197. package/src/cpu-profiler.ts +293 -0
  198. package/src/database.ts +235 -3
  199. package/src/db.test.ts +293 -0
  200. package/src/db.ts +38 -0
  201. package/src/default-channel-provisioning.test.ts +155 -0
  202. package/src/discord-bot.ts +78 -36
  203. package/src/discord-command-registration.test.ts +20 -0
  204. package/src/discord-command-registration.ts +42 -12
  205. package/src/discord-utils.test.ts +176 -0
  206. package/src/discord-utils.ts +152 -7
  207. package/src/errors.ts +12 -0
  208. package/src/exec-async.ts +20 -11
  209. package/src/external-directory-permissions.e2e.test.ts +11 -7
  210. package/src/external-directory-project-deny.e2e.test.ts +5 -3
  211. package/src/external-opencode-sync.test.ts +248 -0
  212. package/src/external-opencode-sync.ts +122 -19
  213. package/src/extract-sdk-error-message.test.ts +18 -0
  214. package/src/file-edit-log.test.ts +287 -0
  215. package/src/file-edit-log.ts +283 -0
  216. package/src/format-tables.test.ts +101 -0
  217. package/src/format-tables.ts +75 -0
  218. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  219. package/src/gateway-proxy.e2e.test.ts +18 -17
  220. package/src/git-worktree-core.ts +247 -20
  221. package/src/interaction-handler.ts +36 -13
  222. package/src/ipc-tools-plugin.ts +82 -5
  223. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  224. package/src/kimaki-opencode-plugin.ts +9 -3
  225. package/src/kimaki-workspace-adaptor.ts +40 -11
  226. package/src/logger.ts +14 -4
  227. package/src/markdown.test.ts +13 -3
  228. package/src/markdown.ts +4 -1
  229. package/src/message-formatting.test.ts +672 -25
  230. package/src/message-formatting.ts +242 -53
  231. package/src/message-preprocessing.ts +116 -1
  232. package/src/oauth-rotation-shared.ts +10 -0
  233. package/src/openai-auth-plugin.ts +1 -0
  234. package/src/openai-auth-state.ts +1 -0
  235. package/src/opencode-command.test.ts +140 -0
  236. package/src/opencode-command.ts +38 -0
  237. package/src/opencode-interrupt-plugin.test.ts +32 -0
  238. package/src/opencode-interrupt-plugin.ts +3 -0
  239. package/src/opencode.ts +115 -15
  240. package/src/project-add.test.ts +80 -0
  241. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  242. package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +172 -0
  243. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  244. package/src/queue-advanced-abort.e2e.test.ts +8 -9
  245. package/src/queue-advanced-action-buttons.e2e.test.ts +8 -8
  246. package/src/queue-advanced-e2e-setup.ts +83 -4
  247. package/src/queue-advanced-footer.e2e.test.ts +67 -315
  248. package/src/queue-advanced-model-switch.e2e.test.ts +9 -10
  249. package/src/queue-advanced-permissions-typing.e2e.test.ts +15 -15
  250. package/src/queue-advanced-question.e2e.test.ts +36 -30
  251. package/src/queue-advanced-typing-interrupt.e2e.test.ts +11 -11
  252. package/src/queue-advanced-typing.e2e.test.ts +8 -8
  253. package/src/queue-delete-message.e2e.test.ts +3 -3
  254. package/src/queue-drain-after-interactive-ui.e2e.test.ts +5 -5
  255. package/src/queue-interrupt-drain.e2e.test.ts +133 -12
  256. package/src/queue-question-select-drain.e2e.test.ts +36 -161
  257. package/src/runtime-lifecycle.e2e.test.ts +21 -54
  258. package/src/schema.sql +15 -2
  259. package/src/schema.ts +37 -1
  260. package/src/session-handler/event-stream-state.test.ts +790 -7
  261. package/src/session-handler/event-stream-state.ts +568 -53
  262. package/src/session-handler/model-utils.test.ts +537 -0
  263. package/src/session-handler/model-utils.ts +281 -5
  264. package/src/session-handler/opencode-session-event-log.ts +31 -1
  265. package/src/session-handler/thread-ingress.test.ts +54 -0
  266. package/src/session-handler/thread-runtime-state.ts +3 -0
  267. package/src/session-handler/thread-session-runtime-summary.test.ts +169 -0
  268. package/src/session-handler/thread-session-runtime.ts +712 -358
  269. package/src/session-search.test.ts +281 -0
  270. package/src/session-search.ts +241 -7
  271. package/src/session-sleep.test.ts +294 -0
  272. package/src/session-title-rename.test.ts +54 -48
  273. package/src/sleep.e2e.test.ts +565 -0
  274. package/src/startup-time.e2e.test.ts +5 -1
  275. package/src/store.ts +22 -0
  276. package/src/system-message.test.ts +226 -15
  277. package/src/system-message.ts +130 -16
  278. package/src/task-runner.ts +138 -3
  279. package/src/task-schedule.test.ts +170 -0
  280. package/src/task-schedule.ts +141 -1
  281. package/src/test-utils.ts +21 -10
  282. package/src/thinking-utils.test.ts +46 -0
  283. package/src/thinking-utils.ts +21 -0
  284. package/src/thread-message-queue.e2e.test.ts +65 -245
  285. package/src/undo-redo.e2e.test.ts +17 -9
  286. package/src/voice-attachment.ts +29 -1
  287. package/src/voice-handler.ts +23 -4
  288. package/src/voice-message.e2e.test.ts +149 -29
  289. package/src/voice-routing-input.e2e.test.ts +203 -0
  290. package/src/voice-routing-question.e2e.test.ts +131 -0
  291. package/src/voice.test.ts +107 -29
  292. package/src/voice.ts +34 -6
  293. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  294. package/src/worktree-lifecycle.e2e.test.ts +55 -35
  295. package/src/worktrees.test.ts +505 -64
  296. package/src/worktrees.ts +257 -603
  297. package/src/xai-auth-plugin.ts +1 -0
  298. package/src/xai-auth-state.ts +1 -0
  299. package/dist/message-finish-field.e2e.test.js +0 -169
  300. package/dist/subagent-rate-limit-plugin.js +0 -184
  301. package/src/message-finish-field.e2e.test.ts +0 -196
  302. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -1,13 +1,13 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { afterEach, describe, test, expect } from 'vitest'
4
- import { formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, serializeEmbeds, serializePoll, serializeMessageSnapshots, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js'
4
+ import { asDiscordQuote, batchChunksForDiscord, collectSessionChunks, formatBashToolTitle, formatPart, formatTaskToolTitle, formatTodoList, getTextAttachments, planAssistantTurnFlush, serializeEmbeds, serializePoll, serializeMessageSnapshots, sessionPartContent, shouldLeadWithBlankLine, TEXT_ATTACHMENT_INLINE_LIMIT_BYTES } from './message-formatting.js'
5
5
  import { getDataDir } from './config.js'
6
6
  import type { Collection, Embed, Message, MessageSnapshot, Poll } from 'discord.js'
7
7
  import type { Part } from '@opencode-ai/sdk/v2'
8
8
 
9
9
  describe('formatPart', () => {
10
- test('callout text does not get ⬥ prefix', () => {
10
+ test('callout text is returned without a diamond prefix', () => {
11
11
  const part: Part = {
12
12
  id: 'test',
13
13
  type: 'text',
@@ -16,15 +16,14 @@ describe('formatPart', () => {
16
16
  text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
17
17
  }
18
18
  expect(formatPart(part)).toMatchInlineSnapshot(`
19
- "
20
- <callout accent="#ef4444">
19
+ "<callout accent="#ef4444">
21
20
  ## Top priority
22
21
  - **Stripe dispute** deadline
23
22
  </callout>"
24
23
  `)
25
24
  })
26
25
 
27
- test('regular text gets ⬥ prefix', () => {
26
+ test('regular text has no diamond prefix', () => {
28
27
  const part: Part = {
29
28
  id: 'test',
30
29
  type: 'text',
@@ -32,10 +31,10 @@ describe('formatPart', () => {
32
31
  messageID: 'msg_test',
33
32
  text: 'hello world',
34
33
  }
35
- expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`)
34
+ expect(formatPart(part)).toMatchInlineSnapshot(`"hello world"`)
36
35
  })
37
36
 
38
- test('text starting with heading does not get ⬥ prefix', () => {
37
+ test('heading text has no diamond prefix', () => {
39
38
  const part: Part = {
40
39
  id: 'test',
41
40
  type: 'text',
@@ -44,35 +43,648 @@ describe('formatPart', () => {
44
43
  text: '## Summary\nDone.',
45
44
  }
46
45
  expect(formatPart(part)).toMatchInlineSnapshot(`
47
- "
48
- ## Summary
46
+ "## Summary
49
47
  Done."
50
48
  `)
51
49
  })
50
+
51
+ })
52
+
53
+ describe('asDiscordQuote', () => {
54
+ test('prefixes every line', () => {
55
+ expect(asDiscordQuote('hello\nworld')).toMatchInlineSnapshot(`
56
+ "> hello
57
+ > world"
58
+ `)
59
+ })
60
+
61
+ test('does not double-quote already quoted text', () => {
62
+ expect(asDiscordQuote('> already quoted')).toMatchInlineSnapshot(`"> already quoted"`)
63
+ })
64
+
65
+ test('prefixes blank lines and later lines, not only the first', () => {
66
+ expect(asDiscordQuote('I will open that file\n\nthen reply')).toMatchInlineSnapshot(`
67
+ "> I will open that file
68
+ >
69
+ > then reply"
70
+ `)
71
+ })
72
+ })
73
+
74
+ describe('planAssistantTurnFlush', () => {
75
+ function text(id: string, body: string, ended = true) {
76
+ return { id, type: 'text', text: body, time: ended ? { end: 1 } : undefined }
77
+ }
78
+ function tool(id: string, name?: string) {
79
+ return { id, type: 'tool', tool: name }
80
+ }
81
+
82
+ function plan(parts: Array<{ id: string; type: string; text?: string }>, mode: 'progress' | 'interactive' | 'final', throughPartId?: string) {
83
+ const result = planAssistantTurnFlush({ parts, mode, throughPartId })
84
+ return { send: result.send, hold: result.hold }
85
+ }
86
+
87
+ test('progress quotes completed short text immediately', () => {
88
+ expect(
89
+ plan([text('t1', 'status'), tool('tool1'), text('t2', 'answer')], 'progress'),
90
+ ).toMatchInlineSnapshot(`
91
+ {
92
+ "hold": [],
93
+ "send": [
94
+ {
95
+ "id": "t1",
96
+ "quoteText": true,
97
+ },
98
+ {
99
+ "id": "tool1",
100
+ "quoteText": false,
101
+ },
102
+ {
103
+ "id": "t2",
104
+ "quoteText": true,
105
+ },
106
+ ],
107
+ }
108
+ `)
109
+ })
110
+
111
+ test('progress quotes a lone completed short text', () => {
112
+ expect(plan([text('t1', 'answer')], 'progress')).toMatchInlineSnapshot(`
113
+ {
114
+ "hold": [],
115
+ "send": [
116
+ {
117
+ "id": "t1",
118
+ "quoteText": true,
119
+ },
120
+ ],
121
+ }
122
+ `)
123
+ })
124
+
125
+ test('progress quotes completed short text before following tools', () => {
126
+ expect(
127
+ plan([text('t1', 'status'), tool('tool1')], 'progress'),
128
+ ).toMatchInlineSnapshot(`
129
+ {
130
+ "hold": [],
131
+ "send": [
132
+ {
133
+ "id": "t1",
134
+ "quoteText": true,
135
+ },
136
+ {
137
+ "id": "tool1",
138
+ "quoteText": false,
139
+ },
140
+ ],
141
+ }
142
+ `)
143
+ })
144
+
145
+ test('progress quotes each short text that precedes a tool', () => {
146
+ expect(
147
+ plan([text('t1', 'status'), text('t2', 'next'), tool('tool1')], 'progress'),
148
+ ).toMatchInlineSnapshot(`
149
+ {
150
+ "hold": [],
151
+ "send": [
152
+ {
153
+ "id": "t1",
154
+ "quoteText": true,
155
+ },
156
+ {
157
+ "id": "t2",
158
+ "quoteText": true,
159
+ },
160
+ {
161
+ "id": "tool1",
162
+ "quoteText": false,
163
+ },
164
+ ],
165
+ }
166
+ `)
167
+ })
168
+
169
+ test('progress holds unfinished last text and following tools', () => {
170
+ expect(
171
+ plan([text('t1', 'status', false), tool('tool1')], 'progress'),
172
+ ).toMatchInlineSnapshot(`
173
+ {
174
+ "hold": [
175
+ {
176
+ "id": "t1",
177
+ "quoteText": false,
178
+ },
179
+ {
180
+ "id": "tool1",
181
+ "quoteText": false,
182
+ },
183
+ ],
184
+ "send": [],
185
+ }
186
+ `)
187
+ })
188
+
189
+ test('progress holds unfinished last text until it ends', () => {
190
+ expect(
191
+ plan([text('t1', 'first'), text('t2', 'second', false)], 'progress'),
192
+ ).toMatchInlineSnapshot(`
193
+ {
194
+ "hold": [
195
+ {
196
+ "id": "t2",
197
+ "quoteText": false,
198
+ },
199
+ ],
200
+ "send": [
201
+ {
202
+ "id": "t1",
203
+ "quoteText": true,
204
+ },
205
+ ],
206
+ }
207
+ `)
208
+ })
209
+
210
+ test('final quotes earlier text and leaves the last text plain', () => {
211
+ expect(
212
+ plan([text('t1', 'status'), tool('tool1'), text('t2', 'done')], 'final'),
213
+ ).toMatchInlineSnapshot(`
214
+ {
215
+ "hold": [],
216
+ "send": [
217
+ {
218
+ "id": "t1",
219
+ "quoteText": true,
220
+ },
221
+ {
222
+ "id": "tool1",
223
+ "quoteText": false,
224
+ },
225
+ {
226
+ "id": "t2",
227
+ "quoteText": false,
228
+ },
229
+ ],
230
+ }
231
+ `)
232
+ })
233
+
234
+ test('final quotes short text before a tool', () => {
235
+ expect(
236
+ plan([text('t1', 'status'), tool('tool1')], 'final'),
237
+ ).toMatchInlineSnapshot(`
238
+ {
239
+ "hold": [],
240
+ "send": [
241
+ {
242
+ "id": "t1",
243
+ "quoteText": true,
244
+ },
245
+ {
246
+ "id": "tool1",
247
+ "quoteText": false,
248
+ },
249
+ ],
250
+ }
251
+ `)
252
+ })
253
+
254
+ test('interactive without throughPartId sends the last text unquoted', () => {
255
+ expect(
256
+ plan([text('t1', 'ask'), tool('question1', 'question')], 'interactive'),
257
+ ).toMatchInlineSnapshot(`
258
+ {
259
+ "hold": [],
260
+ "send": [
261
+ {
262
+ "id": "t1",
263
+ "quoteText": false,
264
+ },
265
+ {
266
+ "id": "question1",
267
+ "quoteText": false,
268
+ },
269
+ ],
270
+ }
271
+ `)
272
+ })
273
+
274
+ test('interactive leaves preceding text unquoted and flushes through the interactive tool', () => {
275
+ expect(
276
+ plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'interactive', 'question1'),
277
+ ).toMatchInlineSnapshot(`
278
+ {
279
+ "hold": [
280
+ {
281
+ "id": "t2",
282
+ "quoteText": false,
283
+ },
284
+ ],
285
+ "send": [
286
+ {
287
+ "id": "t1",
288
+ "quoteText": false,
289
+ },
290
+ {
291
+ "id": "question1",
292
+ "quoteText": false,
293
+ },
294
+ ],
295
+ }
296
+ `)
297
+ })
298
+
299
+ test('progress does not quote earlier text longer than two lines', () => {
300
+ expect(
301
+ plan(
302
+ [
303
+ text('t1', 'line one\nline two\nline three'),
304
+ tool('tool1'),
305
+ text('t2', 'answer'),
306
+ ],
307
+ 'progress',
308
+ ),
309
+ ).toMatchInlineSnapshot(`
310
+ {
311
+ "hold": [],
312
+ "send": [
313
+ {
314
+ "id": "t1",
315
+ "quoteText": false,
316
+ },
317
+ {
318
+ "id": "tool1",
319
+ "quoteText": false,
320
+ },
321
+ {
322
+ "id": "t2",
323
+ "quoteText": true,
324
+ },
325
+ ],
326
+ }
327
+ `)
328
+ })
329
+
330
+ test('progress still quotes two-line earlier text', () => {
331
+ expect(
332
+ plan(
333
+ [text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')],
334
+ 'progress',
335
+ ).send.find((entry) => entry.id === 't1'),
336
+ ).toEqual({ id: 't1', quoteText: true })
337
+ })
338
+
339
+ test('progress does not quote earlier text before an interactive tool', () => {
340
+ expect(
341
+ plan(
342
+ [text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')],
343
+ 'progress',
344
+ ).send.find((entry) => entry.id === 't1'),
345
+ ).toEqual({ id: 't1', quoteText: false })
346
+ expect(
347
+ plan(
348
+ [text('t1', 'wait'), tool('sleep1', 'kimaki_sleep'), text('t2', 'later')],
349
+ 'progress',
350
+ ).send.find((entry) => entry.id === 't1'),
351
+ ).toEqual({ id: 't1', quoteText: false })
352
+ expect(
353
+ plan(
354
+ [text('t1', 'choose'), tool('buttons1', 'kimaki_action_buttons'), text('t2', 'later')],
355
+ 'progress',
356
+ ).send.find((entry) => entry.id === 't1'),
357
+ ).toEqual({ id: 't1', quoteText: false })
358
+ })
359
+ })
360
+
361
+ describe('shouldLeadWithBlankLine', () => {
362
+ test('never leads on the first part', () => {
363
+ expect(
364
+ shouldLeadWithBlankLine({ previousKind: undefined, nextKind: 'text' }),
365
+ ).toBe(false)
366
+ expect(
367
+ shouldLeadWithBlankLine({ previousKind: undefined, nextKind: 'tool' }),
368
+ ).toBe(false)
369
+ })
370
+
371
+ test('leads on both text and tool transitions', () => {
372
+ expect(
373
+ shouldLeadWithBlankLine({ previousKind: 'text', nextKind: 'tool' }),
374
+ ).toBe(true)
375
+ expect(
376
+ shouldLeadWithBlankLine({ previousKind: 'tool', nextKind: 'text' }),
377
+ ).toBe(true)
378
+ expect(
379
+ shouldLeadWithBlankLine({ previousKind: 'text', nextKind: 'text' }),
380
+ ).toBe(false)
381
+ expect(
382
+ shouldLeadWithBlankLine({ previousKind: 'tool', nextKind: 'tool' }),
383
+ ).toBe(false)
384
+ })
385
+ })
386
+
387
+ describe('sessionPartContent', () => {
388
+ test('leaves the first part unchanged', () => {
389
+ expect(
390
+ sessionPartContent({ content: 'hello', leadWithBlankLine: false }),
391
+ ).toBe('hello')
392
+ })
393
+
394
+ test('prepends a newline on text and tool transitions', () => {
395
+ expect(
396
+ sessionPartContent({ content: '┣ bash ls', leadWithBlankLine: true }),
397
+ ).toMatchInlineSnapshot(`
398
+ "
399
+ ┣ bash ls"
400
+ `)
401
+ expect(
402
+ sessionPartContent({ content: 'done', leadWithBlankLine: true }),
403
+ ).toMatchInlineSnapshot(`
404
+ "
405
+ done"
406
+ `)
407
+ })
408
+ })
409
+
410
+ describe('collectSessionChunks', () => {
411
+ function textPart({
412
+ id,
413
+ text,
414
+ messageID,
415
+ }: {
416
+ id: string
417
+ text: string
418
+ messageID: string
419
+ }): Part {
420
+ return {
421
+ id,
422
+ type: 'text',
423
+ sessionID: 'ses_test',
424
+ messageID,
425
+ text,
426
+ }
427
+ }
428
+
429
+ test('quotes short text throughout a turn', () => {
430
+ const { chunks } = collectSessionChunks({
431
+ messages: [
432
+ {
433
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
434
+ parts: [textPart({ id: 't1', text: 'I will read it', messageID: 'msg_1' })],
435
+ },
436
+ {
437
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user' },
438
+ parts: [textPart({ id: 't2', text: 'done', messageID: 'msg_2' })],
439
+ },
440
+ ],
441
+ })
442
+ expect(chunks.map((chunk) => chunk.content)).toMatchInlineSnapshot(`
443
+ [
444
+ "> I will read it",
445
+ "> done",
446
+ ]
447
+ `)
448
+ })
449
+
450
+ test('quotes short text before a tool', () => {
451
+ const { chunks } = collectSessionChunks({
452
+ messages: [
453
+ {
454
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
455
+ parts: [
456
+ textPart({ id: 't1', text: 'I will read it', messageID: 'msg_1' }),
457
+ {
458
+ id: 'tool1',
459
+ type: 'tool',
460
+ sessionID: 'ses_test',
461
+ messageID: 'msg_1',
462
+ tool: 'read',
463
+ callID: 'call_tool1',
464
+ state: { status: 'completed', input: {}, output: '', title: '', metadata: {}, time: { start: 0, end: 0 } },
465
+ },
466
+ ],
467
+ },
468
+ ],
469
+ })
470
+ expect(chunks.map((chunk) => chunk.content)).toEqual([
471
+ '> I will read it',
472
+ '┣ read',
473
+ ])
474
+ })
475
+
476
+ test('does not quote earlier text longer than two lines', () => {
477
+ const { chunks } = collectSessionChunks({
478
+ messages: [
479
+ {
480
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
481
+ parts: [
482
+ textPart({
483
+ id: 't1',
484
+ text: 'line one\nline two\nline three',
485
+ messageID: 'msg_1',
486
+ }),
487
+ ],
488
+ },
489
+ {
490
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user' },
491
+ parts: [textPart({ id: 't2', text: 'done', messageID: 'msg_2' })],
492
+ },
493
+ ],
494
+ })
495
+ expect(chunks.map((chunk) => chunk.content)).toEqual([
496
+ 'line one\nline two\nline three',
497
+ '> done',
498
+ ])
499
+ })
500
+
501
+ test('does not quote text flushed before a question tool', () => {
502
+ const { chunks } = collectSessionChunks({
503
+ messages: [
504
+ {
505
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
506
+ parts: [
507
+ textPart({ id: 't1', text: 'Pick one', messageID: 'msg_1' }),
508
+ {
509
+ id: 'q1',
510
+ type: 'tool',
511
+ sessionID: 'ses_test',
512
+ messageID: 'msg_1',
513
+ tool: 'question',
514
+ callID: 'call_q1',
515
+ state: { status: 'completed', input: {}, output: '', title: '', metadata: {}, time: { start: 0, end: 0 } },
516
+ },
517
+ textPart({ id: 't2', text: 'thanks', messageID: 'msg_1' }),
518
+ ],
519
+ },
520
+ ],
521
+ })
522
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['Pick one', '> thanks'])
523
+ })
524
+
525
+ test('quotes within each user turn separately', () => {
526
+ const { chunks } = collectSessionChunks({
527
+ messages: [
528
+ {
529
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user_1' },
530
+ parts: [textPart({ id: 't1', text: 'first turn', messageID: 'msg_1' })],
531
+ },
532
+ {
533
+ info: { role: 'user', id: 'msg_user_2' },
534
+ parts: [],
535
+ },
536
+ {
537
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user_2' },
538
+ parts: [textPart({ id: 't2', text: 'looking', messageID: 'msg_2' })],
539
+ },
540
+ {
541
+ info: { role: 'assistant', id: 'msg_3', parentID: 'msg_user_2' },
542
+ parts: [textPart({ id: 't3', text: 'second turn done', messageID: 'msg_3' })],
543
+ },
544
+ ],
545
+ })
546
+ expect(chunks.map((chunk) => chunk.content)).toMatchInlineSnapshot(`
547
+ [
548
+ "> first turn",
549
+ "> looking",
550
+ "> second turn done",
551
+ ]
552
+ `)
553
+ })
554
+ })
555
+
556
+ describe('batchChunksForDiscord', () => {
557
+ test('does not merge text chunks with tool chunks', () => {
558
+ expect(
559
+ batchChunksForDiscord([
560
+ { partIds: ['t1'], content: 'hello', kind: 'text' },
561
+ { partIds: ['b1'], content: '┣ bash ls', kind: 'tool' },
562
+ { partIds: ['t2'], content: 'done', kind: 'text' },
563
+ ]),
564
+ ).toMatchInlineSnapshot(`
565
+ [
566
+ {
567
+ "content": "hello",
568
+ "kind": "text",
569
+ "partIds": [
570
+ "t1",
571
+ ],
572
+ },
573
+ {
574
+ "content": "┣ bash ls",
575
+ "kind": "tool",
576
+ "partIds": [
577
+ "b1",
578
+ ],
579
+ },
580
+ {
581
+ "content": "done",
582
+ "kind": "text",
583
+ "partIds": [
584
+ "t2",
585
+ ],
586
+ },
587
+ ]
588
+ `)
589
+ })
52
590
  })
53
591
 
54
592
  describe('formatTaskToolTitle', () => {
55
- test('uses the completed task title when OpenAI omits the description', () => {
56
- const part: Extract<Part, { type: 'tool' }> = {
593
+ function taskPart({
594
+ status,
595
+ input = {},
596
+ title = '',
597
+ sessionId,
598
+ }: {
599
+ status: 'running' | 'completed'
600
+ input?: { description?: string; subagent_type?: string }
601
+ title?: string
602
+ sessionId?: string
603
+ }): Extract<Part, { type: 'tool' }> {
604
+ const base = {
57
605
  id: 'prt_task',
58
- type: 'tool',
606
+ type: 'tool' as const,
59
607
  tool: 'task',
60
608
  callID: 'call_task',
61
609
  sessionID: 'ses_parent',
62
610
  messageID: 'msg_assistant',
611
+ }
612
+ if (status === 'completed') {
613
+ return {
614
+ ...base,
615
+ state: {
616
+ status,
617
+ input,
618
+ output: '',
619
+ title,
620
+ metadata: sessionId ? { sessionId } : {},
621
+ time: { start: 1, end: 2 },
622
+ },
623
+ }
624
+ }
625
+ return {
626
+ ...base,
63
627
  state: {
64
- status: 'completed',
65
- input: { subagent_type: 'general' },
66
- output: '',
67
- title: 'Classify pending changes',
68
- metadata: { sessionId: 'ses_child' },
69
- time: { start: 1, end: 2 },
628
+ status,
629
+ input,
630
+ title,
631
+ metadata: sessionId ? { sessionId } : {},
632
+ time: { start: 1 },
70
633
  },
71
634
  }
635
+ }
72
636
 
73
- expect(formatTaskToolTitle(part)).toMatchInlineSnapshot(
74
- `"┣ general **Classify pending changes**"`,
75
- )
637
+ test('uses the running task title when OpenAI omits the description', () => {
638
+ expect(
639
+ formatTaskToolTitle(
640
+ taskPart({
641
+ status: 'running',
642
+ input: { subagent_type: 'general' },
643
+ title: 'Classify pending changes',
644
+ sessionId: 'ses_child',
645
+ }),
646
+ ),
647
+ ).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`)
648
+ })
649
+
650
+ test('prefers input description on running parts', () => {
651
+ expect(
652
+ formatTaskToolTitle(
653
+ taskPart({
654
+ status: 'running',
655
+ input: { description: 'inspect repo', subagent_type: 'explore' },
656
+ title: 'ignored title',
657
+ sessionId: 'ses_child',
658
+ }),
659
+ ),
660
+ ).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`)
661
+ })
662
+
663
+ test('does not format completed parts so Discord does not post the line at the end', () => {
664
+ expect(
665
+ formatTaskToolTitle(
666
+ taskPart({
667
+ status: 'completed',
668
+ input: { subagent_type: 'general' },
669
+ title: 'Classify pending changes',
670
+ sessionId: 'ses_child',
671
+ }),
672
+ ),
673
+ ).toBe('')
674
+ })
675
+
676
+ test('shows queued task calls before the child session exists', () => {
677
+ expect(
678
+ formatTaskToolTitle(
679
+ taskPart({
680
+ status: 'running',
681
+ input: {
682
+ description: 'audit customer pages',
683
+ subagent_type: 'general',
684
+ },
685
+ }),
686
+ ),
687
+ ).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`)
76
688
  })
77
689
  })
78
690
 
@@ -103,6 +715,41 @@ describe('formatBashToolTitle', () => {
103
715
  ).toMatchInlineSnapshot(`" _Print greeting_"`)
104
716
  })
105
717
 
718
+ test('50-char single-line command is shown in full', () => {
719
+ const command = 'a'.repeat(50)
720
+ expect(formatBashToolTitle({ command, description: 'Ignored' })).toBe(
721
+ ` _${command}_`,
722
+ )
723
+ })
724
+
725
+ test('51-char single-line command uses description', () => {
726
+ expect(
727
+ formatBashToolTitle({
728
+ command: 'a'.repeat(51),
729
+ description: 'Run long command',
730
+ }),
731
+ ).toMatchInlineSnapshot(`" _Run long command_"`)
732
+ })
733
+
734
+ test('51-char single-line command uses summary when description is missing', () => {
735
+ expect(
736
+ formatBashToolTitle({
737
+ command: 'a'.repeat(51),
738
+ summary: 'Install packages',
739
+ }),
740
+ ).toMatchInlineSnapshot(`" _Install packages_"`)
741
+ })
742
+
743
+ test('description wins over summary for long commands', () => {
744
+ expect(
745
+ formatBashToolTitle({
746
+ command: 'a'.repeat(51),
747
+ description: 'Preferred label',
748
+ summary: 'Ignored label',
749
+ }),
750
+ ).toMatchInlineSnapshot(`" _Preferred label_"`)
751
+ })
752
+
106
753
  test('stateTitle used as last resort', () => {
107
754
  expect(
108
755
  formatBashToolTitle({ command: '', stateTitle: 'Running tests' }),
@@ -136,7 +783,7 @@ describe('formatBashToolTitle', () => {
136
783
  })
137
784
 
138
785
  describe('formatTodoList', () => {
139
- test('formats active todo with monospace numbers', () => {
786
+ test('formats active todo with number, dot, and two spaces', () => {
140
787
  const part: Part = {
141
788
  id: 'test',
142
789
  type: 'tool',
@@ -160,7 +807,7 @@ describe('formatTodoList', () => {
160
807
  },
161
808
  }
162
809
 
163
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒉ **second task**"`)
810
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"2. **second task**"`)
164
811
  })
165
812
 
166
813
  test('formats double digit todo numbers', () => {
@@ -186,7 +833,7 @@ describe('formatTodoList', () => {
186
833
  },
187
834
  }
188
835
 
189
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒓ **task 12**"`)
836
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"12. **task 12**"`)
190
837
  })
191
838
 
192
839
  test('lowercases first letter of content', () => {
@@ -209,7 +856,7 @@ describe('formatTodoList', () => {
209
856
  },
210
857
  }
211
858
 
212
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒈ **fix the bug**"`)
859
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"1. **fix the bug**"`)
213
860
  })
214
861
  })
215
862