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
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { describe, expect, test } from 'vitest';
6
6
  import {} from './opencode-session-event-log.js';
7
- import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, getLatestTurnTokenUsage, getIdleTokenUsageDelta, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, isSessionBusy, } from './event-stream-state.js';
7
+ import { derivePendingPermissionRequests, getAssistantMessageIdsForLatestUserTurn, getDerivedSubagentSessions, getEventBufferSessionId, getCurrentTurnStartTime, getDerivedSubtaskIndex, getLatestAssistantMessageIdForLatestUserTurn, getLatestRunInfo, getLatestTurnTokenUsage, getIdleTokenUsageDelta, getTokenUsageSessionIdsForIdle, isDerivedChildSession, hasAssistantMessageCompletedBefore, doesLatestUserTurnHaveNaturalCompletion, isAssistantMessageInLatestUserTurn, isAssistantMessageNaturalCompletion, getAssistantMessageKind, getLatestUserMessage, isSummaryAssistantMessage, isSessionBusy, isAssistantTextReadyForQuestion, deriveLatestUnansweredQuestion, shouldBufferSessionEvent, } from './event-stream-state.js';
8
8
  const fixturesDir = path.join(import.meta.dirname, 'event-stream-fixtures');
9
9
  function loadFixture(filename) {
10
10
  const content = fs.readFileSync(path.join(fixturesDir, filename), 'utf8');
@@ -262,6 +262,171 @@ describe('session-user-interruption', () => {
262
262
  expect(getCurrentTurnStartTime({ events, sessionId })).toBe(1772636335777);
263
263
  });
264
264
  });
