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
@@ -0,0 +1,294 @@
1
+ // Tests for the durable session-sleep delivery protocol.
2
+ //
3
+ // The invariant under test: a wake is delivered at least once and turned into a
4
+ // session turn at most once. A row therefore stays `planned` until ingress
5
+ // consumes it, and every delivery write is guarded on delivery_id so a stale
6
+ // in-flight wake can never touch a newer sleep occurrence.
7
+
8
+ import { afterAll, describe, expect, test } from 'vitest'
9
+ import {
10
+ cancelSessionSleepForThread,
11
+ claimSessionSleepAttempt,
12
+ consumeSessionSleepWake,
13
+ getDueSessionSleeps,
14
+ getSessionSleep,
15
+ getThreadIdBySessionId,
16
+ markSessionSleepFailed,
17
+ setThreadSession,
18
+ upsertSessionSleep,
19
+ } from './database.js'
20
+ import { closeDb } from './db.js'
21
+ import { buildSessionSleepWakeBody } from './task-runner.js'
22
+
23
+ const RETRY_AFTER_MS = 30_000
24
+ const NOW = new Date('2026-08-19T12:00:00Z')
25
+ const DUE = new Date('2026-08-19T11:00:00Z')
26
+
27
+ async function createSleep({
28
+ sessionId,
29
+ threadId,
30
+ wakeAt = DUE,
31
+ reason = null,
32
+ }: {
33
+ sessionId: string
34
+ threadId: string
35
+ wakeAt?: Date
36
+ reason?: string | null
37
+ }) {
38
+ await setThreadSession(threadId, sessionId)
39
+ return upsertSessionSleep({ sessionId, wakeAt, reason })
40
+ }
41
+
42
+ describe('session sleep delivery', () => {
43
+ test('uses a Discord-compatible nonce without changing the delivery marker', () => {
44
+ const deliveryId = 'f0e3a009-dd6b-4c6a-a244-646693d9ae8d'
45
+ const body = buildSessionSleepWakeBody({
46
+ deliveryId,
47
+ wakeAt: DUE,
48
+ reason: 'Continue after deployment',
49
+ })
50
+
51
+ expect({
52
+ nonce: body.nonce,
53
+ nonceLength: body.nonce.length,
54
+ markerIncludesDeliveryId: body.embeds[0]?.footer.text.includes(deliveryId),
55
+ }).toMatchInlineSnapshot(`
56
+ {
57
+ "markerIncludesDeliveryId": true,
58
+ "nonce": "f0e3a009dd6b4c6aa24464669",
59
+ "nonceLength": 25,
60
+ }
61
+ `)
62
+ expect(body.nonce.length).toBeLessThanOrEqual(25)
63
+ })
64
+
65
+ test('claim reserves an attempt but stays planned so a crash retries', async () => {
66
+ const deliveryId = await createSleep({
67
+ sessionId: 'ses-crash',
68
+ threadId: 'thr-crash',
69
+ })
70
+
71
+ const claimed = await claimSessionSleepAttempt({
72
+ sessionId: 'ses-crash',
73
+ deliveryId,
74
+ now: NOW,
75
+ retryAfterMs: RETRY_AFTER_MS,
76
+ })
77
+
78
+ expect(claimed?.status).toBe('planned')
79
+ expect(claimed?.attempts).toBe(1)
80
+
81
+ // Simulates the process dying before the Discord post: once the retry
82
+ // window passes the row is due again rather than lost.
83
+ const laterDue = await getDueSessionSleeps({
84
+ now: new Date(NOW.getTime() + RETRY_AFTER_MS + 1),
85
+ retryAfterMs: RETRY_AFTER_MS,
86
+ limit: 10,
87
+ })
88
+ expect(laterDue.map((row) => row.session_id)).toContain('ses-crash')
89
+ })
90
+
91
+ test('a second claim inside the retry window is refused', async () => {
92
+ const deliveryId = await createSleep({
93
+ sessionId: 'ses-window',
94
+ threadId: 'thr-window',
95
+ })
96
+
97
+ const first = await claimSessionSleepAttempt({
98
+ sessionId: 'ses-window',
99
+ deliveryId,
100
+ now: NOW,
101
+ retryAfterMs: RETRY_AFTER_MS,
102
+ })
103
+ const second = await claimSessionSleepAttempt({
104
+ sessionId: 'ses-window',
105
+ deliveryId,
106
+ now: new Date(NOW.getTime() + 1_000),
107
+ retryAfterMs: RETRY_AFTER_MS,
108
+ })
109
+
110
+ expect(first).not.toBe(null)
111
+ expect(second).toBe(null)
112
+ })
113
+
114
+ test('a new sleep occurrence invalidates an in-flight delivery', async () => {
115
+ const staleDeliveryId = await createSleep({
116
+ sessionId: 'ses-generation',
117
+ threadId: 'thr-generation',
118
+ })
119
+ const freshDeliveryId = await createSleep({
120
+ sessionId: 'ses-generation',
121
+ threadId: 'thr-generation',
122
+ wakeAt: new Date('2026-08-19T11:30:00Z'),
123
+ })
124
+ expect(freshDeliveryId).not.toBe(staleDeliveryId)
125
+
126
+ const staleClaim = await claimSessionSleepAttempt({
127
+ sessionId: 'ses-generation',
128
+ deliveryId: staleDeliveryId,
129
+ now: NOW,
130
+ retryAfterMs: RETRY_AFTER_MS,
131
+ })
132
+ // The wake message already in flight for the old occurrence must not be
133
+ // able to deliver against the new one.
134
+ const staleConsume = await consumeSessionSleepWake({
135
+ deliveryId: staleDeliveryId,
136
+ })
137
+
138
+ expect(staleClaim).toBe(null)
139
+ expect(staleConsume).toBe(false)
140
+ expect((await getSessionSleep({ sessionId: 'ses-generation' }))?.status).toBe(
141
+ 'planned',
142
+ )
143
+ })
144
+
145
+ test('a wake becomes a turn at most once', async () => {
146
+ const deliveryId = await createSleep({
147
+ sessionId: 'ses-once',
148
+ threadId: 'thr-once',
149
+ })
150
+
151
+ // A retry can post the same wake twice; only the first delivery may win.
152
+ const first = await consumeSessionSleepWake({ deliveryId })
153
+ const second = await consumeSessionSleepWake({ deliveryId })
154
+
155
+ expect(first).toBe(true)
156
+ expect(second).toBe(false)
157
+ expect((await getSessionSleep({ sessionId: 'ses-once' }))?.status).toBe(
158
+ 'consumed',
159
+ )
160
+ })
161
+
162
+ test('a wake cancelled while posting is dropped instead of starting a turn', async () => {
163
+ const deliveryId = await createSleep({
164
+ sessionId: 'ses-raced',
165
+ threadId: 'thr-raced',
166
+ })
167
+ await claimSessionSleepAttempt({
168
+ sessionId: 'ses-raced',
169
+ deliveryId,
170
+ now: NOW,
171
+ retryAfterMs: RETRY_AFTER_MS,
172
+ })
173
+
174
+ // User takes the conversation back while the post is in flight.
175
+ await cancelSessionSleepForThread({ threadId: 'thr-raced' })
176
+
177
+ expect(await consumeSessionSleepWake({ deliveryId })).toBe(false)
178
+ expect((await getSessionSleep({ sessionId: 'ses-raced' }))?.status).toBe(
179
+ 'cancelled',
180
+ )
181
+ })
182
+
183
+ test('cancel reaches the sleep from any thread bound to the session', async () => {
184
+ const deliveryId = await createSleep({
185
+ sessionId: 'ses-rebound',
186
+ threadId: 'thr-rebound-old',
187
+ })
188
+ // /resume binds the same session to a second thread. A sleep belongs to the
189
+ // session, not to the thread it was created in, so either thread cancels it.
190
+ await setThreadSession('thr-rebound-new', 'ses-rebound')
191
+
192
+ const cancelled = await cancelSessionSleepForThread({
193
+ threadId: 'thr-rebound-new',
194
+ })
195
+
196
+ expect(cancelled).toBe(true)
197
+ expect(await consumeSessionSleepWake({ deliveryId })).toBe(false)
198
+ })
199
+
200
+ test('a wake nobody consumed stays due and is retried', async () => {
201
+ const deliveryId = await createSleep({
202
+ sessionId: 'ses-stale',
203
+ threadId: 'thr-stale',
204
+ })
205
+
206
+ // Posting does not change the status, so a wake that was delivered to
207
+ // Discord but never reached ingress is simply due again after the retry
208
+ // window. This is the same path that covers a crash before posting.
209
+ await claimSessionSleepAttempt({
210
+ sessionId: 'ses-stale',
211
+ deliveryId,
212
+ now: NOW,
213
+ retryAfterMs: RETRY_AFTER_MS,
214
+ })
215
+ expect((await getSessionSleep({ sessionId: 'ses-stale' }))?.status).toBe(
216
+ 'planned',
217
+ )
218
+
219
+ const retryDue = await getDueSessionSleeps({
220
+ now: new Date(NOW.getTime() + RETRY_AFTER_MS + 1),
221
+ retryAfterMs: RETRY_AFTER_MS,
222
+ limit: 50,
223
+ })
224
+ expect(retryDue.map((row) => row.session_id)).toContain('ses-stale')
225
+ })
226
+
227
+ test('failing a delivery is also guarded by delivery_id', async () => {
228
+ const deliveryId = await createSleep({
229
+ sessionId: 'ses-failed',
230
+ threadId: 'thr-failed',
231
+ })
232
+
233
+ expect(
234
+ await markSessionSleepFailed({
235
+ sessionId: 'ses-failed',
236
+ deliveryId: 'some-other-delivery',
237
+ }),
238
+ ).toBe(false)
239
+ expect(
240
+ await markSessionSleepFailed({ sessionId: 'ses-failed', deliveryId }),
241
+ ).toBe(true)
242
+ })
243
+
244
+ test('only planned rows that are due and not just attempted are returned', async () => {
245
+ await createSleep({ sessionId: 'ses-due', threadId: 'thr-due' })
246
+ await createSleep({
247
+ sessionId: 'ses-future',
248
+ threadId: 'thr-future',
249
+ wakeAt: new Date('2026-08-19T13:00:00Z'),
250
+ })
251
+
252
+ const due = await getDueSessionSleeps({
253
+ now: NOW,
254
+ retryAfterMs: RETRY_AFTER_MS,
255
+ limit: 50,
256
+ })
257
+ const ids = due.map((row) => row.session_id)
258
+
259
+ expect(ids).toContain('ses-due')
260
+ expect(ids).not.toContain('ses-future')
261
+ })
262
+ })
263
+
264
+ describe('session to thread resolution', () => {
265
+ test('resolves to the most recently bound thread, not an arbitrary one', async () => {
266
+ // Bindings are ordered by wall-clock time, so space them enough to get
267
+ // distinct millisecond stamps. Real /resume flows are seconds apart.
268
+ const tick = async () => {
269
+ await new Promise((resolve) => {
270
+ setTimeout(resolve, 5)
271
+ })
272
+ }
273
+
274
+ await setThreadSession('thr-first', 'ses-resume')
275
+ await tick()
276
+ // /resume maps the same session into a brand new thread without clearing
277
+ // the old row. The newest binding must win, otherwise plugin tools post
278
+ // into a dead conversation.
279
+ await setThreadSession('thr-second', 'ses-resume')
280
+
281
+ expect(await getThreadIdBySessionId('ses-resume')).toBe('thr-second')
282
+
283
+ await tick()
284
+ // Rebinding the older thread makes it current again. created_at ordering
285
+ // would still answer thr-second here, which is why updated_at exists.
286
+ await setThreadSession('thr-first', 'ses-resume')
287
+
288
+ expect(await getThreadIdBySessionId('ses-resume')).toBe('thr-first')
289
+ })
290
+ })
291
+
292
+ afterAll(async () => {
293
+ await closeDb()
294
+ })
@@ -5,7 +5,6 @@
5
5
  import { describe, test, expect } from 'vitest'
