@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,401 @@
1
+ /**
2
+ * Tests for the extracted spawnClaudeTurn function (claude-session-spawner.ts).
3
+ * Covers session creation, reuse, eviction, OAuth pool handling, and driver selection
4
+ * without touching AgentCoordinator internals.
5
+ */
6
+
7
+ // NOTE: do NOT mock.module("../shared/log") here — Bun's mock.module mutates
8
+ // the global registry for the whole test run, turning shared/log into noops
9
+ // for every later test file (analytics.test.ts asserts real log output).
10
+
11
+ import { describe, test, expect } from "bun:test"
12
+ import { spawnClaudeTurn, type SpawnClaudeTurnArgs, type SpawnClaudeTurnDeps } from "./claude-session-spawner"
13
+ import { OAuthPoolUnavailableError } from "./oauth-errors"
14
+ import { POLICY_DEFAULT } from "../shared/permission-policy"
15
+ import type { ClaudeSessionState, ActiveTurn } from "./claude-session-state"
16
+ import type { ClaudeSessionHandle } from "./harness-types"
17
+ import type { LlmProviderSnapshot } from "../shared/types"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function makeFakeHandle(overrides: Partial<ClaudeSessionHandle> = {}): ClaudeSessionHandle {
24
+ return {
25
+ provider: "claude",
26
+ stream: (async function* () {})(),
27
+ interrupt: async () => {},
28
+ close: () => {},
29
+ sendPrompt: async () => {},
30
+ setModel: async () => {},
31
+ setPermissionMode: async () => {},
32
+ getSupportedCommands: async () => [],
33
+ // getAccountInfo is optional — omit to keep the fake minimal
34
+ pushChannelPrompt: undefined,
35
+ ...overrides,
36
+ }
37
+ }
38
+
39
+ /** A valid LlmProviderSnapshot for tests that exercise the OpenRouter path. */
40
+ function makeOpenRouterProvider(apiKey = "or-key"): LlmProviderSnapshot {
41
+ return {
42
+ provider: "openrouter",
43
+ apiKey,
44
+ model: "openai/gpt-4o",
45
+ baseUrl: "https://openrouter.ai/api/v1",
46
+ resolvedBaseUrl: "https://openrouter.ai/api/v1",
47
+ enabled: true,
48
+ warning: null,
49
+ filePathDisplay: "",
50
+ }
51
+ }
52
+
53
+ function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
54
+ return {
55
+ id: "sess-1",
56
+ chatId: "chat-1",
57
+ session: makeFakeHandle(),
58
+ localPath: "/tmp/project",
59
+ additionalDirectories: [],
60
+ model: "claude-opus-4",
61
+ effort: undefined,
62
+ planMode: false,
63
+ sessionToken: null,
64
+ accountInfoLoaded: false,
65
+ nextPromptSeq: 0,
66
+ pendingPromptSeqs: [],
67
+ activeTokenId: null,
68
+ oauthKeyMasked: null,
69
+ oauthLabel: null,
70
+ openrouterKeyMasked: null,
71
+ openrouterModel: null,
72
+ lastUsedAt: 0,
73
+ backgroundTaskIds: new Set(),
74
+ backgroundTaskDeadlineAt: 0,
75
+ loopArmedAtSpawn: false,
76
+ cancelledResultPending: 0,
77
+ suppressSessionTokenPersist: false,
78
+ ...overrides,
79
+ }
80
+ }
81
+
82
+ function makeArgs(overrides: Partial<SpawnClaudeTurnArgs> = {}): SpawnClaudeTurnArgs {
83
+ return {
84
+ chatId: "chat-1",
85
+ projectId: "proj-1",
86
+ localPath: "/tmp/project",
87
+ model: "claude-opus-4",
88
+ planMode: false,
89
+ sessionToken: null,
90
+ forkSession: false,
91
+ onToolRequest: async () => null,
92
+ provider: "claude",
93
+ ...overrides,
94
+ }
95
+ }
96
+
97
+ function makeDeps(overrides: Partial<SpawnClaudeTurnDeps> = {}): SpawnClaudeTurnDeps {
98
+ const claudeSessions = new Map<string, ClaudeSessionState>()
99
+ const activeTurns = new Map<string, ActiveTurn>()
100
+ const mentionedSubagentIdsByChat = new Map<string, string[]>()
101
+
102
+ const fakeHandle = makeFakeHandle()
103
+
104
+ return {
105
+ claudeSessions,
106
+ activeTurns,
107
+ mentionedSubagentIdsByChat,
108
+ oauthPool: null,
109
+ store: {
110
+ recordSessionCommandsLoaded: async () => {},
111
+ },
112
+ startClaudeSessionFn: async () => fakeHandle,
113
+ startClaudeSessionPTYFn: async () => fakeHandle,
114
+ subagentOrchestrator: {} as SpawnClaudeTurnDeps["subagentOrchestrator"],
115
+ toolCallback: null,
116
+ tunnelGateway: null,
117
+ claudePtyRegistry: null,
118
+ ptyInstanceRegistry: null,
119
+ workflowRegistry: null,
120
+ subagentTranscriptRegistry: null,
121
+ resolveClaudeDriverPreference: () => "sdk",
122
+ isLoopArmed: () => null,
123
+ closeClaudeSession: () => {},
124
+ enforceClaudeSessionBudget: () => {},
125
+ // readLlmProvider is only called in the OpenRouter path — default throws to
126
+ // surface accidental calls; override per-test when exercising that path.
127
+ readLlmProvider: async () => { throw new Error("readLlmProvider called unexpectedly") },
128
+ buildPoolUnavailableMessage: () => "pool unavailable",
129
+ listOpenRouterModelsFn: null,
130
+ getSubagents: () => [],
131
+ getAppSettingsSnapshot: () => ({}),
132
+ getEnabledCustomMcpServers: () => [],
133
+ buildOAuthBearers: async () => new Map(),
134
+ setupLoop: async () => ({ ok: false as const, errors: [] }),
135
+ stopLoop: async () => {},
136
+ runOrchestration: async () => ({ ok: true as const, runId: "run-1" }),
137
+ cancelOrchRun: async () => {},
138
+ getOrchRunDetail: () => null,
139
+ resolveChatPolicy: () => POLICY_DEFAULT,
140
+ runClaudeSession: () => {},
141
+ mergeLocalCatalog: (commands) => commands,
142
+ emitStateChange: () => {},
143
+ ...overrides,
144
+ }
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Tests
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("spawnClaudeTurn", () => {
152
+ describe("fresh session spawn", () => {
153
+ test("creates and registers a new ClaudeSessionState when no session exists", async () => {
154
+ const deps = makeDeps()
155
+ const args = makeArgs()
156
+
157
+ const turn = await spawnClaudeTurn(deps, args)
158
+
159
+ expect(deps.claudeSessions.size).toBe(1)
160
+ const session = deps.claudeSessions.get("chat-1")
161
+ expect(session).toBeDefined()
162
+ expect(session?.chatId).toBe("chat-1")
163
+ expect(session?.localPath).toBe("/tmp/project")
164
+ expect(session?.model).toBe("claude-opus-4")
165
+ expect(turn.provider).toBe("claude")
166
+ })
167
+
168
+ test("uses SDK driver when resolveClaudeDriverPreference returns sdk", async () => {
169
+ let sdkCalled = false
170
+ let ptyCalled = false
171
+ const deps = makeDeps({
172
+ resolveClaudeDriverPreference: () => "sdk",
173
+ startClaudeSessionFn: async () => { sdkCalled = true; return makeFakeHandle() },
174
+ startClaudeSessionPTYFn: async () => { ptyCalled = true; return makeFakeHandle() },
175
+ })
176
+
177
+ await spawnClaudeTurn(deps, makeArgs({ provider: "claude" }))
178
+
179
+ expect(sdkCalled).toBe(true)
180
+ expect(ptyCalled).toBe(false)
181
+ })
182
+
183
+ test("uses PTY driver when resolveClaudeDriverPreference returns pty", async () => {
184
+ let sdkCalled = false
185
+ let ptyCalled = false
186
+ const deps = makeDeps({
187
+ resolveClaudeDriverPreference: () => "pty",
188
+ startClaudeSessionFn: async () => { sdkCalled = true; return makeFakeHandle() },
189
+ startClaudeSessionPTYFn: async () => { ptyCalled = true; return makeFakeHandle() },
190
+ })
191
+
192
+ await spawnClaudeTurn(deps, makeArgs({ provider: "claude" }))
193
+
194
+ expect(sdkCalled).toBe(false)
195
+ expect(ptyCalled).toBe(true)
196
+ })
197
+
198
+ test("always uses SDK driver for openrouter provider even when pty is preferred", async () => {
199
+ let sdkCalled = false
200
+ let ptyCalled = false
201
+ const deps = makeDeps({
202
+ resolveClaudeDriverPreference: () => "pty",
203
+ startClaudeSessionFn: async () => { sdkCalled = true; return makeFakeHandle() },
204
+ startClaudeSessionPTYFn: async () => { ptyCalled = true; return makeFakeHandle() },
205
+ readLlmProvider: async () => makeOpenRouterProvider("or-key"),
206
+ })
207
+
208
+ await spawnClaudeTurn(deps, makeArgs({ provider: "openrouter" }))
209
+
210
+ expect(sdkCalled).toBe(true)
211
+ expect(ptyCalled).toBe(false)
212
+ })
213
+
214
+ test("fires runClaudeSession in fire-and-forget manner", async () => {
215
+ let sessionPassed: ClaudeSessionState | undefined
216
+ const deps = makeDeps({
217
+ runClaudeSession: (session) => { sessionPassed = session },
218
+ })
219
+
220
+ await spawnClaudeTurn(deps, makeArgs())
221
+
222
+ expect(sessionPassed).toBeDefined()
223
+ expect(sessionPassed?.chatId).toBe("chat-1")
224
+ })
225
+
226
+ test("calls enforceClaudeSessionBudget with chatId", async () => {
227
+ const calls: Array<string | undefined> = []
228
+ const deps = makeDeps({
229
+ enforceClaudeSessionBudget: (chatId) => { calls.push(chatId) },
230
+ })
231
+
232
+ await spawnClaudeTurn(deps, makeArgs())
233
+
234
+ expect(calls.length).toBeGreaterThanOrEqual(1)
235
+ expect(calls[0]).toBe("chat-1")
236
+ })
237
+ })
238
+
239
+ describe("session reuse", () => {
240
+ test("reuses existing session when localPath and effort are the same", async () => {
241
+ const deps = makeDeps()
242
+ const existingSession = makeSession({ lastUsedAt: 100 })
243
+ deps.claudeSessions.set("chat-1", existingSession)
244
+
245
+ let sdkCalled = false
246
+ deps.startClaudeSessionFn = async () => { sdkCalled = true; return makeFakeHandle() }
247
+
248
+ await spawnClaudeTurn(deps, makeArgs())
249
+
250
+ expect(sdkCalled).toBe(false)
251
+ expect(existingSession.lastUsedAt).toBeGreaterThan(100)
252
+ })
253
+
254
+ test("calls setModel on reuse when model changes", async () => {
255
+ let modelSet: string | undefined
256
+ const fakeHandle = makeFakeHandle({
257
+ setModel: async (model) => { modelSet = model },
258
+ })
259
+ const deps = makeDeps()
260
+ const existingSession = makeSession({ session: fakeHandle, model: "claude-opus-4" })
261
+ deps.claudeSessions.set("chat-1", existingSession)
262
+
263
+ await spawnClaudeTurn(deps, makeArgs({ model: "claude-sonnet-4" }))
264
+
265
+ expect(modelSet).toBe("claude-sonnet-4")
266
+ expect(existingSession.model).toBe("claude-sonnet-4")
267
+ })
268
+
269
+ test("calls setPermissionMode on reuse when planMode changes", async () => {
270
+ let planModeSet: boolean | undefined
271
+ const fakeHandle = makeFakeHandle({
272
+ setPermissionMode: async (pm) => { planModeSet = pm },
273
+ })
274
+ const deps = makeDeps()
275
+ const existingSession = makeSession({ session: fakeHandle, planMode: false })
276
+ deps.claudeSessions.set("chat-1", existingSession)
277
+
278
+ await spawnClaudeTurn(deps, makeArgs({ planMode: true }))
279
+
280
+ expect(planModeSet).toBe(true)
281
+ expect(existingSession.planMode).toBe(true)
282
+ })
283
+ })
284
+
285
+ describe("session eviction", () => {
286
+ test("evicts existing session and spawns fresh one when localPath changes", async () => {
287
+ let closedSession: ClaudeSessionState | undefined
288
+ const deps = makeDeps({
289
+ closeClaudeSession: (_, session) => { closedSession = session },
290
+ })
291
+ const existingSession = makeSession({ localPath: "/tmp/project" })
292
+ deps.claudeSessions.set("chat-1", existingSession)
293
+
294
+ await spawnClaudeTurn(deps, makeArgs({ localPath: "/tmp/other" }))
295
+
296
+ expect(closedSession).toBe(existingSession)
297
+ const newSession = deps.claudeSessions.get("chat-1")
298
+ expect(newSession?.localPath).toBe("/tmp/other")
299
+ })
300
+
301
+ test("evicts existing session when forkSession is true", async () => {
302
+ let closedSession: ClaudeSessionState | undefined
303
+ const deps = makeDeps({
304
+ closeClaudeSession: (_, session) => { closedSession = session },
305
+ })
306
+ const existingSession = makeSession()
307
+ deps.claudeSessions.set("chat-1", existingSession)
308
+
309
+ await spawnClaudeTurn(deps, makeArgs({ forkSession: true }))
310
+
311
+ expect(closedSession).toBe(existingSession)
312
+ })
313
+
314
+ test("evicts existing session when loop armed state flips from false to true", async () => {
315
+ let closedSession: ClaudeSessionState | undefined
316
+ const deps = makeDeps({
317
+ closeClaudeSession: (_, session) => { closedSession = session },
318
+ isLoopArmed: () => ({ subagentId: "sa-1", prompt: "loop", armedAt: 0, scheduleId: "s" }),
319
+ })
320
+ const existingSession = makeSession({ loopArmedAtSpawn: false })
321
+ deps.claudeSessions.set("chat-1", existingSession)
322
+
323
+ await spawnClaudeTurn(deps, makeArgs())
324
+
325
+ expect(closedSession).toBe(existingSession)
326
+ const newSession = deps.claudeSessions.get("chat-1")
327
+ expect(newSession?.loopArmedAtSpawn).toBe(true)
328
+ })
329
+ })
330
+
331
+ describe("OAuth pool handling", () => {
332
+ test("throws OAuthPoolUnavailableError when pool has tokens but none available", async () => {
333
+ const deps = makeDeps({
334
+ oauthPool: {
335
+ pickActive: () => null,
336
+ hasAnyToken: () => true,
337
+ markUsed: () => {},
338
+ release: () => {},
339
+ },
340
+ })
341
+
342
+ await expect(spawnClaudeTurn(deps, makeArgs({ provider: "claude" }))).rejects.toThrow(
343
+ OAuthPoolUnavailableError,
344
+ )
345
+ })
346
+
347
+ test("releases OAuth token when session spawn fails", async () => {
348
+ let released = false
349
+ const deps = makeDeps({
350
+ oauthPool: {
351
+ pickActive: () => ({ id: "tok-1", token: "tok-value", label: "test" }),
352
+ hasAnyToken: () => true,
353
+ markUsed: () => {},
354
+ release: () => { released = true },
355
+ },
356
+ startClaudeSessionFn: async () => { throw new Error("spawn failed") },
357
+ startClaudeSessionPTYFn: async () => { throw new Error("spawn failed") },
358
+ })
359
+
360
+ await expect(spawnClaudeTurn(deps, makeArgs())).rejects.toThrow("spawn failed")
361
+ expect(released).toBe(true)
362
+ })
363
+
364
+ test("marks picked token as used and stores masked key in new session", async () => {
365
+ let markedId: string | undefined
366
+ const deps = makeDeps({
367
+ oauthPool: {
368
+ pickActive: () => ({ id: "tok-abc", token: "sk-ant-abcdef1234", label: "my-token" }),
369
+ hasAnyToken: () => true,
370
+ markUsed: (id) => { markedId = id },
371
+ release: () => {},
372
+ },
373
+ })
374
+
375
+ await spawnClaudeTurn(deps, makeArgs({ provider: "claude" }))
376
+
377
+ expect(markedId).toBe("tok-abc")
378
+ const session = deps.claudeSessions.get("chat-1")
379
+ expect(session?.activeTokenId).toBe("tok-abc")
380
+ expect(session?.oauthLabel).toBe("my-token")
381
+ })
382
+ })
383
+
384
+ describe("return value", () => {
385
+ test("returned HarnessTurn has provider=claude", async () => {
386
+ const deps = makeDeps()
387
+ const turn = await spawnClaudeTurn(deps, makeArgs())
388
+ expect(turn.provider).toBe("claude")
389
+ })
390
+
391
+ test("returned HarnessTurn has an empty async-iterable stream", async () => {
392
+ const deps = makeDeps()
393
+ const turn = await spawnClaudeTurn(deps, makeArgs())
394
+ const events: unknown[] = []
395
+ for await (const e of turn.stream) {
396
+ events.push(e)
397
+ }
398
+ expect(events).toHaveLength(0)
399
+ })
400
+ })
401
+ })