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,10 +1,10 @@
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
  describe('formatPart', () => {
7
- test('callout text does not get ⬥ prefix', () => {
7
+ test('callout text is returned without a diamond prefix', () => {
8
8
  const part = {
9
9
  id: 'test',
10
10
  type: 'text',
@@ -13,14 +13,13 @@ describe('formatPart', () => {
13
13
  text: `<callout accent="#ef4444">\n## Top priority\n- **Stripe dispute** deadline\n</callout>`,
14
14
  };
15
15
  expect(formatPart(part)).toMatchInlineSnapshot(`
16
- "
17
- <callout accent="#ef4444">
16
+ "<callout accent="#ef4444">
18
17
  ## Top priority
19
18
  - **Stripe dispute** deadline
20
19
  </callout>"
21
20
  `);
22
21
  });
23
- test('regular text gets ⬥ prefix', () => {
22
+ test('regular text has no diamond prefix', () => {
24
23
  const part = {
25
24
  id: 'test',
26
25
  type: 'text',
@@ -28,9 +27,9 @@ describe('formatPart', () => {
28
27
  messageID: 'msg_test',
29
28
  text: 'hello world',
30
29
  };
31
- expect(formatPart(part)).toMatchInlineSnapshot(`"⬥ hello world"`);
30
+ expect(formatPart(part)).toMatchInlineSnapshot(`"hello world"`);
32
31
  });
33
- test('text starting with heading does not get ⬥ prefix', () => {
32
+ test('heading text has no diamond prefix', () => {
34
33
  const part = {
35
34
  id: 'test',
36
35
  type: 'text',
@@ -39,31 +38,516 @@ describe('formatPart', () => {
39
38
  text: '## Summary\nDone.',
40
39
  };
41
40
  expect(formatPart(part)).toMatchInlineSnapshot(`
42
- "
43
- ## Summary
41
+ "## Summary
44
42
  Done."
45
43
  `);
46
44
  });
47
45
  });
46
+ describe('asDiscordQuote', () => {
47
+ test('prefixes every line', () => {
48
+ expect(asDiscordQuote('hello\nworld')).toMatchInlineSnapshot(`
49
+ "> hello
50
+ > world"
51
+ `);
52
+ });
53
+ test('does not double-quote already quoted text', () => {
54
+ expect(asDiscordQuote('> already quoted')).toMatchInlineSnapshot(`"> already quoted"`);
55
+ });
56
+ test('prefixes blank lines and later lines, not only the first', () => {
57
+ expect(asDiscordQuote('I will open that file\n\nthen reply')).toMatchInlineSnapshot(`
58
+ "> I will open that file
59
+ >
60
+ > then reply"
61
+ `);
62
+ });
63
+ });
64
+ describe('planAssistantTurnFlush', () => {
65
+ function text(id, body, ended = true) {
66
+ return { id, type: 'text', text: body, time: ended ? { end: 1 } : undefined };
67
+ }
68
+ function tool(id, name) {
69
+ return { id, type: 'tool', tool: name };
70
+ }
71
+ function plan(parts, mode, throughPartId) {
72
+ const result = planAssistantTurnFlush({ parts, mode, throughPartId });
73
+ return { send: result.send, hold: result.hold };
74
+ }
75
+ test('progress quotes completed short text immediately', () => {
76
+ expect(plan([text('t1', 'status'), tool('tool1'), text('t2', 'answer')], 'progress')).toMatchInlineSnapshot(`
77
+ {
78
+ "hold": [],
79
+ "send": [
80
+ {
81
+ "id": "t1",
82
+ "quoteText": true,
83
+ },
84
+ {
85
+ "id": "tool1",
86
+ "quoteText": false,
87
+ },
88
+ {
89
+ "id": "t2",
90
+ "quoteText": true,
91
+ },
92
+ ],
93
+ }
94
+ `);
95
+ });
96
+ test('progress quotes a lone completed short text', () => {
97
+ expect(plan([text('t1', 'answer')], 'progress')).toMatchInlineSnapshot(`
98
+ {
99
+ "hold": [],
100
+ "send": [
101
+ {
102
+ "id": "t1",
103
+ "quoteText": true,
104
+ },
105
+ ],
106
+ }
107
+ `);
108
+ });
109
+ test('progress quotes completed short text before following tools', () => {
110
+ expect(plan([text('t1', 'status'), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
111
+ {
112
+ "hold": [],
113
+ "send": [
114
+ {
115
+ "id": "t1",
116
+ "quoteText": true,
117
+ },
118
+ {
119
+ "id": "tool1",
120
+ "quoteText": false,
121
+ },
122
+ ],
123
+ }
124
+ `);
125
+ });
126
+ test('progress quotes each short text that precedes a tool', () => {
127
+ expect(plan([text('t1', 'status'), text('t2', 'next'), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
128
+ {
129
+ "hold": [],
130
+ "send": [
131
+ {
132
+ "id": "t1",
133
+ "quoteText": true,
134
+ },
135
+ {
136
+ "id": "t2",
137
+ "quoteText": true,
138
+ },
139
+ {
140
+ "id": "tool1",
141
+ "quoteText": false,
142
+ },
143
+ ],
144
+ }
145
+ `);
146
+ });
147
+ test('progress holds unfinished last text and following tools', () => {
148
+ expect(plan([text('t1', 'status', false), tool('tool1')], 'progress')).toMatchInlineSnapshot(`
149
+ {
150
+ "hold": [
151
+ {
152
+ "id": "t1",
153
+ "quoteText": false,
154
+ },
155
+ {
156
+ "id": "tool1",
157
+ "quoteText": false,
158
+ },
159
+ ],
160
+ "send": [],
161
+ }
162
+ `);
163
+ });
164
+ test('progress holds unfinished last text until it ends', () => {
165
+ expect(plan([text('t1', 'first'), text('t2', 'second', false)], 'progress')).toMatchInlineSnapshot(`
166
+ {
167
+ "hold": [
168
+ {
169
+ "id": "t2",
170
+ "quoteText": false,
171
+ },
172
+ ],
173
+ "send": [
174
+ {
175
+ "id": "t1",
176
+ "quoteText": true,
177
+ },
178
+ ],
179
+ }
180
+ `);
181
+ });
182
+ test('final quotes earlier text and leaves the last text plain', () => {
183
+ expect(plan([text('t1', 'status'), tool('tool1'), text('t2', 'done')], 'final')).toMatchInlineSnapshot(`
184
+ {
185
+ "hold": [],
186
+ "send": [
187
+ {
188
+ "id": "t1",
189
+ "quoteText": true,
190
+ },
191
+ {
192
+ "id": "tool1",
193
+ "quoteText": false,
194
+ },
195
+ {
196
+ "id": "t2",
197
+ "quoteText": false,
198
+ },
199
+ ],
200
+ }
201
+ `);
202
+ });
203
+ test('final quotes short text before a tool', () => {
204
+ expect(plan([text('t1', 'status'), tool('tool1')], 'final')).toMatchInlineSnapshot(`
205
+ {
206
+ "hold": [],
207
+ "send": [
208
+ {
209
+ "id": "t1",
210
+ "quoteText": true,
211
+ },
212
+ {
213
+ "id": "tool1",
214
+ "quoteText": false,
215
+ },
216
+ ],
217
+ }
218
+ `);
219
+ });
220
+ test('interactive without throughPartId sends the last text unquoted', () => {
221
+ expect(plan([text('t1', 'ask'), tool('question1', 'question')], 'interactive')).toMatchInlineSnapshot(`
222
+ {
223
+ "hold": [],
224
+ "send": [
225
+ {
226
+ "id": "t1",
227
+ "quoteText": false,
228
+ },
229
+ {
230
+ "id": "question1",
231
+ "quoteText": false,
232
+ },
233
+ ],
234
+ }
235
+ `);
236
+ });
237
+ test('interactive leaves preceding text unquoted and flushes through the interactive tool', () => {
238
+ expect(plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'interactive', 'question1')).toMatchInlineSnapshot(`
239
+ {
240
+ "hold": [
241
+ {
242
+ "id": "t2",
243
+ "quoteText": false,
244
+ },
245
+ ],
246
+ "send": [
247
+ {
248
+ "id": "t1",
249
+ "quoteText": false,
250
+ },
251
+ {
252
+ "id": "question1",
253
+ "quoteText": false,
254
+ },
255
+ ],
256
+ }
257
+ `);
258
+ });
259
+ test('progress does not quote earlier text longer than two lines', () => {
260
+ expect(plan([
261
+ text('t1', 'line one\nline two\nline three'),
262
+ tool('tool1'),
263
+ text('t2', 'answer'),
264
+ ], 'progress')).toMatchInlineSnapshot(`
265
+ {
266
+ "hold": [],
267
+ "send": [
268
+ {
269
+ "id": "t1",
270
+ "quoteText": false,
271
+ },
272
+ {
273
+ "id": "tool1",
274
+ "quoteText": false,
275
+ },
276
+ {
277
+ "id": "t2",
278
+ "quoteText": true,
279
+ },
280
+ ],
281
+ }
282
+ `);
283
+ });
284
+ test('progress still quotes two-line earlier text', () => {
285
+ expect(plan([text('t1', 'line one\nline two'), tool('tool1'), text('t2', 'answer')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: true });
286
+ });
287
+ test('progress does not quote earlier text before an interactive tool', () => {
288
+ expect(plan([text('t1', 'ask'), tool('question1', 'question'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
289
+ expect(plan([text('t1', 'wait'), tool('sleep1', 'kimaki_sleep'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
290
+ expect(plan([text('t1', 'choose'), tool('buttons1', 'kimaki_action_buttons'), text('t2', 'later')], 'progress').send.find((entry) => entry.id === 't1')).toEqual({ id: 't1', quoteText: false });
291
+ });
292
+ });
293
+ describe('shouldLeadWithBlankLine', () => {
294
+ test('never leads on the first part', () => {
295
+ expect(shouldLeadWithBlankLine({ previousKind: undefined, nextKind: 'text' })).toBe(false);
296
+ expect(shouldLeadWithBlankLine({ previousKind: undefined, nextKind: 'tool' })).toBe(false);
297
+ });
298
+ test('leads on both text and tool transitions', () => {
299
+ expect(shouldLeadWithBlankLine({ previousKind: 'text', nextKind: 'tool' })).toBe(true);
300
+ expect(shouldLeadWithBlankLine({ previousKind: 'tool', nextKind: 'text' })).toBe(true);
301
+ expect(shouldLeadWithBlankLine({ previousKind: 'text', nextKind: 'text' })).toBe(false);
302
+ expect(shouldLeadWithBlankLine({ previousKind: 'tool', nextKind: 'tool' })).toBe(false);
303
+ });
304
+ });
305
+ describe('sessionPartContent', () => {
306
+ test('leaves the first part unchanged', () => {
307
+ expect(sessionPartContent({ content: 'hello', leadWithBlankLine: false })).toBe('hello');
308
+ });
309
+ test('prepends a newline on text and tool transitions', () => {
310
+ expect(sessionPartContent({ content: '┣ bash ls', leadWithBlankLine: true })).toMatchInlineSnapshot(`
311
+ "
312
+ ┣ bash ls"
313
+ `);
314
+ expect(sessionPartContent({ content: 'done', leadWithBlankLine: true })).toMatchInlineSnapshot(`
315
+ "
316
+ done"
317
+ `);
318
+ });
319
+ });
320
+ describe('collectSessionChunks', () => {
321
+ function textPart({ id, text, messageID, }) {
322
+ return {
323
+ id,
324
+ type: 'text',
325
+ sessionID: 'ses_test',
326
+ messageID,
327
+ text,
328
+ };
329
+ }
330
+ test('quotes short text throughout a turn', () => {
331
+ const { chunks } = collectSessionChunks({
332
+ messages: [
333
+ {
334
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
335
+ parts: [textPart({ id: 't1', text: 'I will read it', messageID: 'msg_1' })],
336
+ },
337
+ {
338
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user' },
339
+ parts: [textPart({ id: 't2', text: 'done', messageID: 'msg_2' })],
340
+ },
341
+ ],
342
+ });
343
+ expect(chunks.map((chunk) => chunk.content)).toMatchInlineSnapshot(`
344
+ [
345
+ "> I will read it",
346
+ "> done",
347
+ ]
348
+ `);
349
+ });
350
+ test('quotes short text before a tool', () => {
351
+ const { chunks } = collectSessionChunks({
352
+ messages: [
353
+ {
354
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
355
+ parts: [
356
+ textPart({ id: 't1', text: 'I will read it', messageID: 'msg_1' }),
357
+ {
358
+ id: 'tool1',
359
+ type: 'tool',
360
+ sessionID: 'ses_test',
361
+ messageID: 'msg_1',
362
+ tool: 'read',
363
+ callID: 'call_tool1',
364
+ state: { status: 'completed', input: {}, output: '', title: '', metadata: {}, time: { start: 0, end: 0 } },
365
+ },
366
+ ],
367
+ },
368
+ ],
369
+ });
370
+ expect(chunks.map((chunk) => chunk.content)).toEqual([
371
+ '> I will read it',
372
+ '┣ read',
373
+ ]);
374
+ });
375
+ test('does not quote earlier text longer than two lines', () => {
376
+ const { chunks } = collectSessionChunks({
377
+ messages: [
378
+ {
379
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
380
+ parts: [
381
+ textPart({
382
+ id: 't1',
383
+ text: 'line one\nline two\nline three',
384
+ messageID: 'msg_1',
385
+ }),
386
+ ],
387
+ },
388
+ {
389
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user' },
390
+ parts: [textPart({ id: 't2', text: 'done', messageID: 'msg_2' })],
391
+ },
392
+ ],
393
+ });
394
+ expect(chunks.map((chunk) => chunk.content)).toEqual([
395
+ 'line one\nline two\nline three',
396
+ '> done',
397
+ ]);
398
+ });
399
+ test('does not quote text flushed before a question tool', () => {
400
+ const { chunks } = collectSessionChunks({
401
+ messages: [
402
+ {
403
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user' },
404
+ parts: [
405
+ textPart({ id: 't1', text: 'Pick one', messageID: 'msg_1' }),
406
+ {
407
+ id: 'q1',
408
+ type: 'tool',
409
+ sessionID: 'ses_test',
410
+ messageID: 'msg_1',
411
+ tool: 'question',
412
+ callID: 'call_q1',
413
+ state: { status: 'completed', input: {}, output: '', title: '', metadata: {}, time: { start: 0, end: 0 } },
414
+ },
415
+ textPart({ id: 't2', text: 'thanks', messageID: 'msg_1' }),
416
+ ],
417
+ },
418
+ ],
419
+ });
420
+ expect(chunks.map((chunk) => chunk.content)).toEqual(['Pick one', '> thanks']);
421
+ });
422
+ test('quotes within each user turn separately', () => {
423
+ const { chunks } = collectSessionChunks({
424
+ messages: [
425
+ {
426
+ info: { role: 'assistant', id: 'msg_1', parentID: 'msg_user_1' },
427
+ parts: [textPart({ id: 't1', text: 'first turn', messageID: 'msg_1' })],
428
+ },
429
+ {
430
+ info: { role: 'user', id: 'msg_user_2' },
431
+ parts: [],
432
+ },
433
+ {
434
+ info: { role: 'assistant', id: 'msg_2', parentID: 'msg_user_2' },
435
+ parts: [textPart({ id: 't2', text: 'looking', messageID: 'msg_2' })],
436
+ },
437
+ {
438
+ info: { role: 'assistant', id: 'msg_3', parentID: 'msg_user_2' },
439
+ parts: [textPart({ id: 't3', text: 'second turn done', messageID: 'msg_3' })],
440
+ },
441
+ ],
442
+ });
443
+ expect(chunks.map((chunk) => chunk.content)).toMatchInlineSnapshot(`
444
+ [
445
+ "> first turn",
446
+ "> looking",
447
+ "> second turn done",
448
+ ]
449
+ `);
450
+ });
451
+ });
452
+ describe('batchChunksForDiscord', () => {
453
+ test('does not merge text chunks with tool chunks', () => {
454
+ expect(batchChunksForDiscord([
455
+ { partIds: ['t1'], content: 'hello', kind: 'text' },
456
+ { partIds: ['b1'], content: '┣ bash ls', kind: 'tool' },
457
+ { partIds: ['t2'], content: 'done', kind: 'text' },
458
+ ])).toMatchInlineSnapshot(`
459
+ [
460
+ {
461
+ "content": "hello",
462
+ "kind": "text",
463
+ "partIds": [
464
+ "t1",
465
+ ],
466
+ },
467
+ {
468
+ "content": "┣ bash ls",
469
+ "kind": "tool",
470
+ "partIds": [
471
+ "b1",
472
+ ],
473
+ },
474
+ {
475
+ "content": "done",
476
+ "kind": "text",
477
+ "partIds": [
478
+ "t2",
479
+ ],
480
+ },
481
+ ]
482
+ `);
483
+ });
484
+ });
48
485
  describe('formatTaskToolTitle', () => {
49
- test('uses the completed task title when OpenAI omits the description', () => {
50
- const part = {
486
+ function taskPart({ status, input = {}, title = '', sessionId, }) {
487
+ const base = {
51
488
  id: 'prt_task',
52
489
  type: 'tool',
53
490
  tool: 'task',
54
491
  callID: 'call_task',
55
492
  sessionID: 'ses_parent',
56
493
  messageID: 'msg_assistant',
494
+ };
495
+ if (status === 'completed') {
496
+ return {
497
+ ...base,
498
+ state: {
499
+ status,
500
+ input,
501
+ output: '',
502
+ title,
503
+ metadata: sessionId ? { sessionId } : {},
504
+ time: { start: 1, end: 2 },
505
+ },
506
+ };
507
+ }
508
+ return {
509
+ ...base,
57
510
  state: {
58
- status: 'completed',
59
- input: { subagent_type: 'general' },
60
- output: '',
61
- title: 'Classify pending changes',
62
- metadata: { sessionId: 'ses_child' },
63
- time: { start: 1, end: 2 },
511
+ status,
512
+ input,
513
+ title,
514
+ metadata: sessionId ? { sessionId } : {},
515
+ time: { start: 1 },
64
516
  },
65
517
  };
66
- expect(formatTaskToolTitle(part)).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
518
+ }
519
+ test('uses the running task title when OpenAI omits the description', () => {
520
+ expect(formatTaskToolTitle(taskPart({
521
+ status: 'running',
522
+ input: { subagent_type: 'general' },
523
+ title: 'Classify pending changes',
524
+ sessionId: 'ses_child',
525
+ }))).toMatchInlineSnapshot(`"┣ general **Classify pending changes**"`);
526
+ });
527
+ test('prefers input description on running parts', () => {
528
+ expect(formatTaskToolTitle(taskPart({
529
+ status: 'running',
530
+ input: { description: 'inspect repo', subagent_type: 'explore' },
531
+ title: 'ignored title',
532
+ sessionId: 'ses_child',
533
+ }))).toMatchInlineSnapshot(`"┣ explore **inspect repo**"`);
534
+ });
535
+ test('does not format completed parts so Discord does not post the line at the end', () => {
536
+ expect(formatTaskToolTitle(taskPart({
537
+ status: 'completed',
538
+ input: { subagent_type: 'general' },
539
+ title: 'Classify pending changes',
540
+ sessionId: 'ses_child',
541
+ }))).toBe('');
542
+ });
543
+ test('shows queued task calls before the child session exists', () => {
544
+ expect(formatTaskToolTitle(taskPart({
545
+ status: 'running',
546
+ input: {
547
+ description: 'audit customer pages',
548
+ subagent_type: 'general',
549
+ },
550
+ }))).toMatchInlineSnapshot(`"┣ general **audit customer pages**"`);
67
551
  });
68
552
  });
69
553
  describe('formatBashToolTitle', () => {
@@ -85,6 +569,29 @@ describe('formatBashToolTitle', () => {
85
569
  description: 'Print greeting',
86
570
  })).toMatchInlineSnapshot(`" _Print greeting_"`);
87
571
  });
572
+ test('50-char single-line command is shown in full', () => {
573
+ const command = 'a'.repeat(50);
574
+ expect(formatBashToolTitle({ command, description: 'Ignored' })).toBe(` _${command}_`);
575
+ });
576
+ test('51-char single-line command uses description', () => {
577
+ expect(formatBashToolTitle({
578
+ command: 'a'.repeat(51),
579
+ description: 'Run long command',
580
+ })).toMatchInlineSnapshot(`" _Run long command_"`);
581
+ });
582
+ test('51-char single-line command uses summary when description is missing', () => {
583
+ expect(formatBashToolTitle({
584
+ command: 'a'.repeat(51),
585
+ summary: 'Install packages',
586
+ })).toMatchInlineSnapshot(`" _Install packages_"`);
587
+ });
588
+ test('description wins over summary for long commands', () => {
589
+ expect(formatBashToolTitle({
590
+ command: 'a'.repeat(51),
591
+ description: 'Preferred label',
592
+ summary: 'Ignored label',
593
+ })).toMatchInlineSnapshot(`" _Preferred label_"`);
594
+ });
88
595
  test('stateTitle used as last resort', () => {
89
596
  expect(formatBashToolTitle({ command: '', stateTitle: 'Running tests' })).toMatchInlineSnapshot(`" _Running tests_"`);
90
597
  });
@@ -105,7 +612,7 @@ describe('formatBashToolTitle', () => {
105
612
  });
106
613
  });
107
614
  describe('formatTodoList', () => {
108
- test('formats active todo with monospace numbers', () => {
615
+ test('formats active todo with number, dot, and two spaces', () => {
109
616
  const part = {
110
617
  id: 'test',
111
618
  type: 'tool',
@@ -128,7 +635,7 @@ describe('formatTodoList', () => {
128
635
  time: { start: 0, end: 0 },
129
636
  },
130
637
  };
131
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒉ **second task**"`);
638
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"2. **second task**"`);
132
639
  });
133
640
  test('formats double digit todo numbers', () => {
134
641
  const todos = Array.from({ length: 12 }, (_, i) => ({
@@ -151,7 +658,7 @@ describe('formatTodoList', () => {
151
658
  time: { start: 0, end: 0 },
152
659
  },
153
660
  };
154
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒓ **task 12**"`);
661
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"12. **task 12**"`);
155
662
  });
156
663
  test('lowercases first letter of content', () => {
157
664
  const part = {
@@ -172,7 +679,7 @@ describe('formatTodoList', () => {
172
679
  time: { start: 0, end: 0 },
173
680
  },
174
681
  };
175
- expect(formatTodoList(part)).toMatchInlineSnapshot(`"⒈ **fix the bug**"`);
682
+ expect(formatTodoList(part)).toMatchInlineSnapshot(`"1. **fix the bug**"`);
176
683
  });
177
684
  });
178
685
  describe('serializeEmbeds', () => {