@cuongtran001/kanna 1.2.0 → 1.3.1

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 (196) hide show
  1. package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
  5. package/dist/client/assets/channel-BEYTrUAm.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
  27. package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
  28. package/dist/client/assets/index-CLsOWXff.css +32 -0
  29. package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
  34. package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
  49. package/dist/client/index.html +2 -2
  50. package/package.json +1 -1
  51. package/src/server/agent-coordinator-types.ts +158 -0
  52. package/src/server/agent-coordinator.ts +919 -0
  53. package/src/server/agent-deps-builders.ts +425 -0
  54. package/src/server/agent.oauth-rotation.test.ts +101 -0
  55. package/src/server/agent.test.ts +24 -8
  56. package/src/server/agent.ts +35 -4963
  57. package/src/server/auto-continue/read-model.test.ts +2 -2
  58. package/src/server/auto-continue/read-model.ts +4 -1
  59. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  60. package/src/server/claude-autocontinue-commands.ts +256 -0
  61. package/src/server/claude-cancel-handler.test.ts +472 -0
  62. package/src/server/claude-cancel-handler.ts +241 -0
  63. package/src/server/claude-chat-management.test.ts +395 -0
  64. package/src/server/claude-chat-management.ts +251 -0
  65. package/src/server/claude-harness-stream.test.ts +273 -0
  66. package/src/server/claude-harness-stream.ts +158 -0
  67. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  68. package/src/server/claude-loop-orch-commands.ts +466 -0
  69. package/src/server/claude-message-normalizer.test.ts +213 -0
  70. package/src/server/claude-message-normalizer.ts +436 -0
  71. package/src/server/claude-prompt-helpers.test.ts +248 -0
  72. package/src/server/claude-prompt-helpers.ts +139 -0
  73. package/src/server/claude-sdk-queue.test.ts +169 -0
  74. package/src/server/claude-sdk-queue.ts +82 -0
  75. package/src/server/claude-send-command.test.ts +414 -0
  76. package/src/server/claude-send-command.ts +420 -0
  77. package/src/server/claude-session-config-helpers.test.ts +279 -0
  78. package/src/server/claude-session-config-helpers.ts +151 -0
  79. package/src/server/claude-session-config.test.ts +250 -0
  80. package/src/server/claude-session-config.ts +159 -0
  81. package/src/server/claude-session-error-handler.test.ts +498 -0
  82. package/src/server/claude-session-error-handler.ts +361 -0
  83. package/src/server/claude-session-lifecycle.test.ts +669 -0
  84. package/src/server/claude-session-lifecycle.ts +283 -0
  85. package/src/server/claude-session-rebuild.test.ts +224 -0
  86. package/src/server/claude-session-rebuild.ts +101 -0
  87. package/src/server/claude-session-runner.test.ts +523 -0
  88. package/src/server/claude-session-runner.ts +521 -0
  89. package/src/server/claude-session-spawner.test.ts +401 -0
  90. package/src/server/claude-session-spawner.ts +401 -0
  91. package/src/server/claude-session-start.test.ts +205 -0
  92. package/src/server/claude-session-start.ts +277 -0
  93. package/src/server/claude-session-state-queries.test.ts +344 -0
  94. package/src/server/claude-session-state-queries.ts +160 -0
  95. package/src/server/claude-session-state.ts +104 -0
  96. package/src/server/claude-slash-commands.test.ts +277 -0
  97. package/src/server/claude-slash-commands.ts +234 -0
  98. package/src/server/claude-spawn-helpers.test.ts +221 -0
  99. package/src/server/claude-spawn-helpers.ts +179 -0
  100. package/src/server/claude-steer-log.test.ts +243 -0
  101. package/src/server/claude-steer-log.ts +58 -0
  102. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  103. package/src/server/claude-subagent-tool-response.ts +165 -0
  104. package/src/server/claude-subagent-wiring.test.ts +263 -0
  105. package/src/server/claude-subagent-wiring.ts +324 -0
  106. package/src/server/claude-tool-respond.test.ts +321 -0
  107. package/src/server/claude-tool-respond.ts +142 -0
  108. package/src/server/claude-turn-runner.test.ts +310 -0
  109. package/src/server/claude-turn-runner.ts +228 -0
  110. package/src/server/claude-turn-starter.test.ts +266 -0
  111. package/src/server/claude-turn-starter.ts +566 -0
  112. package/src/server/claude-usage-math.test.ts +169 -0
  113. package/src/server/claude-usage-math.ts +132 -0
  114. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  115. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  116. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  117. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  118. package/src/server/diff-store-errors.ts +85 -0
  119. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  120. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  121. package/src/server/diff-store-io.adapter.ts +27 -0
  122. package/src/server/diff-store-parse.test.ts +212 -0
  123. package/src/server/diff-store-parse.ts +122 -0
  124. package/src/server/diff-store-state.ts +82 -0
  125. package/src/server/diff-store.ts +76 -1854
  126. package/src/server/event-store-apply.test.ts +280 -0
  127. package/src/server/event-store-apply.ts +132 -0
  128. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  129. package/src/server/event-store-chat-lifecycle.ts +441 -0
  130. package/src/server/event-store-entity-write.ts +356 -0
  131. package/src/server/event-store-helpers.test.ts +230 -0
  132. package/src/server/event-store-helpers.ts +235 -0
  133. package/src/server/event-store-init.ts +276 -0
  134. package/src/server/event-store-messages.adapter.test.ts +365 -0
  135. package/src/server/event-store-messages.adapter.ts +213 -0
  136. package/src/server/event-store-orch-subscription.ts +124 -0
  137. package/src/server/event-store-orch.test.ts +462 -0
  138. package/src/server/event-store-orch.ts +312 -0
  139. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  140. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  141. package/src/server/event-store-snapshot.test.ts +597 -0
  142. package/src/server/event-store-snapshot.ts +658 -0
  143. package/src/server/event-store-subagent.test.ts +408 -0
  144. package/src/server/event-store-subagent.ts +198 -0
  145. package/src/server/event-store-tool-requests.test.ts +210 -0
  146. package/src/server/event-store-tool-requests.ts +131 -0
  147. package/src/server/event-store-transcript-write.adapter.ts +285 -0
  148. package/src/server/event-store-write-ops.test.ts +554 -0
  149. package/src/server/event-store-write-ops.ts +529 -0
  150. package/src/server/event-store.test.ts +35 -0
  151. package/src/server/event-store.ts +251 -2284
  152. package/src/server/events.ts +3 -0
  153. package/src/server/harness-types.ts +15 -1
  154. package/src/server/oauth-errors.ts +19 -0
  155. package/src/server/read-models.ts +26 -1
  156. package/src/server/subagent-orchestrator.ts +3 -0
  157. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  158. package/src/server/ws-router-agent-ctrl.ts +137 -0
  159. package/src/server/ws-router-broadcast.ts +488 -0
  160. package/src/server/ws-router-chat.test.ts +461 -0
  161. package/src/server/ws-router-chat.ts +279 -0
  162. package/src/server/ws-router-defaults.test.ts +283 -0
  163. package/src/server/ws-router-defaults.ts +434 -0
  164. package/src/server/ws-router-diff.test.ts +167 -0
  165. package/src/server/ws-router-diff.ts +250 -0
  166. package/src/server/ws-router-envelope.ts +329 -0
  167. package/src/server/ws-router-misc.test.ts +422 -0
  168. package/src/server/ws-router-misc.ts +265 -0
  169. package/src/server/ws-router-orch.test.ts +190 -0
  170. package/src/server/ws-router-orch.ts +93 -0
  171. package/src/server/ws-router-project.test.ts +403 -0
  172. package/src/server/ws-router-project.ts +256 -0
  173. package/src/server/ws-router-push.test.ts +193 -0
  174. package/src/server/ws-router-push.ts +120 -0
  175. package/src/server/ws-router-settings.test.ts +173 -0
  176. package/src/server/ws-router-settings.ts +416 -0
  177. package/src/server/ws-router-skills.test.ts +194 -0
  178. package/src/server/ws-router-skills.ts +204 -0
  179. package/src/server/ws-router-utils.ts +224 -0
  180. package/src/server/ws-router.ts +339 -2254
  181. package/src/shared/app-settings-types.ts +365 -0
  182. package/src/shared/core-types.ts +27 -0
  183. package/src/shared/git-diff-types.ts +165 -0
  184. package/src/shared/loop-progress.test.ts +106 -0
  185. package/src/shared/loop-progress.ts +88 -0
  186. package/src/shared/mcp-types.ts +105 -0
  187. package/src/shared/provider-model-types.ts +437 -0
  188. package/src/shared/subagent-types.ts +198 -0
  189. package/src/shared/tool-call-types.ts +314 -0
  190. package/src/shared/transcript-types.ts +270 -0
  191. package/src/shared/types.ts +27 -1668
  192. package/dist/client/assets/channel-BMkhjiLI.js +0 -1
  193. package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
  194. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
  195. package/dist/client/assets/index-H6C-f2Vp.css +0 -32
  196. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-Ca2U11af.js +0 -1