265
+ describe('compaction summary during an active user turn', () => {
266
+ const sessionId = 'ses_compaction';
267
+ const userMessageId = 'msg_user_compaction';
268
+ const replyMessageId = 'msg_reply_compaction';
269
+ const summaryMessageId = 'msg_summary_compaction';
270
+ function assistantEvent({ messageId, created, completed, summary, }) {
271
+ return eventEntry({
272
+ type: 'message.updated',
273
+ properties: {
274
+ sessionID: sessionId,
275
+ info: {
276
+ id: messageId,
277
+ sessionID: sessionId,
278
+ role: 'assistant',
279
+ parentID: userMessageId,
280
+ time: { created, completed },
281
+ modelID: summary ? 'compaction-model' : 'reply-model',
282
+ providerID: 'test-provider',
283
+ mode: summary ? 'compaction' : 'build',
284
+ agent: summary ? 'compaction' : 'build',
285
+ path: { cwd: '/test', root: '/test' },
286
+ cost: summary ? 2 : 1,
287
+ tokens: {
288
+ input: summary ? 20 : 10,
289
+ output: 1,
290
+ reasoning: 0,
291
+ cache: { read: 0, write: 0 },
292
+ },
293
+ finish: completed ? 'stop' : undefined,
294
+ summary,
295
+ },
296
+ },
297
+ });
298
+ }
299
+ const activeEvents = [
300
+ eventEntry({
301
+ type: 'message.updated',
302
+ properties: {
303
+ sessionID: sessionId,
304
+ info: {
305
+ id: userMessageId,
306
+ sessionID: sessionId,
307
+ role: 'user',
308
+ time: { created: 1 },
309
+ agent: 'build',
310
+ model: { providerID: 'test-provider', modelID: 'reply-model' },
311
+ },
312
+ },
313
+ }),
314
+ assistantEvent({ messageId: replyMessageId, created: 2 }),
315
+ assistantEvent({
316
+ messageId: summaryMessageId,
317
+ created: 3,
318
+ completed: 4,
319
+ summary: true,
320
+ }),
321
+ ];
322
+ test('compaction completion does not replace or complete the user-facing reply', () => {
323
+ expect(getAssistantMessageIdsForLatestUserTurn({
324
+ events: activeEvents,
325
+ sessionId,
326
+ })).toEqual(new Set([replyMessageId]));
327
+ expect(getLatestAssistantMessageIdForLatestUserTurn({
328
+ events: activeEvents,
329
+ sessionId,
330
+ })).toBe(replyMessageId);
331
+ expect(isAssistantMessageInLatestUserTurn({
332
+ events: activeEvents,
333
+ sessionId,
334
+ messageId: summaryMessageId,
335
+ })).toBe(false);
336
+ expect(doesLatestUserTurnHaveNaturalCompletion({
337
+ events: activeEvents,
338
+ sessionId,
339
+ })).toBe(false);
340
+ });
341
+ test('summary identity is derivable while its billed usage remains counted', () => {
342
+ expect(getAssistantMessageKind({
343
+ events: activeEvents.slice(0, 2),
344
+ sessionId,
345
+ messageId: summaryMessageId,
346
+ })).toBe('unknown');
347
+ expect(getAssistantMessageKind({
348
+ events: activeEvents,
349
+ sessionId,
350
+ messageId: summaryMessageId,
351
+ })).toBe('summary');
352
+ expect(getAssistantMessageKind({
353
+ events: activeEvents,
354
+ sessionId,
355
+ messageId: replyMessageId,
356
+ })).toBe('user-facing');
357
+ expect(isSummaryAssistantMessage({
358
+ events: activeEvents,
359
+ sessionId,
360
+ messageId: summaryMessageId,
361
+ })).toBe(true);
362
+ expect(isAssistantMessageNaturalCompletion({
363
+ message: getAssistantMessageById({
364
+ events: activeEvents,
365
+ sessionId,
366
+ messageId: summaryMessageId,
367
+ }),
368
+ })).toBe(false);
369
+ expect(getLatestRunInfo({ events: activeEvents, sessionId })).toEqual({
370
+ model: 'reply-model',
371
+ providerID: 'test-provider',
372
+ agent: 'build',
373
+ tokensUsed: 11,
374
+ });
375
+ expect(getLatestTurnTokenUsage({ events: activeEvents, sessionId })).toMatchObject({
376
+ total: 32,
377
+ cost: 3,
378
+ assistantMessageCount: 2,
379
+ });
380
+ });
381
+ test('the real continuation still completes normally after compaction', () => {
382
+ const completedEvents = [
383
+ ...activeEvents,
384
+ assistantEvent({ messageId: replyMessageId, created: 2, completed: 5 }),
385
+ ];
386
+ expect(doesLatestUserTurnHaveNaturalCompletion({
387
+ events: completedEvents,
388
+ sessionId,
389
+ })).toBe(true);
390
+ });
391
+ test('compaction user messages do not steal the latest user-facing turn', () => {
392
+ const compactionUserId = 'msg_compaction_user';
393
+ const events = [
394
+ ...activeEvents,
395
+ eventEntry({
396
+ type: 'message.updated',
397
+ properties: {
398
+ sessionID: sessionId,
399
+ info: {
400
+ id: compactionUserId,
401
+ sessionID: sessionId,
402
+ role: 'user',
403
+ time: { created: 6 },
404
+ agent: 'build',
405
+ model: { providerID: 'test-provider', modelID: 'reply-model' },
406
+ },
407
+ },
408
+ }),
409
+ eventEntry({
410
+ type: 'message.part.updated',
411
+ properties: {
412
+ sessionID: sessionId,
413
+ part: {
414
+ id: 'prt_compaction',
415
+ sessionID: sessionId,
416
+ messageID: compactionUserId,
417
+ type: 'compaction',
418
+ auto: true,
419
+ },
420
+ },
421
+ }),
422
+ ];
423
+ expect(getLatestUserMessage({ events, sessionId })?.id).toBe(userMessageId);
424
+ expect(getAssistantMessageIdsForLatestUserTurn({
425
+ events,
426
+ sessionId,
427
+ })).toEqual(new Set([replyMessageId]));
428
+ });
429
+ });
265
430
  describe('session-two-completions-same-session', () => {
266
431
  const events = loadFixture('session-two-completions-same-session.jsonl');
267
432
  const sessionId = getSessionId(events);
@@ -983,7 +1148,7 @@ describe('getLatestTurnTokenUsage', () => {
983
1148
  assistantMessageCount: 2,
984
1149
  });
985
1150
  });
