@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,498 @@
1
+ /**
2
+ * Tests for the extracted session error-response handlers.
3
+ *
4
+ * Each test builds a minimal `SessionErrorHandlerDeps` 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 } from "bun:test"
9
+ import { AUTO_CONTINUE_EVENT_VERSION } from "./auto-continue/events"
10
+ import {
11
+ acquireRotationSlot,
12
+ handleLimitError,
13
+ handleLimitDetection,
14
+ handleAuthFailure,
15
+ TOKEN_ROTATION_DEDUPE_WINDOW_MS,
16
+ TOKEN_ROTATION_HERD_STAGGER_MS,
17
+ TOKEN_ROTATION_SCHEDULE_DELAY_MS,
18
+ type SessionErrorHandlerDeps,
19
+ type TokenRotationDedupeEntry,
20
+ } from "./claude-session-error-handler"
21
+ import type { ClaudeSessionState } from "./claude-session-state"
22
+ import type { ActiveTurn } from "./claude-session-state"
23
+ import type { AutoContinueEvent } from "./auto-continue/events"
24
+ import type { LimitDetection, LimitDetector } from "./auto-continue/limit-detector"
25
+ import type { AuthErrorDetection } from "./auto-continue/auth-error-detector"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function makeHandle() {
32
+ return {
33
+ provider: "claude" as const,
34
+ stream: (async function* () {})() as AsyncIterable<never>,
35
+ interrupt: async () => {},
36
+ close: () => {},
37
+ sendPrompt: async () => {},
38
+ setModel: async () => {},
39
+ setPermissionMode: async () => {},
40
+ getSupportedCommands: async () => [],
41
+ pushChannelPrompt: async () => {},
42
+ }
43
+ }
44
+
45
+ function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
46
+ return {
47
+ id: "sess-1",
48
+ chatId: "chat-1",
49
+ session: makeHandle(),
50
+ localPath: "/tmp/project",
51
+ additionalDirectories: [],
52
+ model: "claude-opus-4",
53
+ planMode: false,
54
+ sessionToken: null,
55
+ accountInfoLoaded: false,
56
+ nextPromptSeq: 1,
57
+ pendingPromptSeqs: [],
58
+ activeTokenId: null,
59
+ oauthKeyMasked: null,
60
+ oauthLabel: null,
61
+ openrouterKeyMasked: null,
62
+ openrouterModel: null,
63
+ lastUsedAt: Date.now(),
64
+ backgroundTaskIds: new Set<string>(),
65
+ backgroundTaskDeadlineAt: 0,
66
+ loopArmedAtSpawn: false,
67
+ cancelledResultPending: 0,
68
+ suppressSessionTokenPersist: false,
69
+ ...overrides,
70
+ }
71
+ }
72
+
73
+ function makeActiveTurn(chatId = "chat-1"): ActiveTurn {
74
+ return {
75
+ chatId,
76
+ provider: "claude",
77
+ turn: null as never,
78
+ model: "claude-opus-4",
79
+ planMode: false,
80
+ status: "running",
81
+ pendingTool: null,
82
+ postToolFollowUp: null,
83
+ hasFinalResult: false,
84
+ cancelRequested: false,
85
+ cancelRecorded: false,
86
+ waitStartedAt: null,
87
+ userMessageId: null,
88
+ }
89
+ }
90
+
91
+ /** Fake LimitDetection */
92
+ function makeLimitDetection(resetAt = Date.now() + 60_000): LimitDetection {
93
+ return { chatId: "chat-1", resetAt, tz: "system", raw: {} }
94
+ }
95
+
96
+ /** Build a minimal SessionErrorHandlerDeps. Override fields as needed. */
97
+ function makeDeps(overrides: Partial<SessionErrorHandlerDeps> = {}): SessionErrorHandlerDeps {
98
+ const emittedEvents: AutoContinueEvent[] = []
99
+ const appendedMessages: Array<{ chatId: string; entry: unknown }> = []
100
+ const closedSessions: string[] = []
101
+
102
+ return {
103
+ tokenRotationDedupe: new Map<string, TokenRotationDedupeEntry>(),
104
+ claudeSessions: new Map(),
105
+ activeTurns: new Map(),
106
+ oauthPool: null,
107
+ store: {
108
+ getAutoContinueEvents: () => [],
109
+ appendAutoContinueEvent: async (ev) => { emittedEvents.push(ev) },
110
+ recordTurnFailed: async () => {},
111
+ appendMessage: async (chatId, entry) => { appendedMessages.push({ chatId, entry }) },
112
+ },
113
+ resolveAutoResumeFor: () => false,
114
+ emitAutoContinueEvent: async (ev) => { emittedEvents.push(ev) },
115
+ closeClaudeSession: (chatId) => { closedSessions.push(chatId) },
116
+ ...overrides,
117
+ }
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // acquireRotationSlot
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("acquireRotationSlot", () => {
125
+ test("null tokenId → always returns isFirst:true and 0 delay", () => {
126
+ const deps = makeDeps()
127
+ const result = acquireRotationSlot(deps, null)
128
+ expect(result).toEqual({ extraDelayMs: 0, isFirst: true })
129
+ })
130
+
131
+ test("first call for a tokenId → isFirst:true, 0 delay, creates dedupe entry", () => {
132
+ const deps = makeDeps()
133
+ const result = acquireRotationSlot(deps, "tok-1")
134
+ expect(result).toEqual({ extraDelayMs: 0, isFirst: true })
135
+ expect(deps.tokenRotationDedupe.has("tok-1")).toBe(true)
136
+ })
137
+
138
+ test("second call within window → isFirst:false, extra delay = 1 × HERD_STAGGER", () => {
139
+ const deps = makeDeps()
140
+ acquireRotationSlot(deps, "tok-1") // first
141
+ const result = acquireRotationSlot(deps, "tok-1") // second
142
+ expect(result.isFirst).toBe(false)
143
+ expect(result.extraDelayMs).toBe(TOKEN_ROTATION_HERD_STAGGER_MS)
144
+ })
145
+
146
+ test("third call within window → extra delay = 2 × HERD_STAGGER", () => {
147
+ const deps = makeDeps()
148
+ acquireRotationSlot(deps, "tok-1")
149
+ acquireRotationSlot(deps, "tok-1")
150
+ const result = acquireRotationSlot(deps, "tok-1")
151
+ expect(result.extraDelayMs).toBe(2 * TOKEN_ROTATION_HERD_STAGGER_MS)
152
+ })
153
+
154
+ test("call after dedupe window expires → treated as first again", () => {
155
+ const deps = makeDeps()
156
+ // Seed an entry that's already expired
157
+ deps.tokenRotationDedupe.set("tok-1", {
158
+ firstSeenAt: Date.now() - TOKEN_ROTATION_DEDUPE_WINDOW_MS - 100,
159
+ staggerCount: 5,
160
+ })
161
+ const result = acquireRotationSlot(deps, "tok-1")
162
+ expect(result).toEqual({ extraDelayMs: 0, isFirst: true })
163
+ })
164
+
165
+ test("different token IDs are tracked independently", () => {
166
+ const deps = makeDeps()
167
+ acquireRotationSlot(deps, "tok-A")
168
+ const result = acquireRotationSlot(deps, "tok-B")
169
+ expect(result).toEqual({ extraDelayMs: 0, isFirst: true })
170
+ })
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // handleLimitError
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe("handleLimitError", () => {
178
+ test("returns false when detector returns null (not a rate-limit error)", async () => {
179
+ const deps = makeDeps()
180
+ const detector: LimitDetector = { detect: () => null }
181
+ const result = await handleLimitError(deps, "chat-1", detector, new Error("other"))
182
+ expect(result).toBe(false)
183
+ })
184
+
185
+ test("returns true and delegates to handleLimitDetection when detection fires", async () => {
186
+ const deps = makeDeps()
187
+ const detection = makeLimitDetection()
188
+ const detector: LimitDetector = { detect: () => detection }
189
+ const emitted: AutoContinueEvent[] = []
190
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
191
+
192
+ const result = await handleLimitError(deps, "chat-1", detector, new Error("rate limited"))
193
+ expect(result).toBe(true)
194
+ expect(emitted.length).toBe(1)
195
+ })
196
+ })
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // handleLimitDetection
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe("handleLimitDetection", () => {
203
+ test("returns true early when a live schedule already exists (deduplication guard)", async () => {
204
+ const deps = makeDeps({
205
+ store: {
206
+ getAutoContinueEvents: () => [{
207
+ v: AUTO_CONTINUE_EVENT_VERSION,
208
+ kind: "auto_continue_accepted",
209
+ chatId: "chat-1",
210
+ scheduleId: "sched-1",
211
+ timestamp: Date.now(),
212
+ scheduledAt: Date.now() + 60_000,
213
+ tz: "system",
214
+ source: "auto_setting",
215
+ resetAt: Date.now() + 60_000,
216
+ detectedAt: Date.now(),
217
+ }],
218
+ appendAutoContinueEvent: async () => {},
219
+ recordTurnFailed: async () => {},
220
+ appendMessage: async () => {},
221
+ },
222
+ })
223
+ const emitted: AutoContinueEvent[] = []
224
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
225
+
226
+ const result = await handleLimitDetection(deps, "chat-1", makeLimitDetection())
227
+ expect(result).toBe(true)
228
+ // No new event should be emitted — the guard bailed early
229
+ expect(emitted.length).toBe(0)
230
+ })
231
+
232
+ test("emits auto_continue_proposed when no pool and no auto-resume", async () => {
233
+ const deps = makeDeps({ oauthPool: null, resolveAutoResumeFor: () => false })
234
+ const emitted: AutoContinueEvent[] = []
235
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
236
+
237
+ const result = await handleLimitDetection(deps, "chat-1", makeLimitDetection())
238
+ expect(result).toBe(true)
239
+ expect(emitted[0]?.kind).toBe("auto_continue_proposed")
240
+ })
241
+
242
+ test("emits auto_continue_accepted with source auto_setting when auto-resume is on", async () => {
243
+ const deps = makeDeps({ oauthPool: null, resolveAutoResumeFor: () => true })
244
+ const emitted: AutoContinueEvent[] = []
245
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
246
+
247
+ const result = await handleLimitDetection(deps, "chat-1", makeLimitDetection())
248
+ expect(result).toBe(true)
249
+ const ev = emitted[0]
250
+ expect(ev?.kind).toBe("auto_continue_accepted")
251
+ if (ev?.kind === "auto_continue_accepted") {
252
+ expect(ev.source).toBe("auto_setting")
253
+ }
254
+ })
255
+
256
+ test("emits auto_continue_accepted with source token_rotation and closes session when pool can rotate", async () => {
257
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-old" })
258
+ const closedSessions: string[] = []
259
+ const emitted: AutoContinueEvent[] = []
260
+ const turnsFailed: string[] = []
261
+
262
+ const deps = makeDeps({
263
+ oauthPool: {
264
+ markLimited: () => {},
265
+ markError: () => {},
266
+ pickActive: () => ({ id: "tok-new" } as never),
267
+ earliestUnlimit: () => null,
268
+ },
269
+ claudeSessions: new Map([["chat-1", session]]),
270
+ activeTurns: new Map(),
271
+ closeClaudeSession: (chatId) => { closedSessions.push(chatId) },
272
+ store: {
273
+ getAutoContinueEvents: () => [],
274
+ appendAutoContinueEvent: async () => {},
275
+ recordTurnFailed: async (chatId) => { turnsFailed.push(chatId) },
276
+ appendMessage: async () => {},
277
+ },
278
+ })
279
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
280
+
281
+ const result = await handleLimitDetection(deps, "chat-1", makeLimitDetection())
282
+ expect(result).toBe(true)
283
+ expect(emitted[0]?.kind).toBe("auto_continue_accepted")
284
+ if (emitted[0]?.kind === "auto_continue_accepted") {
285
+ expect(emitted[0].source).toBe("token_rotation")
286
+ expect(emitted[0].scheduledAt).toBeGreaterThanOrEqual(Date.now() + TOKEN_ROTATION_SCHEDULE_DELAY_MS - 1)
287
+ }
288
+ expect(closedSessions).toContain("chat-1")
289
+ // No active turn → recordTurnFailed not called
290
+ expect(turnsFailed).toHaveLength(0)
291
+ })
292
+
293
+ test("records turn failed and removes active turn when active turn exists", async () => {
294
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-old" })
295
+ const activeTurns = new Map([["chat-1", makeActiveTurn()]])
296
+ const turnsFailed: string[] = []
297
+
298
+ const deps = makeDeps({
299
+ oauthPool: {
300
+ markLimited: () => {},
301
+ markError: () => {},
302
+ pickActive: () => ({ id: "tok-new" } as never),
303
+ earliestUnlimit: () => null,
304
+ },
305
+ claudeSessions: new Map([["chat-1", session]]),
306
+ activeTurns,
307
+ closeClaudeSession: () => {},
308
+ store: {
309
+ getAutoContinueEvents: () => [],
310
+ appendAutoContinueEvent: async () => {},
311
+ recordTurnFailed: async (chatId) => { turnsFailed.push(chatId) },
312
+ appendMessage: async () => {},
313
+ },
314
+ })
315
+ deps.emitAutoContinueEvent = async () => {}
316
+
317
+ await handleLimitDetection(deps, "chat-1", makeLimitDetection())
318
+ expect(turnsFailed).toContain("chat-1")
319
+ expect(activeTurns.has("chat-1")).toBe(false)
320
+ })
321
+
322
+ test("appends auto_continue_prompt transcript entry when not rotating", async () => {
323
+ const appendedMessages: Array<{ chatId: string; entry: { kind: string } }> = []
324
+ const deps = makeDeps({
325
+ oauthPool: null,
326
+ resolveAutoResumeFor: () => false,
327
+ store: {
328
+ getAutoContinueEvents: () => [],
329
+ appendAutoContinueEvent: async () => {},
330
+ recordTurnFailed: async () => {},
331
+ appendMessage: async (chatId, entry) => {
332
+ appendedMessages.push({ chatId, entry: entry as { kind: string } })
333
+ },
334
+ },
335
+ })
336
+ deps.emitAutoContinueEvent = async () => {}
337
+
338
+ await handleLimitDetection(deps, "chat-1", makeLimitDetection())
339
+ expect(appendedMessages.some(m => m.chatId === "chat-1" && m.entry.kind === "auto_continue_prompt")).toBe(true)
340
+ })
341
+ })
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // handleAuthFailure
345
+ // ---------------------------------------------------------------------------
346
+
347
+ describe("handleAuthFailure", () => {
348
+ const fakeDetection: AuthErrorDetection = { chatId: "chat-1", reason: "401 Unauthorized", raw: {} }
349
+
350
+ test("returns true early when a live schedule already exists", async () => {
351
+ const session = makeSession({ chatId: "chat-1" })
352
+ const deps = makeDeps({
353
+ store: {
354
+ getAutoContinueEvents: () => [{
355
+ v: AUTO_CONTINUE_EVENT_VERSION,
356
+ kind: "auto_continue_accepted",
357
+ chatId: "chat-1",
358
+ scheduleId: "sched-1",
359
+ timestamp: Date.now(),
360
+ scheduledAt: Date.now() + 60_000,
361
+ tz: "system",
362
+ source: "auto_setting",
363
+ resetAt: Date.now() + 60_000,
364
+ detectedAt: Date.now(),
365
+ }],
366
+ appendAutoContinueEvent: async () => {},
367
+ recordTurnFailed: async () => {},
368
+ appendMessage: async () => {},
369
+ },
370
+ })
371
+ const emitted: AutoContinueEvent[] = []
372
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
373
+
374
+ const result = await handleAuthFailure(deps, session, fakeDetection)
375
+ expect(result).toBe(true)
376
+ expect(emitted.length).toBe(0)
377
+ })
378
+
379
+ test("emits auto_continue_proposed when pool cannot rotate", async () => {
380
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-dead" })
381
+ const emitted: AutoContinueEvent[] = []
382
+ const deps = makeDeps({
383
+ oauthPool: {
384
+ markLimited: () => {},
385
+ markError: () => {},
386
+ // pool has no other token
387
+ pickActive: () => ({ id: "tok-dead" } as never),
388
+ earliestUnlimit: () => null,
389
+ },
390
+ })
391
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
392
+
393
+ const result = await handleAuthFailure(deps, session, fakeDetection)
394
+ expect(result).toBe(true)
395
+ expect(emitted[0]?.kind).toBe("auto_continue_proposed")
396
+ })
397
+
398
+ test("emits auto_continue_accepted with token_rotation and closes session when pool can rotate", async () => {
399
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-dead" })
400
+ const closedSessions: string[] = []
401
+ const emitted: AutoContinueEvent[] = []
402
+
403
+ const deps = makeDeps({
404
+ oauthPool: {
405
+ markLimited: () => {},
406
+ markError: () => {},
407
+ pickActive: () => ({ id: "tok-new" } as never),
408
+ earliestUnlimit: () => null,
409
+ },
410
+ closeClaudeSession: (chatId) => { closedSessions.push(chatId) },
411
+ activeTurns: new Map(),
412
+ })
413
+ deps.emitAutoContinueEvent = async (ev) => { emitted.push(ev) }
414
+
415
+ const result = await handleAuthFailure(deps, session, fakeDetection)
416
+ expect(result).toBe(true)
417
+ const ev = emitted[0]
418
+ expect(ev?.kind).toBe("auto_continue_accepted")
419
+ if (ev?.kind === "auto_continue_accepted") {
420
+ expect(ev.source).toBe("token_rotation")
421
+ expect(ev.tz).toBe("system")
422
+ }
423
+ expect(closedSessions).toContain("chat-1")
424
+ })
425
+
426
+ test("records turn failed when active turn exists at time of auth failure", async () => {
427
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-dead" })
428
+ const activeTurns = new Map([["chat-1", makeActiveTurn()]])
429
+ const turnsFailed: string[] = []
430
+
431
+ const deps = makeDeps({
432
+ oauthPool: {
433
+ markLimited: () => {},
434
+ markError: () => {},
435
+ pickActive: () => ({ id: "tok-new" } as never),
436
+ earliestUnlimit: () => null,
437
+ },
438
+ activeTurns,
439
+ closeClaudeSession: () => {},
440
+ store: {
441
+ getAutoContinueEvents: () => [],
442
+ appendAutoContinueEvent: async () => {},
443
+ recordTurnFailed: async (chatId) => { turnsFailed.push(chatId) },
444
+ appendMessage: async () => {},
445
+ },
446
+ })
447
+ deps.emitAutoContinueEvent = async () => {}
448
+
449
+ await handleAuthFailure(deps, session, fakeDetection)
450
+ expect(turnsFailed).toContain("chat-1")
451
+ expect(activeTurns.has("chat-1")).toBe(false)
452
+ })
453
+
454
+ test("appends auto_continue_prompt transcript entry when not rotating", async () => {
455
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-dead" })
456
+ const appendedMessages: Array<{ chatId: string; entry: { kind: string } }> = []
457
+
458
+ const deps = makeDeps({
459
+ oauthPool: {
460
+ markLimited: () => {},
461
+ markError: () => {},
462
+ // same token id → no rotation
463
+ pickActive: () => ({ id: "tok-dead" } as never),
464
+ earliestUnlimit: () => null,
465
+ },
466
+ store: {
467
+ getAutoContinueEvents: () => [],
468
+ appendAutoContinueEvent: async () => {},
469
+ recordTurnFailed: async () => {},
470
+ appendMessage: async (chatId, entry) => {
471
+ appendedMessages.push({ chatId, entry: entry as { kind: string } })
472
+ },
473
+ },
474
+ })
475
+ deps.emitAutoContinueEvent = async () => {}
476
+
477
+ await handleAuthFailure(deps, session, fakeDetection)
478
+ expect(appendedMessages.some(m => m.entry.kind === "auto_continue_prompt")).toBe(true)
479
+ })
480
+
481
+ test("markError is called only for the first detector in the herd window", async () => {
482
+ const session = makeSession({ chatId: "chat-1", activeTokenId: "tok-dead" })
483
+ const markErrorCalls: string[] = []
484
+ const deps = makeDeps({
485
+ oauthPool: {
486
+ markLimited: () => {},
487
+ markError: (id) => { markErrorCalls.push(id) },
488
+ pickActive: () => null,
489
+ earliestUnlimit: () => null,
490
+ },
491
+ })
492
+ deps.emitAutoContinueEvent = async () => {}
493
+
494
+ await handleAuthFailure(deps, session, fakeDetection) // first → calls markError
495
+ await handleAuthFailure(deps, session, fakeDetection) // second → skipped
496
+ expect(markErrorCalls).toHaveLength(1)
497
+ })
498
+ })