@@ -0,0 +1,169 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ maxClaudeContextWindowFromModelUsage,
4
+ normalizeClaudeUsageSnapshot,
5
+ parseConfiguredContextWindowFromModelId,
6
+ resolveFinalTurnUsage,
7
+ } from "./claude-usage-math"
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // normalizeClaudeUsageSnapshot
11
+ // ---------------------------------------------------------------------------
12
+
13
+ describe("normalizeClaudeUsageSnapshot", () => {
14
+ test("returns null for null input", () => {
15
+ expect(normalizeClaudeUsageSnapshot(null)).toBeNull()
16
+ })
17
+
18
+ test("returns null for empty object (zero usedTokens)", () => {
19
+ expect(normalizeClaudeUsageSnapshot({})).toBeNull()
20
+ })
21
+
22
+ test("returns null for non-record input", () => {
23
+ expect(normalizeClaudeUsageSnapshot("string")).toBeNull()
24
+ expect(normalizeClaudeUsageSnapshot(42)).toBeNull()
25
+ })
26
+
27
+ test("normalizes basic input/output tokens", () => {
28
+ const result = normalizeClaudeUsageSnapshot({
29
+ input_tokens: 100,
30
+ output_tokens: 50,
31
+ })
32
+ expect(result).not.toBeNull()
33
+ expect(result!.usedTokens).toBe(150)
34
+ expect(result!.inputTokens).toBe(100)
35
+ expect(result!.outputTokens).toBe(50)
36
+ })
37
+
38
+ test("accepts camelCase field names", () => {
39
+ const result = normalizeClaudeUsageSnapshot({
40
+ inputTokens: 200,
41
+ outputTokens: 80,
42
+ })
43
+ expect(result).not.toBeNull()
44
+ expect(result!.usedTokens).toBe(280)
45
+ expect(result!.inputTokens).toBe(200)
46
+ })
47
+
48
+ test("includes cachedInputTokens when cache_read_input_tokens > 0", () => {
49
+ const result = normalizeClaudeUsageSnapshot({
50
+ input_tokens: 100,
51
+ output_tokens: 20,
52
+ cache_read_input_tokens: 30,
53
+ })
54
+ expect(result).not.toBeNull()
55
+ // usedTokens includes cached tokens (which are part of total input)
56
+ expect(result!.cachedInputTokens).toBe(30)
57
+ })
58
+
59
+ test("passes maxTokens through when provided and positive", () => {
60
+ const result = normalizeClaudeUsageSnapshot(
61
+ { input_tokens: 500, output_tokens: 100 },
62
+ 200_000,
63
+ )
64
+ expect(result).not.toBeNull()
65
+ expect(result!.maxTokens).toBe(200_000)
66
+ })
67
+
68
+ test("omits maxTokens when not provided", () => {
69
+ const result = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 100 })
70
+ expect(result).not.toBeNull()
71
+ expect(result!.maxTokens).toBeUndefined()
72
+ })
73
+
74
+ test("includes reasoningOutputTokens when provided", () => {
75
+ const result = normalizeClaudeUsageSnapshot({
76
+ input_tokens: 100,
77
+ output_tokens: 50,
78
+ reasoning_output_tokens: 20,
79
+ })
80
+ expect(result).not.toBeNull()
81
+ expect(result!.reasoningOutputTokens).toBe(20)
82
+ })
83
+ })
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // resolveFinalTurnUsage
87
+ // ---------------------------------------------------------------------------
88
+
89
+ describe("resolveFinalTurnUsage", () => {
90
+ test("returns null when latestUsageSnapshot is null", () => {
91
+ expect(resolveFinalTurnUsage(null, null, undefined)).toBeNull()
92
+ expect(resolveFinalTurnUsage(null, normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 10 }), 200_000)).toBeNull()
93
+ })
94
+
95
+ test("propagates maxTokens from lastKnownContextWindow", () => {
96
+ const live = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 50 })!
97
+ const result = resolveFinalTurnUsage(live, null, 200_000)
98
+ expect(result!.maxTokens).toBe(200_000)
99
+ })
100
+
101
+ test("enriches totalProcessedTokens when accumulated > live usedTokens", () => {
102
+ const live = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 50 })!
103
+ const accumulated = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 50 })!
104
+ expect(accumulated.usedTokens).toBeGreaterThan(live.usedTokens)
105
+
106
+ const result = resolveFinalTurnUsage(live, accumulated, undefined)
107
+ expect(result!.totalProcessedTokens).toBe(accumulated.usedTokens)
108
+ })
109
+
110
+ test("does NOT enrich totalProcessedTokens when accumulated <= live usedTokens", () => {
111
+ const live = normalizeClaudeUsageSnapshot({ input_tokens: 500, output_tokens: 50 })!
112
+ const accumulated = normalizeClaudeUsageSnapshot({ input_tokens: 100, output_tokens: 20 })!
113
+ expect(accumulated.usedTokens).toBeLessThanOrEqual(live.usedTokens)
114
+
115
+ const result = resolveFinalTurnUsage(live, accumulated, undefined)
116
+ expect(result!.totalProcessedTokens).toBeUndefined()
117
+ })
118
+ })
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // maxClaudeContextWindowFromModelUsage
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("maxClaudeContextWindowFromModelUsage", () => {
125
+ test("returns undefined for null", () => {
126
+ expect(maxClaudeContextWindowFromModelUsage(null)).toBeUndefined()
127
+ })
128
+
129
+ test("returns undefined for non-record input", () => {
130
+ expect(maxClaudeContextWindowFromModelUsage("string")).toBeUndefined()
131
+ expect(maxClaudeContextWindowFromModelUsage(42)).toBeUndefined()
132
+ })
133
+
134
+ test("returns undefined for empty object", () => {
135
+ expect(maxClaudeContextWindowFromModelUsage({})).toBeUndefined()
136
+ })
137
+
138
+ test("returns max contextWindow across multiple models", () => {
139
+ const modelUsage = {
140
+ "claude-opus": { contextWindow: 200_000, inputTokens: 100 },
141
+ "claude-sonnet": { context_window: 1_000_000, inputTokens: 200 },
142
+ }
143
+ expect(maxClaudeContextWindowFromModelUsage(modelUsage)).toBe(1_000_000)
144
+ })
145
+
146
+ test("accepts context_window (snake_case) field name", () => {
147
+ const modelUsage = {
148
+ model: { context_window: 150_000 },
149
+ }
150
+ expect(maxClaudeContextWindowFromModelUsage(modelUsage)).toBe(150_000)
151
+ })
152
+ })
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // parseConfiguredContextWindowFromModelId
156
+ // ---------------------------------------------------------------------------
157
+
158
+ describe("parseConfiguredContextWindowFromModelId", () => {
159
+ test("returns 1_000_000 for model ids ending with [1m]", () => {
160
+ expect(parseConfiguredContextWindowFromModelId("claude-opus-4-6[1m]")).toBe(1_000_000)
161
+ expect(parseConfiguredContextWindowFromModelId("claude-sonnet-4-7[1m]")).toBe(1_000_000)
162
+ })
163
+
164
+ test("returns undefined for model ids without [1m] suffix", () => {
165
+ expect(parseConfiguredContextWindowFromModelId("claude-opus-4-6")).toBeUndefined()
166
+ expect(parseConfiguredContextWindowFromModelId("claude-sonnet-4-7")).toBeUndefined()
167
+ expect(parseConfiguredContextWindowFromModelId("")).toBeUndefined()
168
+ })
169
+ })
@@ -0,0 +1,132 @@
1
+ /**
2
+ * claude-usage-math.ts
3
+ *
4
+ * Pure context-window usage math: normalises raw SDK token-count shapes into
5
+ * typed ContextWindowUsageSnapshot values. No IO, no side effects.
6
+ *
7
+ * Extracted from agent.ts — see decompose-large-files loop.
8
+ */
9
+ import type { ContextWindowUsageSnapshot } from "../shared/types"
10
+ import { isRecord } from "../shared/errors"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Private narrowing helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function asRecord<T>(value: T): Record<string, unknown> | null {
17
+ return isRecord(value) ? value : null
18
+ }
19
+
20
+ function asNumber<T>(value: T): number | undefined {
21
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Public exports
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Normalise the raw `usage` object from a Claude SDK assistant/result message
30
+ * into a typed ContextWindowUsageSnapshot, returning null when the value is
31
+ * empty or contains no positive token counts.
32
+ *
33
+ * Accepts both camelCase (SDK internal) and snake_case (JSON-wire) field names.
34
+ */
35
+ export function normalizeClaudeUsageSnapshot<T>(
36
+ value: T,
37
+ maxTokens?: number,
38
+ ): ContextWindowUsageSnapshot | null {
39
+ const usage = asRecord(value)
40
+ if (!usage) return null
41
+
42
+ const directInputTokens = asNumber(usage.input_tokens) ?? asNumber(usage.inputTokens) ?? 0
43
+ const cacheCreationInputTokens =
44
+ asNumber(usage.cache_creation_input_tokens) ?? asNumber(usage.cacheCreationInputTokens) ?? 0
45
+ const cacheReadInputTokens =
46
+ asNumber(usage.cache_read_input_tokens) ?? asNumber(usage.cacheReadInputTokens) ?? 0
47
+ const outputTokens = asNumber(usage.output_tokens) ?? asNumber(usage.outputTokens) ?? 0
48
+ const reasoningOutputTokens =
49
+ asNumber(usage.reasoning_output_tokens) ?? asNumber(usage.reasoningOutputTokens)
50
+ const toolUses = asNumber(usage.tool_uses) ?? asNumber(usage.toolUses)
51
+ const durationMs = asNumber(usage.duration_ms) ?? asNumber(usage.durationMs)
52
+
53
+ const inputTokens = directInputTokens + cacheCreationInputTokens + cacheReadInputTokens
54
+ const usedTokens = inputTokens + outputTokens
55
+ if (usedTokens <= 0) {
56
+ return null
57
+ }
58
+
59
+ return {
60
+ usedTokens,
61
+ inputTokens,
62
+ ...(cacheReadInputTokens > 0 ? { cachedInputTokens: cacheReadInputTokens } : {}),
63
+ ...(outputTokens > 0 ? { outputTokens } : {}),
64
+ ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
65
+ lastUsedTokens: usedTokens,
66
+ lastInputTokens: inputTokens,
67
+ ...(cacheReadInputTokens > 0 ? { lastCachedInputTokens: cacheReadInputTokens } : {}),
68
+ ...(outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}),
69
+ ...(reasoningOutputTokens !== undefined ? { lastReasoningOutputTokens: reasoningOutputTokens } : {}),
70
+ ...(toolUses !== undefined ? { toolUses } : {}),
71
+ ...(durationMs !== undefined ? { durationMs } : {}),
72
+ ...(typeof maxTokens === "number" && maxTokens > 0 ? { maxTokens } : {}),
73
+ compactsAutomatically: false,
74
+ }
75
+ }
76
+
77
+ // Resolve the single `context_window_updated` snapshot emitted at end of a
78
+ // turn. `latestUsageSnapshot` is the last per-`assistant`-message usage — a
79
+ // single-request view, the real live context size. `accumulatedUsage` is
80
+ // derived from SDK `result.usage`, which is CUMULATIVE: it re-counts
81
+ // `cache_read_input_tokens` on every tool round-trip, so its `usedTokens`
82
+ // balloons to millions on long turns.
83
+ //
84
+ // The cumulative figure must never become `usedTokens` — proactive-compact
85
+ // reads `usedTokens` and would trip far below the real threshold, then the
86
+ // no-assistant-usage compact turn would re-inflate and force a second
87
+ // compact (the double-compact bug). So cumulative only ever enriches
88
+ // `totalProcessedTokens`. When no per-assistant snapshot exists (compact /
89
+ // system turns), return null: the caller skips emission and proactive-compact
90
+ // falls back to the prior live snapshot (or a compact_boundary → no compact).
91
+ export function resolveFinalTurnUsage(
92
+ latestUsageSnapshot: ContextWindowUsageSnapshot | null,
93
+ accumulatedUsage: ContextWindowUsageSnapshot | null,
94
+ lastKnownContextWindow: number | undefined,
95
+ ): ContextWindowUsageSnapshot | null {
96
+ if (!latestUsageSnapshot) return null
97
+ return {
98
+ ...latestUsageSnapshot,
99
+ ...(typeof lastKnownContextWindow === "number" ? { maxTokens: lastKnownContextWindow } : {}),
100
+ ...(accumulatedUsage && accumulatedUsage.usedTokens > latestUsageSnapshot.usedTokens
101
+ ? { totalProcessedTokens: accumulatedUsage.usedTokens }
102
+ : {}),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Extracts the maximum context window reported across all models in the SDK's
108
+ * `result.modelUsage` map. Returns undefined when the value is not a
109
+ * record or contains no numeric contextWindow fields.
110
+ */
111
+ export function maxClaudeContextWindowFromModelUsage<T>(modelUsage: T): number | undefined {
112
+ const record = asRecord(modelUsage)
113
+ if (!record) return undefined
114
+
115
+ let maxContextWindow: number | undefined
116
+ for (const value of Object.values(record)) {
117
+ const usage = asRecord(value)
118
+ const contextWindow = asNumber(usage?.contextWindow) ?? asNumber(usage?.context_window)
119
+ if (contextWindow === undefined) continue
120
+ maxContextWindow = Math.max(maxContextWindow ?? 0, contextWindow)
121
+ }
122
+ return maxContextWindow
123
+ }
124
+
125
+ // The SDK's `result.modelUsage[*].contextWindow` can lie: it reports 200_000 even
126
+ // when the user opted into the 1M beta via the `[1m]` model id suffix
127
+ // (claude-agent-sdk-typescript#238). Without this hint, proactive-compact would
128
+ // trip at 167k tokens — ~17% of the real 1M window — and compact far too often.
129
+ // We derive the configured window from the SDK model id and use it as a floor.
130
+ export function parseConfiguredContextWindowFromModelId(modelId: string): number | undefined {
131
+ return modelId.endsWith("[1m]") ? 1_000_000 : undefined
132
+ }
@@ -0,0 +1,332 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { runGit } from "./diff-store"
6
+ import type { DiffStoreBranchOpsDeps } from "./diff-store-branch-ops.adapter"
7
+ import {
8
+ checkoutBranch,
9
+ createBranch,
10
+ listBranches,
11
+ mergeBranch,
12
+ previewMergeBranch,
13
+ syncBranch,
14
+ } from "./diff-store-branch-ops.adapter"
15
+
16
+ async function makeRepo(): Promise<string> {
17
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-branch-ops-"))
18
+ await runGit(["init", "-b", "main"], dir)
19
+ await runGit(["config", "user.email", "test@kanna.local"], dir)
20
+ await runGit(["config", "user.name", "kanna-test"], dir)
21
+ writeFileSync(path.join(dir, "README.md"), "hello\n")
22
+ await runGit(["add", "README.md"], dir)
23
+ await runGit(["commit", "-m", "init"], dir)
24
+ return dir
25
+ }
26
+
27
+ function makeDeps(overrides: Partial<DiffStoreBranchOpsDeps> = {}): DiffStoreBranchOpsDeps {
28
+ return {
29
+ refreshSnapshot: async () => false,
30
+ ...overrides,
31
+ }
32
+ }
33
+
34
+ describe("listBranches", () => {
35
+ test("throws when not a git repo", async () => {
36
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
37
+ await expect(listBranches(makeDeps(), { projectPath: dir })).rejects.toThrow("not in a git repository")
38
+ }, 15_000)
39
+
40
+ test("lists current branch in local branches", async () => {
41
+ const repo = await makeRepo()
42
+ const result = await listBranches(makeDeps(), { projectPath: repo })
43
+ expect(result.currentBranchName).toBe("main")
44
+ const localNames = result.local.map((b) => b.name)
45
+ expect(localNames).toContain("main")
46
+ expect(result.local[0]).toMatchObject({ kind: "local", displayName: "main" })
47
+ }, 30_000)
48
+
49
+ test("returns empty remote/pullRequests with no remote", async () => {
50
+ const repo = await makeRepo()
51
+ const result = await listBranches(makeDeps(), { projectPath: repo })
52
+ expect(result.remote).toHaveLength(0)
53
+ expect(result.pullRequests).toHaveLength(0)
54
+ expect(result.pullRequestsStatus).toBe("unavailable")
55
+ }, 30_000)
56
+
57
+ test("lists multiple local branches", async () => {
58
+ const repo = await makeRepo()
59
+ await runGit(["switch", "-c", "feature-a"], repo)
60
+ await runGit(["switch", "main"], repo)
61
+ await runGit(["switch", "-c", "feature-b"], repo)
62
+ await runGit(["switch", "main"], repo)
63
+
64
+ const result = await listBranches(makeDeps(), { projectPath: repo })
65
+ const localNames = result.local.map((b) => b.name)
66
+ expect(localNames).toContain("main")
67
+ expect(localNames).toContain("feature-a")
68
+ expect(localNames).toContain("feature-b")
69
+ }, 30_000)
70
+ })
71
+
72
+ describe("createBranch", () => {
73
+ test("throws for empty branch name", async () => {
74
+ const repo = await makeRepo()
75
+ await expect(
76
+ createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "" })
77
+ ).rejects.toThrow("Branch name is required")
78
+ }, 15_000)
79
+
80
+ test("throws when not a git repo", async () => {
81
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
82
+ await expect(
83
+ createBranch(makeDeps(), { projectId: "p1", projectPath: dir, name: "new-branch" })
84
+ ).rejects.toThrow("not in a git repository")
85
+ }, 15_000)
86
+
87
+ test("returns failure for duplicate branch name", async () => {
88
+ const repo = await makeRepo()
89
+ const result = await createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "main" })
90
+ expect(result.ok).toBe(false)
91
+ if (!result.ok) {
92
+ expect(result.title).toContain("Create branch failed")
93
+ expect(result.message).toContain("main")
94
+ }
95
+ }, 30_000)
96
+
97
+ test("creates a new branch and switches to it", async () => {
98
+ const repo = await makeRepo()
99
+ const result = await createBranch(makeDeps(), { projectId: "p1", projectPath: repo, name: "my-feature" })
100
+ expect(result.ok).toBe(true)
101
+ if (result.ok) {
102
+ expect(result.branchName).toBe("my-feature")
103
+ expect(result.snapshotChanged).toBe(false)
104
+ }
105
+ }, 30_000)
106
+
107
+ test("creates branch from explicit base branch", async () => {
108
+ const repo = await makeRepo()
109
+ const result = await createBranch(makeDeps(), {
110
+ projectId: "p1",
111
+ projectPath: repo,
112
+ name: "from-main",
113
+ baseBranchName: "main",
114
+ })
115
+ expect(result.ok).toBe(true)
116
+ if (result.ok) {
117
+ expect(result.branchName).toBe("from-main")
118
+ }
119
+ }, 30_000)
120
+ })
121
+
122
+ describe("checkoutBranch", () => {
123
+ test("throws when not a git repo", async () => {
124
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
125
+ await expect(
126
+ checkoutBranch(makeDeps(), {
127
+ projectId: "p1",
128
+ projectPath: dir,
129
+ branch: { kind: "local", name: "main" },
130
+ })
131
+ ).rejects.toThrow("not in a git repository")
132
+ }, 15_000)
133
+
134
+ test("cancels when dirty paths and bringChanges is false", async () => {
135
+ const repo = await makeRepo()
136
+ // Create another branch to checkout
137
+ await runGit(["switch", "-c", "other"], repo)
138
+ await runGit(["switch", "main"], repo)
139
+ // Dirty the working tree
140
+ writeFileSync(path.join(repo, "dirty.txt"), "changes\n")
141
+
142
+ const result = await checkoutBranch(makeDeps(), {
143
+ projectId: "p1",
144
+ projectPath: repo,
145
+ branch: { kind: "local", name: "other" },
146
+ bringChanges: false,
147
+ })
148
+ expect(result.ok).toBe(false)
149
+ if (!result.ok) {
150
+ expect(result.cancelled).toBe(true)
151
+ expect(result.title).toContain("cancelled")
152
+ }
153
+ }, 30_000)
154
+
155
+ test("switches to a local branch", async () => {
156
+ const repo = await makeRepo()
157
+ await runGit(["switch", "-c", "feature-x"], repo)
158
+ await runGit(["switch", "main"], repo)
159
+
160
+ const result = await checkoutBranch(makeDeps(), {
161
+ projectId: "p1",
162
+ projectPath: repo,
163
+ branch: { kind: "local", name: "feature-x" },
164
+ })
165
+ expect(result.ok).toBe(true)
166
+ if (result.ok) {
167
+ expect(result.branchName).toBe("feature-x")
168
+ }
169
+ }, 30_000)
170
+ })
171
+
172
+ describe("previewMergeBranch", () => {
173
+ test("throws when not a git repo", async () => {
174
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
175
+ await expect(
176
+ previewMergeBranch(makeDeps(), {
177
+ projectPath: dir,
178
+ branch: { kind: "local", name: "main" },
179
+ })
180
+ ).rejects.toThrow("not in a git repository")
181
+ }, 15_000)
182
+
183
+ test("returns up_to_date when targeting current branch", async () => {
184
+ const repo = await makeRepo()
185
+ const result = await previewMergeBranch(makeDeps(), {
186
+ projectPath: repo,
187
+ branch: { kind: "local", name: "main" },
188
+ })
189
+ expect(result.status).toBe("up_to_date")
190
+ expect(result.currentBranchName).toBe("main")
191
+ expect(result.targetBranchName).toBe("main")
192
+ }, 30_000)
193
+
194
+ test("returns up_to_date when branch has no new commits", async () => {
195
+ const repo = await makeRepo()
196
+ // Create a branch but don't add commits — so it has 0 commits ahead of main
197
+ await runGit(["switch", "-c", "empty-branch"], repo)
198
+ await runGit(["switch", "main"], repo)
199
+
200
+ const result = await previewMergeBranch(makeDeps(), {
201
+ projectPath: repo,
202
+ branch: { kind: "local", name: "empty-branch" },
203
+ })
204
+ expect(result.status).toBe("up_to_date")
205
+ expect(result.commitCount).toBe(0)
206
+ }, 30_000)
207
+
208
+ test("returns mergeable when branch has new commits", async () => {
209
+ const repo = await makeRepo()
210
+ // Create a branch with a commit
211
+ await runGit(["switch", "-c", "with-commits"], repo)
212
+ writeFileSync(path.join(repo, "new-file.txt"), "content\n")
213
+ await runGit(["add", "new-file.txt"], repo)
214
+ await runGit(["commit", "-m", "add new file"], repo)
215
+ await runGit(["switch", "main"], repo)
216
+
217
+ const result = await previewMergeBranch(makeDeps(), {
218
+ projectPath: repo,
219
+ branch: { kind: "local", name: "with-commits" },
220
+ })
221
+ expect(result.status).toBe("mergeable")
222
+ expect(result.commitCount).toBe(1)
223
+ expect(result.hasConflicts).toBe(false)
224
+ }, 30_000)
225
+ })
226
+
227
+ describe("mergeBranch", () => {
228
+ test("throws when not a git repo", async () => {
229
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
230
+ await expect(
231
+ mergeBranch(makeDeps(), {
232
+ projectId: "p1",
233
+ projectPath: dir,
234
+ branch: { kind: "local", name: "main" },
235
+ })
236
+ ).rejects.toThrow("not in a git repository")
237
+ }, 15_000)
238
+
239
+ test("returns failure when working tree is dirty", async () => {
240
+ const repo = await makeRepo()
241
+ writeFileSync(path.join(repo, "dirty.txt"), "changes\n")
242
+ const result = await mergeBranch(makeDeps(), {
243
+ projectId: "p1",
244
+ projectPath: repo,
245
+ branch: { kind: "local", name: "main" },
246
+ })
247
+ expect(result.ok).toBe(false)
248
+ if (!result.ok) {
249
+ expect(result.title).toContain("Merge blocked")
250
+ }
251
+ }, 30_000)
252
+
253
+ test("returns failure when already up to date", async () => {
254
+ const repo = await makeRepo()
255
+ await runGit(["switch", "-c", "no-new-commits"], repo)
256
+ await runGit(["switch", "main"], repo)
257
+ const result = await mergeBranch(makeDeps(), {
258
+ projectId: "p1",
259
+ projectPath: repo,
260
+ branch: { kind: "local", name: "no-new-commits" },
261
+ })
262
+ expect(result.ok).toBe(false)
263
+ if (!result.ok) {
264
+ expect(result.title).toContain("Already up to date")
265
+ }
266
+ }, 30_000)
267
+
268
+ test("merges a branch with new commits", async () => {
269
+ const repo = await makeRepo()
270
+ await runGit(["switch", "-c", "to-merge"], repo)
271
+ writeFileSync(path.join(repo, "merged-file.txt"), "content\n")
272
+ await runGit(["add", "merged-file.txt"], repo)
273
+ await runGit(["commit", "-m", "add merged file"], repo)
274
+ await runGit(["switch", "main"], repo)
275
+
276
+ const result = await mergeBranch(makeDeps(), {
277
+ projectId: "p1",
278
+ projectPath: repo,
279
+ branch: { kind: "local", name: "to-merge" },
280
+ })
281
+ expect(result.ok).toBe(true)
282
+ if (result.ok) {
283
+ expect(result.branchName).toBe("main")
284
+ }
285
+ }, 30_000)
286
+ })
287
+
288
+ describe("syncBranch", () => {
289
+ test("throws when not a git repo", async () => {
290
+ const dir = mkdtempSync(path.join(tmpdir(), "kanna-not-git-"))
291
+ await expect(
292
+ syncBranch(makeDeps(), { projectId: "p1", projectPath: dir, action: "fetch" })
293
+ ).rejects.toThrow("not in a git repository")
294
+ }, 15_000)
295
+
296
+ test("returns failure for push without upstream", async () => {
297
+ const repo = await makeRepo()
298
+ const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "push" })
299
+ expect(result.ok).toBe(false)
300
+ if (!result.ok) {
301
+ expect(result.title).toContain("Push failed")
302
+ expect(result.message).toContain("upstream")
303
+ }
304
+ }, 30_000)
305
+
306
+ test("returns failure for pull without upstream", async () => {
307
+ const repo = await makeRepo()
308
+ const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "pull" })
309
+ expect(result.ok).toBe(false)
310
+ if (!result.ok) {
311
+ expect(result.title).toContain("Pull failed")
312
+ expect(result.message).toContain("upstream")
313
+ }
314
+ }, 30_000)
315
+
316
+ test("fetch returns failure when no remote configured", async () => {
317
+ const repo = await makeRepo()
318
+ const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "fetch" })
319
+ // fetch --all --prune with no remotes: git either returns non-zero or returns ok with nothing to fetch
320
+ // Either is acceptable — just verify we get a response
321
+ expect(result.action).toBe("fetch")
322
+ }, 30_000)
323
+
324
+ test("publish returns failure when no remote configured", async () => {
325
+ const repo = await makeRepo()
326
+ const result = await syncBranch(makeDeps(), { projectId: "p1", projectPath: repo, action: "publish" })
327
+ expect(result.ok).toBe(false)
328
+ if (!result.ok) {
329
+ expect(result.action).toBe("publish")
330
+ }
331
+ }, 30_000)
332
+ })