986
- test('returns empty when the session has no user message', () => {
1151
+ test('sums assistant tokens when the session has no user message', () => {
987
1152
  const sessionId = 'ses_no_user';
988
1153
  const events = [
989
1154
  assistantEvent({
@@ -1000,16 +1165,16 @@ describe('getLatestTurnTokenUsage', () => {
1000
1165
  }),
1001
1166
  ];
1002
1167
  expect(getLatestTurnTokenUsage({ events, sessionId })).toEqual({
1003
- input: 0,
1004
- output: 0,
1168
+ input: 100,
1169
+ output: 10,
1005
1170
  reasoning: 0,
1006
1171
  cacheRead: 0,
1007
1172
  cacheWrite: 0,
1008
- total: 0,
1173
+ total: 110,
1009
1174
  cost: 0,
1010
- model: undefined,
1011
- providerID: undefined,
1012
- assistantMessageCount: 0,
1175
+ model: 'gpt-5.3-codex',
1176
+ providerID: 'openai',
1177
+ assistantMessageCount: 1,
1013
1178
  userMessageId: undefined,
1014
1179
  });
1015
1180
  });
@@ -1176,4 +1341,567 @@ describe('getIdleTokenUsageDelta', () => {
1176
1341
  total: 20,
1177
1342
  });
1178
1343
  });
1344
+ test('counts child session assistant tokens without a user message.updated', () => {
1345
+ const childSessionId = 'ses_task_child';
1346
+ const events = [
1347
+ eventEntry({
1348
+ type: 'message.updated',
1349
+ properties: {
1350
+ sessionID: childSessionId,
1351
+ info: {
1352
+ id: 'msg_child_asst_1',
1353
+ sessionID: childSessionId,
1354
+ role: 'assistant',
1355
+ time: { created: 2, completed: 3 },
1356
+ parentID: 'msg_child_user_missing',
1357
+ modelID: 'gpt-5.3-codex',
1358
+ providerID: 'openai',
1359
+ mode: 'general',
1360
+ agent: 'general',
1361
+ path: { cwd: '/test', root: '/test' },
1362
+ cost: 0.02,
1363
+ tokens: {
1364
+ total: 40,
1365
+ input: 30,
1366
+ output: 10,
1367
+ reasoning: 4,
1368
+ cache: { read: 0, write: 0 },
1369
+ },
1370
+ finish: 'stop',
1371
+ },
1372
+ },
1373
+ }),
1374
+ idleEvent(childSessionId),
1375
+ ];
1376
+ expect(getIdleTokenUsageDelta({
1377
+ events,
1378
+ sessionId: childSessionId,
1379
+ idleEventIndex: events.length - 1,
1380
+ })).toMatchObject({
1381
+ input: 30,
1382
+ output: 10,
1383
+ reasoning: 4,
1384
+ total: 40,
1385
+ cost: 0.02,
1386
+ assistantMessageCount: 1,
1387
+ });
1388
+ });
1389
+ test('falls back to session.updated Session.tokens for a child with no message.updated', () => {
1390
+ const childSessionId = 'ses_task_child_session_tokens';
1391
+ const events = [
1392
+ eventEntry({
1393
+ type: 'session.updated',
1394
+ properties: {
1395
+ sessionID: childSessionId,
1396
+ info: {
1397
+ id: childSessionId,
1398
+ slug: 'child',
1399
+ projectID: 'prj_1',
1400
+ directory: '/test',
1401
+ parentID: 'ses_main',
1402
+ title: 'child task',
1403
+ version: '1',
1404
+ cost: 0.05,
1405
+ tokens: {
1406
+ input: 100,
1407
+ output: 20,
1408
+ reasoning: 8,
1409
+ cache: { read: 4, write: 1 },
1410
+ },
1411
+ time: { created: 1, updated: 2 },
1412
+ },
1413
+ },
1414
+ }),
1415
+ idleEvent(childSessionId),
1416
+ ];
1417
+ expect(getIdleTokenUsageDelta({
1418
+ events,
1419
+ sessionId: childSessionId,
1420
+ idleEventIndex: events.length - 1,
1421
+ })).toMatchObject({
1422
+ input: 100,
1423
+ output: 20,
1424
+ reasoning: 8,
1425
+ cacheRead: 4,
1426
+ cacheWrite: 1,
1427
+ total: 133,
1428
+ cost: 0.05,
1429
+ });
1430
+ });
1431
+ test('does not re-emit child tokens at parent idle after the child already idled', () => {
1432
+ const childSessionId = 'ses_task_child_dedupe';
1433
+ const events = [
1434
+ eventEntry({
1435
+ type: 'message.updated',
1436
+ properties: {
1437
+ sessionID: childSessionId,
1438
+ info: {
1439
+ id: 'msg_child_asst_1',
1440
+ sessionID: childSessionId,
1441
+ role: 'assistant',
1442
+ time: { created: 2, completed: 3 },
1443
+ parentID: 'msg_child_user_missing',
1444
+ modelID: 'gpt-5.3-codex',
1445
+ providerID: 'openai',
1446
+ mode: 'general',
1447
+ agent: 'general',
1448
+ path: { cwd: '/test', root: '/test' },
1449
+ cost: 0,
1450
+ tokens: {
1451
+ total: 40,
1452
+ input: 30,
1453
+ output: 10,
1454
+ reasoning: 0,
1455
+ cache: { read: 0, write: 0 },
1456
+ },
1457
+ finish: 'stop',
1458
+ },
1459
+ },
1460
+ }),
1461
+ idleEvent(childSessionId),
1462
+ idleEvent('ses_main'),
1463
+ ];
1464
+ expect(getIdleTokenUsageDelta({
1465
+ events,
1466
+ sessionId: childSessionId,
1467
+ idleEventIndex: 1,
1468
+ })).toMatchObject({ total: 40 });
1469
+ expect(getIdleTokenUsageDelta({
1470
+ events,
1471
+ sessionId: childSessionId,
1472
+ idleEventIndex: 2,
1473
+ })).toBeUndefined();
1474
+ });
1475
+ });
1476
+ describe('task child session token tracking', () => {
1477
+ function idleEvent(sessionId) {
1478
+ return eventEntry({
1479
+ type: 'session.idle',
1480
+ properties: { sessionID: sessionId },
1481
+ });
1482
+ }
1483
+ test('isDerivedChildSession is true from session.created parentID before task metadata', () => {
1484
+ const mainSessionId = 'ses_main';
1485
+ const childSessionId = 'ses_child';
1486
+ const events = [
1487
+ eventEntry({
1488
+ type: 'session.created',
1489
+ properties: {
1490
+ sessionID: childSessionId,
1491
+ info: {
1492
+ id: childSessionId,
1493
+ slug: 'child',
1494
+ projectID: 'prj_1',
1495
+ directory: '/test',
1496
+ parentID: mainSessionId,
1497
+ title: 'explore files',
1498
+ version: '1',
1499
+ time: { created: 1, updated: 1 },
1500
+ },
1501
+ },
1502
+ }),
1503
+ ];
1504
+ expect(isDerivedChildSession({
1505
+ events,
1506
+ mainSessionId,
1507
+ candidateSessionId: childSessionId,
1508
+ })).toBe(true);
1509
+ expect(isDerivedChildSession({
1510
+ events,
1511
+ mainSessionId,
1512
+ candidateSessionId: 'ses_unrelated',
1513
+ })).toBe(false);
1514
+ });
1515
+ test('main idle also returns child session ids so their tokens can be tracked', () => {
1516
+ const mainSessionId = 'ses_main';
1517
+ const childSessionId = 'ses_child';
1518
+ const events = [
1519
+ eventEntry({
1520
+ type: 'session.created',
1521
+ properties: {
1522
+ sessionID: childSessionId,
1523
+ info: {
1524
+ id: childSessionId,
1525
+ slug: 'child',
1526
+ projectID: 'prj_1',
1527
+ directory: '/test',
1528
+ parentID: mainSessionId,
1529
+ title: 'child task',
1530
+ version: '1',
1531
+ time: { created: 1, updated: 1 },
1532
+ },
1533
+ },
1534
+ }),
1535
+ eventEntry({
1536
+ type: 'message.part.updated',
1537
+ properties: {
1538
+ part: {
1539
+ id: 'prt_task',
1540
+ sessionID: mainSessionId,
1541
+ messageID: 'msg_asst',
1542
+ type: 'tool',
1543
+ callID: 'call_task',
1544
+ tool: 'task',
1545
+ state: {
1546
+ status: 'running',
1547
+ input: { subagent_type: 'general' },
1548
+ metadata: { sessionId: childSessionId },
1549
+ },
1550
+ },
1551
+ },
1552
+ }),
1553
+ idleEvent(mainSessionId),
1554
+ ];
1555
+ expect(getTokenUsageSessionIdsForIdle({
1556
+ events,
1557
+ mainSessionId,
1558
+ idleSessionId: mainSessionId,
1559
+ })).toEqual([mainSessionId, childSessionId]);
1560
+ expect(getTokenUsageSessionIdsForIdle({
1561
+ events,
1562
+ mainSessionId,
1563
+ idleSessionId: childSessionId,
1564
+ })).toEqual([childSessionId]);
1565
+ });
1566
+ });
1567
+ describe('question waits for preceding text-end', () => {
1568
+ const sessionId = 'ses_question_text';
1569
+ const messageId = 'msg_asst_question';
1570
+ const questionId = 'que_1';
1571
+ const textStart = eventEntry({
1572
+ type: 'message.part.updated',
1573
+ properties: {
1574
+ sessionID: sessionId,
1575
+ part: {
1576
+ id: 'prt_text',
1577
+ sessionID: sessionId,
1578
+ messageID: messageId,
1579
+ type: 'text',
1580
+ text: '',
1581
+ time: { start: 1 },
1582
+ },
1583
+ },
1584
+ });
1585
+ const questionAsked = eventEntry({
1586
+ type: 'question.asked',
1587
+ properties: {
1588
+ id: questionId,
1589
+ sessionID: sessionId,
1590
+ questions: [{
1591
+ question: 'What next?',
1592
+ header: 'Next step',
1593
+ options: [
1594
+ { label: 'Commit', description: 'Commit these files' },
1595
+ ],
1596
+ }],
1597
+ tool: {
1598
+ messageID: messageId,
1599
+ callID: 'call_question',
1600
+ },
1601
+ },
1602
+ });
1603
+ const textEnd = eventEntry({
1604
+ type: 'message.part.updated',
1605
+ properties: {
1606
+ sessionID: sessionId,
1607
+ part: {
1608
+ id: 'prt_text',
1609
+ sessionID: sessionId,
1610
+ messageID: messageId,
1611
+ type: 'text',
1612
+ text: 'Done callout',
1613
+ time: { start: 1, end: 2 },
1614
+ },
1615
+ },
1616
+ });
1617
+ const questionError = eventEntry({
1618
+ type: 'message.part.updated',
1619
+ properties: {
1620
+ sessionID: sessionId,
1621
+ part: {
1622
+ id: 'prt_question',
1623
+ sessionID: sessionId,
1624
+ messageID: messageId,
1625
+ type: 'tool',
1626
+ tool: 'question',
1627
+ callID: 'call_question',
1628
+ state: {
1629
+ status: 'error',
1630
+ error: 'Aborted',
1631
+ time: { start: 2, end: 3 },
1632
+ },
1633
+ },
1634
+ },
1635
+ });
1636
+ test('text is not ready when question.asked arrives before time.end', () => {
1637
+ const events = [textStart, questionAsked];
1638
+ expect(isAssistantTextReadyForQuestion({
1639
+ events,
1640
+ sessionId,
1641
+ messageId,
1642
+ })).toBe(false);
1643
+ expect(deriveLatestUnansweredQuestion({
1644
+ events,
1645
+ sessionId,
1646
+ })).toMatchObject({
1647
+ id: questionId,
1648
+ tool: { messageID: messageId },
1649
+ });
1650
+ });
1651
+ test('text is ready after time.end, and still unanswered', () => {
1652
+ const events = [textStart, questionAsked, textEnd];
1653
+ expect(isAssistantTextReadyForQuestion({
1654
+ events,
1655
+ sessionId,
1656
+ messageId,
1657
+ })).toBe(true);
1658
+ expect(deriveLatestUnansweredQuestion({
1659
+ events,
1660
+ sessionId,
1661
+ })?.id).toBe(questionId);
1662
+ });
1663
+ test('no text part means the question is ready immediately', () => {
1664
+ const events = [questionAsked];
1665
+ expect(isAssistantTextReadyForQuestion({
1666
+ events,
1667
+ sessionId,
1668
+ messageId,
1669
+ })).toBe(true);
1670
+ });
1671
+ test('aborted question is not unanswered', () => {
1672
+ const events = [textStart, questionAsked, textEnd, questionError];
1673
+ expect(deriveLatestUnansweredQuestion({
1674
+ events,
1675
+ sessionId,
1676
+ })).toBeUndefined();
1677
+ });
1678
+ test('question from an older user turn is not unanswered', () => {
1679
+ const firstUser = eventEntry({
1680
+ type: 'message.updated',
1681
+ properties: {
1682
+ sessionID: sessionId,
1683
+ info: {
1684
+ id: 'msg_user_first',
1685
+ sessionID: sessionId,
1686
+ role: 'user',
1687
+ time: { created: 1 },
1688
+ agent: 'build',
1689
+ model: {
1690
+ providerID: 'deterministic-provider',
1691
+ modelID: 'deterministic-v2',
1692
+ },
1693
+ },
1694
+ },
1695
+ });
1696
+ const questionAssistant = eventEntry({
1697
+ type: 'message.updated',
1698
+ properties: {
1699
+ sessionID: sessionId,
1700
+ info: {
1701
+ id: messageId,
1702
+ sessionID: sessionId,
1703
+ role: 'assistant',
1704
+ time: { created: 2 },
1705
+ parentID: 'msg_user_first',
1706
+ modelID: 'deterministic-v2',
1707
+ providerID: 'deterministic-provider',
1708
+ mode: 'build',
1709
+ agent: 'build',
1710
+ path: { cwd: '/test', root: '/test' },
1711
+ cost: 0,
1712
+ tokens: {
1713
+ input: 1,
1714
+ output: 1,
1715
+ reasoning: 0,
1716
+ cache: { read: 0, write: 0 },
1717
+ },
1718
+ },
1719
+ },
1720
+ });
1721
+ const nextUser = eventEntry({
1722
+ type: 'message.updated',
1723
+ properties: {
1724
+ sessionID: sessionId,
1725
+ info: {
1726
+ id: 'msg_user_commit',
1727
+ sessionID: sessionId,
1728
+ role: 'user',
1729
+ time: { created: 3 },
1730
+ agent: 'build',
1731
+ model: {
1732
+ providerID: 'deterministic-provider',
1733
+ modelID: 'deterministic-v2',
1734
+ },
1735
+ },
1736
+ },
1737
+ });
1738
+ const events = [
1739
+ firstUser,
1740
+ questionAssistant,
1741
+ textStart,
1742
+ questionAsked,
1743
+ textEnd,
1744
+ nextUser,
1745
+ ];
1746
+ expect(deriveLatestUnansweredQuestion({
1747
+ events,
1748
+ sessionId,
1749
+ })).toBeUndefined();
1750
+ });
1751
+ });
1752
+ describe('shouldBufferSessionEvent', () => {
1753
+ const mainSessionId = 'ses_parent';
1754
+ const childSessionId = 'ses_child';
1755
+ const btwSessionId = 'ses_btw_fork';
1756
+ test('keeps the thread session and drops unrelated /btw fork events', () => {
1757
+ const parentBusy = eventEntry({
1758
+ type: 'session.status',
1759
+ properties: {
1760
+ sessionID: mainSessionId,
1761
+ status: { type: 'busy' },
1762
+ },
1763
+ }).event;
1764
+ const btwClone = eventEntry({
1765
+ type: 'message.updated',
1766
+ properties: {
1767
+ sessionID: btwSessionId,
1768
+ info: {
1769
+ id: 'msg_cloned',
1770
+ sessionID: btwSessionId,
1771
+ role: 'user',
1772
+ time: { created: 1 },
1773
+ agent: 'build',
1774
+ model: { providerID: 'test', modelID: 'test' },
1775
+ },
1776
+ },
1777
+ }).event;
1778
+ expect(shouldBufferSessionEvent({
1779
+ event: parentBusy,
1780
+ mainSessionId,
1781
+ isKnownChildSession: () => false,
1782
+ })).toBe(true);
1783
+ expect(shouldBufferSessionEvent({
1784
+ event: btwClone,
1785
+ mainSessionId,
1786
+ isKnownChildSession: () => false,
1787
+ })).toBe(false);
1788
+ });
1789
+ test('keeps the first child session.created so later child events can be recognized', () => {
1790
+ const childCreated = eventEntry({
1791
+ type: 'session.created',
1792
+ properties: {
1793
+ info: {
1794
+ id: childSessionId,
1795
+ parentID: mainSessionId,
1796
+ title: 'task',
1797
+ version: '1',
1798
+ projectID: 'proj',
1799
+ directory: '/test',
1800
+ time: { created: 1, updated: 1 },
1801
+ },
1802
+ },
1803
+ }).event;
1804
+ expect(shouldBufferSessionEvent({
1805
+ event: childCreated,
1806
+ mainSessionId,
1807
+ isKnownChildSession: () => false,
1808
+ })).toBe(true);
1809
+ });
1810
+ test('keeps known child session events and global toasts', () => {
1811
+ const childBusy = eventEntry({
1812
+ type: 'session.status',
1813
+ properties: {
1814
+ sessionID: childSessionId,
1815
+ status: { type: 'busy' },
1816
+ },
1817
+ }).event;
1818
+ const toast = eventEntry({
1819
+ type: 'tui.toast.show',
1820
+ properties: {
1821
+ title: 'ok',
1822
+ message: 'done',
1823
+ variant: 'info',
1824
+ },
1825
+ }).event;
1826
+ const diff = eventEntry({
1827
+ type: 'session.diff',
1828
+ properties: {
1829
+ sessionID: mainSessionId,
1830
+ diff: [],
1831
+ },
1832
+ }).event;
1833
+ expect(shouldBufferSessionEvent({
1834
+ event: childBusy,
1835
+ mainSessionId,
1836
+ isKnownChildSession: (sessionId) => sessionId === childSessionId,
1837
+ })).toBe(true);
1838
+ expect(shouldBufferSessionEvent({
1839
+ event: toast,
1840
+ mainSessionId,
1841
+ isKnownChildSession: () => false,
1842
+ })).toBe(true);
1843
+ expect(shouldBufferSessionEvent({
1844
+ event: diff,
1845
+ mainSessionId,
1846
+ isKnownChildSession: () => false,
1847
+ })).toBe(false);
1848
+ });
1849
+ test('drops unrelated session.next text deltas that lack a typed session helper', () => {
1850
+ const btwDelta = eventEntry({
1851
+ type: 'session.next.text.delta',
1852
+ properties: {
1853
+ timestamp: 1,
1854
+ sessionID: btwSessionId,
1855
+ assistantMessageID: 'msg_btw',
1856
+ textID: 'txt_btw',
1857
+ delta: 'x',
1858
+ },
1859
+ }).event;
1860
+ const parentDelta = eventEntry({
1861
+ type: 'session.next.text.delta',
1862
+ properties: {
1863
+ timestamp: 1,
1864
+ sessionID: mainSessionId,
1865
+ assistantMessageID: 'msg_parent',
1866
+ textID: 'txt_parent',
1867
+ delta: 'x',
1868
+ },
1869
+ }).event;
1870
+ expect(getEventBufferSessionId(btwDelta)).toBe(btwSessionId);
1871
+ expect(shouldBufferSessionEvent({
1872
+ event: btwDelta,
1873
+ mainSessionId,
1874
+ isKnownChildSession: () => false,
1875
+ })).toBe(false);
1876
+ expect(shouldBufferSessionEvent({
1877
+ event: parentDelta,
1878
+ mainSessionId,
1879
+ isKnownChildSession: () => false,
1880
+ })).toBe(false);
1881
+ });
1882
+ test('drops scoped events until the thread session id is bound', () => {
1883
+ const parentBusy = eventEntry({
1884
+ type: 'session.status',
1885
+ properties: {
1886
+ sessionID: mainSessionId,
1887
+ status: { type: 'busy' },
1888
+ },
1889
+ }).event;
1890
+ const toast = eventEntry({
1891
+ type: 'tui.toast.show',
1892
+ properties: {
1893
+ title: 'ok',
1894
+ message: 'done',
1895
+ variant: 'info',
1896
+ },
1897
+ }).event;
1898
+ expect(shouldBufferSessionEvent({
1899
+ event: parentBusy,
1900
+ isKnownChildSession: () => false,
1901
+ })).toBe(false);
1902
+ expect(shouldBufferSessionEvent({
1903
+ event: toast,
1904
+ isKnownChildSession: () => false,
1905
+ })).toBe(true);
1906
+ });
1179
1907
  });