@cuongtran001/kanna 1.3.0 → 1.4.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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.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.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Tests for the extracted session-lifecycle standalone functions.
3
+ *
4
+ * Each test builds a minimal `SessionLifecycleDeps` fake and asserts the
5
+ * correct behaviour of the function under test. No real IO or OS calls.
6
+ */
7
+
8
+ import { describe, test, expect, beforeEach } from "bun:test"
9
+ import {
10
+ resolveClaudeIdleMs,
11
+ resolveClaudeMaxResident,
12
+ hasLiveWorkflow,
13
+ hasPendingBackgroundTask,
14
+ closeClaudeSession,
15
+ maybeRegisterSdkWorkflowsDir,
16
+ enforceClaudeSessionBudget,
17
+ buildPoolUnavailableMessage,
18
+ type SessionLifecycleDeps,
19
+ } from "./claude-session-lifecycle"
20
+ import type { ClaudeSessionState } from "./claude-session-state"
21
+ import type { TokenUnavailability } from "./oauth-pool/oauth-token-pool"
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Build a minimal fake ClaudeSessionHandle. */
28
+ function makeFakeHandle() {
29
+ return {
30
+ provider: "claude" as const,
31
+ stream: (async function* () {})() as AsyncIterable<never>,
32
+ interrupt: async () => {},
33
+ close: () => {},
34
+ sendPrompt: async () => {},
35
+ setModel: async () => {},
36
+ setPermissionMode: async () => {},
37
+ getSupportedCommands: async () => [],
38
+ }
39
+ }
40
+
41
+ /** Build a minimal ClaudeSessionState. Override fields as needed. */
42
+ function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
43
+ return {
44
+ id: "sess-1",
45
+ chatId: "chat-1",
46
+ session: makeFakeHandle(),
47
+ localPath: "/home/user/project",
48
+ additionalDirectories: [],
49
+ model: "claude-opus-4",
50
+ planMode: false,
51
+ sessionToken: null,
52
+ accountInfoLoaded: false,
53
+ nextPromptSeq: 1,
54
+ pendingPromptSeqs: [],
55
+ activeTokenId: null,
56
+ oauthKeyMasked: null,
57
+ oauthLabel: null,
58
+ openrouterKeyMasked: null,
59
+ openrouterModel: null,
60
+ lastUsedAt: Date.now(),
61
+ backgroundTaskIds: new Set<string>(),
62
+ backgroundTaskDeadlineAt: 0,
63
+ loopArmedAtSpawn: false,
64
+ workflowsDirRegistered: false,
65
+ cancelledResultPending: 0,
66
+ suppressSessionTokenPersist: false,
67
+ ...overrides,
68
+ }
69
+ }
70
+
71
+ /** Build a minimal SessionLifecycleDeps. Override fields as needed. */
72
+ function makeDeps(overrides: Partial<SessionLifecycleDeps> = {}): SessionLifecycleDeps {
73
+ return {
74
+ getAppSettingsSnapshot: () => ({}),
75
+ defaultIdleMs: 600_000,
76
+ defaultMaxResidentSessions: 3,
77
+ claudeSessions: new Map(),
78
+ activeTurns: new Map(),
79
+ oauthPool: null,
80
+ workflowRegistry: null,
81
+ resolveClaudeDriverPreference: () => "sdk",
82
+ emitStateChange: () => {},
83
+ store: {
84
+ getChat: () => null,
85
+ },
86
+ homeDir: "/home/testuser",
87
+ ...overrides,
88
+ }
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // resolveClaudeIdleMs
93
+ // ---------------------------------------------------------------------------
94
+
95
+ describe("resolveClaudeIdleMs", () => {
96
+ test("returns default when settings has no lifecycle override", () => {
97
+ const deps = makeDeps({ defaultIdleMs: 300_000 })
98
+ expect(resolveClaudeIdleMs(deps)).toBe(300_000)
99
+ })
100
+
101
+ test("returns settings value when valid positive number", () => {
102
+ const deps = makeDeps({
103
+ getAppSettingsSnapshot: () => ({
104
+ claudeDriver: { lifecycle: { idleTimeoutMs: 120_000 } },
105
+ }),
106
+ defaultIdleMs: 600_000,
107
+ })
108
+ expect(resolveClaudeIdleMs(deps)).toBe(120_000)
109
+ })
110
+
111
+ test("rounds fractional settings value", () => {
112
+ const deps = makeDeps({
113
+ getAppSettingsSnapshot: () => ({
114
+ claudeDriver: { lifecycle: { idleTimeoutMs: 120_001.7 } },
115
+ }),
116
+ defaultIdleMs: 600_000,
117
+ })
118
+ expect(resolveClaudeIdleMs(deps)).toBe(120_002)
119
+ })
120
+
121
+ test("falls back to default when settings value is zero", () => {
122
+ const deps = makeDeps({
123
+ getAppSettingsSnapshot: () => ({
124
+ claudeDriver: { lifecycle: { idleTimeoutMs: 0 } },
125
+ }),
126
+ defaultIdleMs: 600_000,
127
+ })
128
+ expect(resolveClaudeIdleMs(deps)).toBe(600_000)
129
+ })
130
+
131
+ test("falls back to default when settings value is negative", () => {
132
+ const deps = makeDeps({
133
+ getAppSettingsSnapshot: () => ({
134
+ claudeDriver: { lifecycle: { idleTimeoutMs: -1 } },
135
+ }),
136
+ defaultIdleMs: 600_000,
137
+ })
138
+ expect(resolveClaudeIdleMs(deps)).toBe(600_000)
139
+ })
140
+
141
+ test("falls back to default when settings value is Infinity", () => {
142
+ const deps = makeDeps({
143
+ getAppSettingsSnapshot: () => ({
144
+ claudeDriver: { lifecycle: { idleTimeoutMs: Infinity } },
145
+ }),
146
+ defaultIdleMs: 600_000,
147
+ })
148
+ expect(resolveClaudeIdleMs(deps)).toBe(600_000)
149
+ })
150
+ })
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // resolveClaudeMaxResident
154
+ // ---------------------------------------------------------------------------
155
+
156
+ describe("resolveClaudeMaxResident", () => {
157
+ test("returns default when no override in settings", () => {
158
+ const deps = makeDeps({ defaultMaxResidentSessions: 5 })
159
+ expect(resolveClaudeMaxResident(deps)).toBe(5)
160
+ })
161
+
162
+ test("returns settings value when valid", () => {
163
+ const deps = makeDeps({
164
+ getAppSettingsSnapshot: () => ({
165
+ claudeDriver: { lifecycle: { maxConcurrent: 8 } },
166
+ }),
167
+ defaultMaxResidentSessions: 3,
168
+ })
169
+ expect(resolveClaudeMaxResident(deps)).toBe(8)
170
+ })
171
+
172
+ test("falls back to default when settings value is 0", () => {
173
+ const deps = makeDeps({
174
+ getAppSettingsSnapshot: () => ({
175
+ claudeDriver: { lifecycle: { maxConcurrent: 0 } },
176
+ }),
177
+ defaultMaxResidentSessions: 3,
178
+ })
179
+ expect(resolveClaudeMaxResident(deps)).toBe(3)
180
+ })
181
+ })
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // hasLiveWorkflow
185
+ // ---------------------------------------------------------------------------
186
+
187
+ describe("hasLiveWorkflow", () => {
188
+ test("returns false when workflowRegistry is null", () => {
189
+ const deps = makeDeps({ workflowRegistry: null })
190
+ expect(hasLiveWorkflow(deps, "chat-1")).toBe(false)
191
+ })
192
+
193
+ test("returns false when registry says no active run", () => {
194
+ const deps = makeDeps({
195
+ workflowRegistry: {
196
+ hasActiveRun: () => false,
197
+ register: () => {},
198
+ unregister: () => {},
199
+ },
200
+ })
201
+ expect(hasLiveWorkflow(deps, "chat-1")).toBe(false)
202
+ })
203
+
204
+ test("returns true when registry reports an active run", () => {
205
+ const deps = makeDeps({
206
+ workflowRegistry: {
207
+ hasActiveRun: () => true,
208
+ register: () => {},
209
+ unregister: () => {},
210
+ },
211
+ })
212
+ expect(hasLiveWorkflow(deps, "chat-1")).toBe(true)
213
+ })
214
+
215
+ test("passes freshness from resolveClaudeIdleMs", () => {
216
+ let capturedFreshness = 0
217
+ const deps = makeDeps({
218
+ defaultIdleMs: 999_000,
219
+ workflowRegistry: {
220
+ hasActiveRun: (_chatId, freshnessMs) => {
221
+ capturedFreshness = freshnessMs
222
+ return false
223
+ },
224
+ register: () => {},
225
+ unregister: () => {},
226
+ },
227
+ })
228
+ hasLiveWorkflow(deps, "chat-1")
229
+ expect(capturedFreshness).toBe(999_000)
230
+ })
231
+ })
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // hasPendingBackgroundTask
235
+ // ---------------------------------------------------------------------------
236
+
237
+ describe("hasPendingBackgroundTask", () => {
238
+ test("returns false when backgroundTaskIds is empty", () => {
239
+ const session = makeSession({ backgroundTaskIds: new Set(), backgroundTaskDeadlineAt: 0 })
240
+ expect(hasPendingBackgroundTask(session, Date.now())).toBe(false)
241
+ })
242
+
243
+ test("returns true when task ids present and deadline not expired", () => {
244
+ const now = Date.now()
245
+ const session = makeSession({
246
+ backgroundTaskIds: new Set(["task-1"]),
247
+ backgroundTaskDeadlineAt: now + 60_000,
248
+ })
249
+ expect(hasPendingBackgroundTask(session, now)).toBe(true)
250
+ })
251
+
252
+ test("returns false and clears state when deadline expired", () => {
253
+ const now = Date.now()
254
+ const session = makeSession({
255
+ backgroundTaskIds: new Set(["task-1"]),
256
+ backgroundTaskDeadlineAt: now - 1,
257
+ })
258
+ const result = hasPendingBackgroundTask(session, now)
259
+ expect(result).toBe(false)
260
+ expect(session.backgroundTaskIds.size).toBe(0)
261
+ expect(session.backgroundTaskDeadlineAt).toBe(0)
262
+ })
263
+ })
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // closeClaudeSession
267
+ // ---------------------------------------------------------------------------
268
+
269
+ describe("closeClaudeSession", () => {
270
+ test("removes session from claudeSessions map", () => {
271
+ const session = makeSession({ chatId: "chat-1" })
272
+ const claudeSessions = new Map([["chat-1", session]])
273
+ const deps = makeDeps({ claudeSessions })
274
+ closeClaudeSession(deps, "chat-1", session)
275
+ expect(claudeSessions.has("chat-1")).toBe(false)
276
+ })
277
+
278
+ test("calls session.close()", () => {
279
+ let closed = false
280
+ const session = makeSession()
281
+ session.session.close = () => { closed = true }
282
+ closeClaudeSession(makeDeps(), "chat-1", session)
283
+ expect(closed).toBe(true)
284
+ })
285
+
286
+ test("releases oauth pool by default", () => {
287
+ let releasedFor = ""
288
+ const session = makeSession({ chatId: "chat-1" })
289
+ const deps = makeDeps({
290
+ oauthPool: {
291
+ release: (chatId) => { releasedFor = chatId },
292
+ describeUnavailability: () => [],
293
+ },
294
+ })
295
+ closeClaudeSession(deps, "chat-1", session)
296
+ expect(releasedFor).toBe("chat-1")
297
+ })
298
+
299
+ test("skips oauth release when keepReservation is true", () => {
300
+ let released = false
301
+ const session = makeSession({ chatId: "chat-1" })
302
+ const deps = makeDeps({
303
+ oauthPool: {
304
+ release: () => { released = true },
305
+ describeUnavailability: () => [],
306
+ },
307
+ })
308
+ closeClaudeSession(deps, "chat-1", session, { keepReservation: true })
309
+ expect(released).toBe(false)
310
+ })
311
+
312
+ test("unregisters workflow for SDK driver", () => {
313
+ let unregisteredChat = ""
314
+ const session = makeSession({ chatId: "chat-1" })
315
+ const deps = makeDeps({
316
+ resolveClaudeDriverPreference: () => "sdk",
317
+ workflowRegistry: {
318
+ hasActiveRun: () => false,
319
+ register: () => {},
320
+ unregister: (chatId) => { unregisteredChat = chatId },
321
+ },
322
+ })
323
+ closeClaudeSession(deps, "chat-1", session)
324
+ expect(unregisteredChat).toBe("chat-1")
325
+ })
326
+
327
+ test("does NOT unregister workflow for PTY driver", () => {
328
+ let unregistered = false
329
+ const session = makeSession({ chatId: "chat-1" })
330
+ const deps = makeDeps({
331
+ resolveClaudeDriverPreference: () => "pty",
332
+ workflowRegistry: {
333
+ hasActiveRun: () => false,
334
+ register: () => {},
335
+ unregister: () => { unregistered = true },
336
+ },
337
+ })
338
+ closeClaudeSession(deps, "chat-1", session)
339
+ expect(unregistered).toBe(false)
340
+ })
341
+
342
+ test("does not error when session is already removed from map", () => {
343
+ const session = makeSession({ chatId: "chat-1" })
344
+ const differentSession = makeSession({ chatId: "chat-1" })
345
+ const claudeSessions = new Map([["chat-1", differentSession]])
346
+ const deps = makeDeps({ claudeSessions })
347
+ // session !== claudeSessions.get("chat-1"), so map entry should survive
348
+ closeClaudeSession(deps, "chat-1", session)
349
+ expect(claudeSessions.has("chat-1")).toBe(true)
350
+ })
351
+ })
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // maybeRegisterSdkWorkflowsDir
355
+ // ---------------------------------------------------------------------------
356
+
357
+ describe("maybeRegisterSdkWorkflowsDir", () => {
358
+ test("no-ops when workflowRegistry is null", () => {
359
+ const session = makeSession({ sessionToken: "tok-abc" })
360
+ const deps = makeDeps({ workflowRegistry: null })
361
+ // Should not throw
362
+ maybeRegisterSdkWorkflowsDir(deps, session)
363
+ expect(session.workflowsDirRegistered).toBeFalsy()
364
+ })
365
+
366
+ test("no-ops when already registered", () => {
367
+ let registered = false
368
+ const session = makeSession({
369
+ sessionToken: "tok-abc",
370
+ workflowsDirRegistered: true,
371
+ })
372
+ const deps = makeDeps({
373
+ workflowRegistry: {
374
+ hasActiveRun: () => false,
375
+ register: () => { registered = true },
376
+ unregister: () => {},
377
+ },
378
+ })
379
+ maybeRegisterSdkWorkflowsDir(deps, session)
380
+ expect(registered).toBe(false)
381
+ })
382
+
383
+ test("no-ops when driver is PTY", () => {
384
+ let registered = false
385
+ const session = makeSession({ sessionToken: "tok-abc", workflowsDirRegistered: false })
386
+ const deps = makeDeps({
387
+ resolveClaudeDriverPreference: () => "pty",
388
+ workflowRegistry: {
389
+ hasActiveRun: () => false,
390
+ register: () => { registered = true },
391
+ unregister: () => {},
392
+ },
393
+ })
394
+ maybeRegisterSdkWorkflowsDir(deps, session)
395
+ expect(registered).toBe(false)
396
+ })
397
+
398
+ test("no-ops when sessionToken is null", () => {
399
+ let registered = false
400
+ const session = makeSession({ sessionToken: null, workflowsDirRegistered: false })
401
+ const deps = makeDeps({
402
+ workflowRegistry: {
403
+ hasActiveRun: () => false,
404
+ register: () => { registered = true },
405
+ unregister: () => {},
406
+ },
407
+ })
408
+ maybeRegisterSdkWorkflowsDir(deps, session)
409
+ expect(registered).toBe(false)
410
+ })
411
+
412
+ test("registers and sets workflowsDirRegistered when all conditions met", () => {
413
+ let registeredChat = ""
414
+ let registeredDir = ""
415
+ const session = makeSession({
416
+ chatId: "chat-7",
417
+ sessionToken: "session-abc123",
418
+ // Use /tmp — an existing directory that realpathSync can resolve
419
+ localPath: "/tmp",
420
+ workflowsDirRegistered: false,
421
+ })
422
+ const deps = makeDeps({
423
+ resolveClaudeDriverPreference: () => "sdk",
424
+ homeDir: "/home/user",
425
+ workflowRegistry: {
426
+ hasActiveRun: () => false,
427
+ register: (chatId, dir) => {
428
+ registeredChat = chatId
429
+ registeredDir = dir
430
+ },
431
+ unregister: () => {},
432
+ },
433
+ })
434
+ maybeRegisterSdkWorkflowsDir(deps, session)
435
+ expect(session.workflowsDirRegistered).toBe(true)
436
+ expect(registeredChat).toBe("chat-7")
437
+ // The dir should include the session token
438
+ expect(registeredDir).toContain("session-abc123")
439
+ })
440
+ })
441
+
442
+ // ---------------------------------------------------------------------------
443
+ // enforceClaudeSessionBudget
444
+ // ---------------------------------------------------------------------------
445
+
446
+ describe("enforceClaudeSessionBudget", () => {
447
+ let emitted: string[]
448
+ let activeTurns: Map<string, unknown>
449
+
450
+ beforeEach(() => {
451
+ emitted = []
452
+ activeTurns = new Map()
453
+ })
454
+
455
+ function makeBudgetDeps(sessions: Map<string, ClaudeSessionState>, max: number): SessionLifecycleDeps {
456
+ return makeDeps({
457
+ getAppSettingsSnapshot: () => ({
458
+ claudeDriver: { lifecycle: { maxConcurrent: max } },
459
+ }),
460
+ claudeSessions: sessions,
461
+ activeTurns: activeTurns as Map<string, never>,
462
+ emitStateChange: (chatId) => { emitted.push(chatId) },
463
+ })
464
+ }
465
+
466
+ test("no-op when sessions at or below max", () => {
467
+ const sessions = new Map([
468
+ ["c1", makeSession({ chatId: "c1" })],
469
+ ["c2", makeSession({ chatId: "c2" })],
470
+ ])
471
+ const deps = makeBudgetDeps(sessions, 2)
472
+ enforceClaudeSessionBudget(deps)
473
+ expect(sessions.size).toBe(2)
474
+ expect(emitted.length).toBe(0)
475
+ })
476
+
477
+ test("evicts LRU idle session when over cap", () => {
478
+ const now = Date.now()
479
+ const older = makeSession({ chatId: "c1", lastUsedAt: now - 10_000 })
480
+ const newer = makeSession({ chatId: "c2", lastUsedAt: now - 1_000 })
481
+ const sessions = new Map<string, ClaudeSessionState>([
482
+ ["c1", older],
483
+ ["c2", newer],
484
+ ["c3", makeSession({ chatId: "c3", lastUsedAt: now })],
485
+ ])
486
+ const deps = makeBudgetDeps(sessions, 2)
487
+ enforceClaudeSessionBudget(deps)
488
+ expect(sessions.size).toBe(2)
489
+ expect(sessions.has("c1")).toBe(false)
490
+ expect(emitted).toContain("c1")
491
+ })
492
+
493
+ test("skips protected chat even if oldest", () => {
494
+ const now = Date.now()
495
+ const oldest = makeSession({ chatId: "c1", lastUsedAt: now - 10_000 })
496
+ const sessions = new Map<string, ClaudeSessionState>([
497
+ ["c1", oldest],
498
+ ["c2", makeSession({ chatId: "c2", lastUsedAt: now - 5_000 })],
499
+ ["c3", makeSession({ chatId: "c3", lastUsedAt: now })],
500
+ ])
501
+ const deps = makeBudgetDeps(sessions, 2)
502
+ enforceClaudeSessionBudget(deps, "c1")
503
+ expect(sessions.has("c1")).toBe(true)
504
+ expect(sessions.size).toBe(2)
505
+ expect(emitted).not.toContain("c1")
506
+ })
507
+
508
+ test("skips sessions with an active turn", () => {
509
+ const now = Date.now()
510
+ const activeSession = makeSession({ chatId: "c1", lastUsedAt: now - 10_000 })
511
+ activeTurns.set("c1", {})
512
+ const sessions = new Map<string, ClaudeSessionState>([
513
+ ["c1", activeSession],
514
+ ["c2", makeSession({ chatId: "c2", lastUsedAt: now - 5_000 })],
515
+ ["c3", makeSession({ chatId: "c3", lastUsedAt: now })],
516
+ ])
517
+ const deps = makeBudgetDeps(sessions, 2)
518
+ enforceClaudeSessionBudget(deps)
519
+ expect(sessions.has("c1")).toBe(true)
520
+ })
521
+
522
+ test("skips sessions with pending prompts", () => {
523
+ const now = Date.now()
524
+ const queued = makeSession({ chatId: "c1", lastUsedAt: now - 10_000, pendingPromptSeqs: [1] })
525
+ const sessions = new Map<string, ClaudeSessionState>([
526
+ ["c1", queued],
527
+ ["c2", makeSession({ chatId: "c2", lastUsedAt: now - 5_000 })],
528
+ ["c3", makeSession({ chatId: "c3", lastUsedAt: now })],
529
+ ])
530
+ const deps = makeBudgetDeps(sessions, 2)
531
+ enforceClaudeSessionBudget(deps)
532
+ expect(sessions.has("c1")).toBe(true)
533
+ })
534
+
535
+ test("no-op when max is 0 (unlimited)", () => {
536
+ const sessions = new Map([
537
+ ["c1", makeSession({ chatId: "c1" })],
538
+ ["c2", makeSession({ chatId: "c2" })],
539
+ ["c3", makeSession({ chatId: "c3" })],
540
+ ])
541
+ const deps = makeDeps({
542
+ defaultMaxResidentSessions: 0,
543
+ claudeSessions: sessions,
544
+ })
545
+ enforceClaudeSessionBudget(deps)
546
+ expect(sessions.size).toBe(3)
547
+ })
548
+ })
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // buildPoolUnavailableMessage
552
+ // ---------------------------------------------------------------------------
553
+
554
+ describe("buildPoolUnavailableMessage", () => {
555
+ test("returns simple message when no pool configured", () => {
556
+ const deps = makeDeps({ oauthPool: null })
557
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", " for subagent")
558
+ expect(msg).toBe(
559
+ "All OAuth tokens are unavailable for subagent (rate-limited, errored, or in use)."
560
+ )
561
+ })
562
+
563
+ test("includes pool header and footer when pool present", () => {
564
+ const deps = makeDeps({
565
+ oauthPool: {
566
+ release: () => {},
567
+ describeUnavailability: (): TokenUnavailability[] => [],
568
+ },
569
+ })
570
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
571
+ expect(msg).toContain("All OAuth tokens are unavailable:")
572
+ expect(msg).toContain("Close the chat holding a contested token")
573
+ })
574
+
575
+ test("formats limited token with time remaining", () => {
576
+ const now = Date.now()
577
+ const deps = makeDeps({
578
+ oauthPool: {
579
+ release: () => {},
580
+ describeUnavailability: (): TokenUnavailability[] => [
581
+ { tokenId: "tok-1", label: "MyToken", reason: "limited", until: now + 30 * 60_000 },
582
+ ],
583
+ },
584
+ })
585
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
586
+ expect(msg).toContain("MyToken")
587
+ expect(msg).toContain("rate-limited")
588
+ expect(msg).toContain("30m")
589
+ })
590
+
591
+ test("formats reserved token with chat link", () => {
592
+ const deps = makeDeps({
593
+ store: {
594
+ getChat: (id) => (id === "chat-abc" ? { title: "My Chat" } : null),
595
+ },
596
+ oauthPool: {
597
+ release: () => {},
598
+ describeUnavailability: (): TokenUnavailability[] => [
599
+ {
600
+ tokenId: "tok-1",
601
+ label: "WorkToken",
602
+ reason: "reserved",
603
+ byChatIds: ["chat-abc"],
604
+ ownedBySelf: false,
605
+ },
606
+ ],
607
+ },
608
+ })
609
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
610
+ expect(msg).toContain("WorkToken")
611
+ expect(msg).toContain("in use by")
612
+ expect(msg).toContain("My Chat")
613
+ })
614
+
615
+ test("formats error token with message", () => {
616
+ const deps = makeDeps({
617
+ oauthPool: {
618
+ release: () => {},
619
+ describeUnavailability: (): TokenUnavailability[] => [
620
+ { tokenId: "tok-2", label: "ErrToken", reason: "error", message: "invalid scope" },
621
+ ],
622
+ },
623
+ })
624
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
625
+ expect(msg).toContain("ErrToken")
626
+ expect(msg).toContain("errored")
627
+ expect(msg).toContain("invalid scope")
628
+ })
629
+
630
+ test("formats disabled token", () => {
631
+ const deps = makeDeps({
632
+ oauthPool: {
633
+ release: () => {},
634
+ describeUnavailability: (): TokenUnavailability[] => [
635
+ { tokenId: "tok-3", label: "DisabledToken", reason: "disabled" },
636
+ ],
637
+ },
638
+ })
639
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
640
+ expect(msg).toContain("DisabledToken")
641
+ expect(msg).toContain("disabled")
642
+ })
643
+
644
+ test("skips available tokens", () => {
645
+ const deps = makeDeps({
646
+ oauthPool: {
647
+ release: () => {},
648
+ describeUnavailability: (): TokenUnavailability[] => [
649
+ { tokenId: "tok-4", label: "GoodToken", reason: "available" },
650
+ ],
651
+ },
652
+ })
653
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
654
+ expect(msg).not.toContain("GoodToken")
655
+ })
656
+
657
+ test("uses token id prefix when label is empty", () => {
658
+ const deps = makeDeps({
659
+ oauthPool: {
660
+ release: () => {},
661
+ describeUnavailability: (): TokenUnavailability[] => [
662
+ { tokenId: "abcdef12345678", label: "", reason: "disabled" },
663
+ ],
664
+ },
665
+ })
666
+ const msg = buildPoolUnavailableMessage(deps, "chat-1", "")
667
+ expect(msg).toContain("abcdef12")
668
+ })
669
+ })