6
6
  import {
7
7
  deriveThreadNameFromSessionTitle,
8
- deriveThreadRenameFromSessionUpdate,
9
8
  } from './session-handler/thread-session-runtime.js'
10
9
 
11
10
  describe('deriveThreadNameFromSessionTitle', () => {
@@ -98,6 +97,42 @@ describe('deriveThreadNameFromSessionTitle', () => {
98
97
  ).toMatchInlineSnapshot(`undefined`)
99
98
  })
100
99
 
100
+ test('ignores a title containing only OpenCode callout markup', () => {
101
+ expect(
102
+ deriveThreadNameFromSessionTitle({
103
+ sessionTitle: '<callout accent="#ef4444">',
104
+ currentName: 'Useful existing title',
105
+ }),
106
+ ).toMatchInlineSnapshot(`undefined`)
107
+ })
108
+
109
+ test('ignores paired OpenCode callout markup with no title text', () => {
110
+ expect(
111
+ deriveThreadNameFromSessionTitle({
112
+ sessionTitle: '<callout accent="#ef4444"></callout>',
113
+ currentName: 'Useful existing title',
114
+ }),
115
+ ).toMatchInlineSnapshot(`undefined`)
116
+ })
117
+
118
+ test('keeps useful text while removing OpenCode callout wrappers', () => {
119
+ expect(
120
+ deriveThreadNameFromSessionTitle({
121
+ sessionTitle: '<callout accent="#ef4444">Useful title</callout>',
122
+ currentName: 'seed',
123
+ }),
124
+ ).toMatchInlineSnapshot(`"Useful title"`)
125
+ })
126
+
127
+ test('preserves a thread prefix around a multiline uppercase callout', () => {
128
+ expect(
129
+ deriveThreadNameFromSessionTitle({
130
+ sessionTitle: '<CALLOUT icon="info">\nUseful title\n</CALLOUT>',
131
+ currentName: 'btw: original title',
132
+ }),
133
+ ).toMatchInlineSnapshot(`"btw: Useful title"`)
134
+ })
135
+
101
136
  test('preserves btw: prefix from current name', () => {
102
137
  expect(
103
138
  deriveThreadNameFromSessionTitle({
@@ -116,65 +151,36 @@ describe('deriveThreadNameFromSessionTitle', () => {
116
151
  ).toMatchInlineSnapshot(`"Fork: Forked task title"`)
117
152
  })
118
153
 
119
- test('returns undefined for null/undefined title', () => {
154
+ test('does not double a worktree prefix copied from the Discord thread name', () => {
120
155
  expect(
121
156
  deriveThreadNameFromSessionTitle({
122
- sessionTitle: null,
123
- currentName: 'seed',
124
- }),
125
- ).toMatchInlineSnapshot(`undefined`)
126
- expect(
127
- deriveThreadNameFromSessionTitle({
128
- sessionTitle: undefined,
129
- currentName: 'seed',
157
+ sessionTitle: '⬦ Fix queue draining',
158
+ currentName: '⬦ Old name',
130
159
  }),
131
- ).toMatchInlineSnapshot(`undefined`)
160
+ ).toMatchInlineSnapshot(`"⬦ Fix queue draining"`)
132
161
  })
133
- })
134
162
 
135
- describe('deriveThreadRenameFromSessionUpdate', () => {
136
- test('skips auto-rename after the thread differs from the persisted synced name', () => {
163
+ test('preserves the worktree prefix', () => {
137
164
  expect(
138
- deriveThreadRenameFromSessionUpdate({
139
- sessionTitle: 'New OpenCode title',
140
- currentName: 'custom name from user',
141
- lastSyncedName: 'Old OpenCode title',
165
+ deriveThreadNameFromSessionTitle({
166
+ sessionTitle: 'Refactor queue',
167
+ currentName: '⬦ old worktree title',
142
168
  }),
143
- ).toMatchInlineSnapshot(`
144
- {
145
- "desiredName": null,
146
- "nextSyncedName": "Old OpenCode title",
147
- }
148
- `)
169
+ ).toMatchInlineSnapshot(`"⬦ Refactor queue"`)
149
170
  })
150
171
 
151
- test('returns desired name while thread still matches the persisted synced name', () => {
172
+ test('returns undefined for null/undefined title', () => {
152
173
  expect(
153
- deriveThreadRenameFromSessionUpdate({
154
- sessionTitle: 'New OpenCode title',
155
- currentName: 'Old OpenCode title',
156
- lastSyncedName: 'Old OpenCode title',
174
+ deriveThreadNameFromSessionTitle({
175
+ sessionTitle: null,
176
+ currentName: 'seed',
157
177
  }),
158
- ).toMatchInlineSnapshot(`
159
- {
160
- "desiredName": "New OpenCode title",
161
- "nextSyncedName": "New OpenCode title",
162
- }
163
- `)
164
- })
165
-
166
- test('remembers a no-op matching title as synced for later manual rename detection', () => {
178
+ ).toMatchInlineSnapshot(`undefined`)
167
179
  expect(
168
- deriveThreadRenameFromSessionUpdate({
169
- sessionTitle: 'Old OpenCode title',
170
- currentName: 'Old OpenCode title',
171
- lastSyncedName: null,
180
+ deriveThreadNameFromSessionTitle({
181
+ sessionTitle: undefined,
182
+ currentName: 'seed',
172
183
  }),
173
- ).toMatchInlineSnapshot(`
174
- {
175
- "desiredName": null,
176
- "nextSyncedName": "Old OpenCode title",
177
- }
178
- `)
184
+ ).toMatchInlineSnapshot(`undefined`)
179
185
  })
180
186
  })