@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,243 @@
1
+ import { describe, it, expect, mock } from "bun:test"
2
+ import {
3
+ isClaudeSteerLoggingEnabled,
4
+ isSendToStartingProfilingEnabled,
5
+ elapsedProfileMs,
6
+ logClaudeSteer,
7
+ logSendToStartingProfile,
8
+ type SendToStartingProfile,
9
+ } from "./claude-steer-log"
10
+ import { log } from "../shared/log"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function withEnv(key: string, value: string | undefined, fn: () => void) {
17
+ const previous = process.env[key]
18
+ if (value === undefined) {
19
+ delete process.env[key]
20
+ } else {
21
+ process.env[key] = value
22
+ }
23
+ try {
24
+ fn()
25
+ } finally {
26
+ if (previous === undefined) {
27
+ delete process.env[key]
28
+ } else {
29
+ process.env[key] = previous
30
+ }
31
+ }
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // isClaudeSteerLoggingEnabled
36
+ // ---------------------------------------------------------------------------
37
+
38
+ describe("isClaudeSteerLoggingEnabled", () => {
39
+ it("returns false when env var is unset", () => {
40
+ withEnv("KANNA_LOG_CLAUDE_STEER", undefined, () => {
41
+ expect(isClaudeSteerLoggingEnabled()).toBe(false)
42
+ })
43
+ })
44
+
45
+ it("returns false when env var is '0'", () => {
46
+ withEnv("KANNA_LOG_CLAUDE_STEER", "0", () => {
47
+ expect(isClaudeSteerLoggingEnabled()).toBe(false)
48
+ })
49
+ })
50
+
51
+ it("returns false when env var is any non-'1' string", () => {
52
+ withEnv("KANNA_LOG_CLAUDE_STEER", "true", () => {
53
+ expect(isClaudeSteerLoggingEnabled()).toBe(false)
54
+ })
55
+ })
56
+
57
+ it("returns true when env var is '1'", () => {
58
+ withEnv("KANNA_LOG_CLAUDE_STEER", "1", () => {
59
+ expect(isClaudeSteerLoggingEnabled()).toBe(true)
60
+ })
61
+ })
62
+ })
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // isSendToStartingProfilingEnabled
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe("isSendToStartingProfilingEnabled", () => {
69
+ it("returns false when env var is unset", () => {
70
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", undefined, () => {
71
+ expect(isSendToStartingProfilingEnabled()).toBe(false)
72
+ })
73
+ })
74
+
75
+ it("returns true when env var is '1'", () => {
76
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", "1", () => {
77
+ expect(isSendToStartingProfilingEnabled()).toBe(true)
78
+ })
79
+ })
80
+
81
+ it("returns false when env var is '0'", () => {
82
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", "0", () => {
83
+ expect(isSendToStartingProfilingEnabled()).toBe(false)
84
+ })
85
+ })
86
+ })
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // elapsedProfileMs
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("elapsedProfileMs", () => {
93
+ it("returns a non-negative number", () => {
94
+ const start = performance.now()
95
+ const elapsed = elapsedProfileMs(start)
96
+ expect(elapsed).toBeGreaterThanOrEqual(0)
97
+ })
98
+
99
+ it("returns a number (not NaN)", () => {
100
+ const elapsed = elapsedProfileMs(performance.now())
101
+ expect(Number.isNaN(elapsed)).toBe(false)
102
+ })
103
+
104
+ it("returns a value with at most 1 decimal place", () => {
105
+ const elapsed = elapsedProfileMs(performance.now() - 100)
106
+ // toFixed(1) means at most one decimal digit
107
+ expect(elapsed.toString()).toMatch(/^\d+(\.\d)?$/)
108
+ })
109
+
110
+ it("returns a larger value for an earlier startedAt", () => {
111
+ const earlier = performance.now() - 200
112
+ const later = performance.now() - 50
113
+ expect(elapsedProfileMs(earlier)).toBeGreaterThanOrEqual(elapsedProfileMs(later))
114
+ })
115
+ })
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // logClaudeSteer
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("logClaudeSteer", () => {
122
+ it("does not call log.info when logging is disabled", () => {
123
+ const spy = mock(() => {})
124
+ const original = log.info
125
+ log.info = spy
126
+ try {
127
+ withEnv("KANNA_LOG_CLAUDE_STEER", undefined, () => {
128
+ logClaudeSteer("test_stage", { foo: "bar" })
129
+ })
130
+ expect(spy).not.toHaveBeenCalled()
131
+ } finally {
132
+ log.info = original
133
+ }
134
+ })
135
+
136
+ it("calls log.info with stage and details when enabled", () => {
137
+ const calls: Parameters<typeof log.info>[] = []
138
+ const original = log.info
139
+ log.info = (...args: Parameters<typeof log.info>) => { calls.push(args) }
140
+ try {
141
+ withEnv("KANNA_LOG_CLAUDE_STEER", "1", () => {
142
+ logClaudeSteer("my_stage", { key: "val" })
143
+ })
144
+ expect(calls.length).toBe(1)
145
+ const [prefix, json] = calls[0]
146
+ expect(prefix).toBe("[kanna/claude-steer]")
147
+ const parsed = JSON.parse(json as string)
148
+ expect(parsed.stage).toBe("my_stage")
149
+ expect(parsed.key).toBe("val")
150
+ } finally {
151
+ log.info = original
152
+ }
153
+ })
154
+
155
+ it("calls log.info with only stage when details omitted", () => {
156
+ const calls: Parameters<typeof log.info>[] = []
157
+ const original = log.info
158
+ log.info = (...args: Parameters<typeof log.info>) => { calls.push(args) }
159
+ try {
160
+ withEnv("KANNA_LOG_CLAUDE_STEER", "1", () => {
161
+ logClaudeSteer("no_details")
162
+ })
163
+ expect(calls.length).toBe(1)
164
+ const [, json] = calls[0]
165
+ const parsed = JSON.parse(json as string)
166
+ expect(parsed.stage).toBe("no_details")
167
+ } finally {
168
+ log.info = original
169
+ }
170
+ })
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // logSendToStartingProfile
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe("logSendToStartingProfile", () => {
178
+ it("is a no-op when profile is null", () => {
179
+ const spy = mock(() => {})
180
+ const original = log.info
181
+ log.info = spy
182
+ try {
183
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", "1", () => {
184
+ logSendToStartingProfile(null, "some_stage")
185
+ })
186
+ expect(spy).not.toHaveBeenCalled()
187
+ } finally {
188
+ log.info = original
189
+ }
190
+ })
191
+
192
+ it("is a no-op when profile is undefined", () => {
193
+ const spy = mock(() => {})
194
+ const original = log.info
195
+ log.info = spy
196
+ try {
197
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", "1", () => {
198
+ logSendToStartingProfile(undefined, "some_stage")
199
+ })
200
+ expect(spy).not.toHaveBeenCalled()
201
+ } finally {
202
+ log.info = original
203
+ }
204
+ })
205
+
206
+ it("is a no-op when profiling env var is not set", () => {
207
+ const spy = mock(() => {})
208
+ const original = log.info
209
+ log.info = spy
210
+ try {
211
+ const profile: SendToStartingProfile = { traceId: "abc", startedAt: performance.now() }
212
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", undefined, () => {
213
+ logSendToStartingProfile(profile, "some_stage")
214
+ })
215
+ expect(spy).not.toHaveBeenCalled()
216
+ } finally {
217
+ log.info = original
218
+ }
219
+ })
220
+
221
+ it("logs traceId, stage, and elapsedMs when profile is provided and enabled", () => {
222
+ const calls: Parameters<typeof log.info>[] = []
223
+ const original = log.info
224
+ log.info = (...args: Parameters<typeof log.info>) => { calls.push(args) }
225
+ try {
226
+ const profile: SendToStartingProfile = { traceId: "trace-123", startedAt: performance.now() - 50 }
227
+ withEnv("KANNA_PROFILE_SEND_TO_STARTING", "1", () => {
228
+ logSendToStartingProfile(profile, "my_stage", { extra: 42 })
229
+ })
230
+ expect(calls.length).toBe(1)
231
+ const [prefix, json] = calls[0]
232
+ expect(prefix).toBe("[kanna/send->starting][server]")
233
+ const parsed = JSON.parse(json as string)
234
+ expect(parsed.traceId).toBe("trace-123")
235
+ expect(parsed.stage).toBe("my_stage")
236
+ expect(typeof parsed.elapsedMs).toBe("number")
237
+ expect(parsed.elapsedMs).toBeGreaterThanOrEqual(0)
238
+ expect(parsed.extra).toBe(42)
239
+ } finally {
240
+ log.info = original
241
+ }
242
+ })
243
+ })
@@ -0,0 +1,58 @@
1
+ import { log } from "../shared/log"
2
+ import type { AgentProvider, ModelOptions } from "../shared/types"
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Steer-logging and send-to-starting profiling helpers
6
+ // Extracted from agent.ts — pure env/math utilities, no AgentCoordinator dep.
7
+ // ---------------------------------------------------------------------------
8
+
9
+ export interface SendToStartingProfile {
10
+ traceId: string
11
+ startedAt: number
12
+ }
13
+
14
+ export function isClaudeSteerLoggingEnabled() {
15
+ return process.env.KANNA_LOG_CLAUDE_STEER === "1"
16
+ }
17
+
18
+ export function logClaudeSteer(stage: string, details?: Record<string, unknown>) {
19
+ if (!isClaudeSteerLoggingEnabled()) return
20
+ log.info("[kanna/claude-steer]", JSON.stringify({
21
+ stage,
22
+ ...details,
23
+ }))
24
+ }
25
+
26
+ export interface SendMessageOptions {
27
+ provider?: AgentProvider
28
+ model?: string
29
+ modelOptions?: ModelOptions
30
+ effort?: string
31
+ planMode?: boolean
32
+ autoContinue?: { scheduleId: string }
33
+ }
34
+
35
+ export function isSendToStartingProfilingEnabled() {
36
+ return process.env.KANNA_PROFILE_SEND_TO_STARTING === "1"
37
+ }
38
+
39
+ export function elapsedProfileMs(startedAt: number) {
40
+ return Number((performance.now() - startedAt).toFixed(1))
41
+ }
42
+
43
+ export function logSendToStartingProfile(
44
+ profile: SendToStartingProfile | null | undefined,
45
+ stage: string,
46
+ details?: Record<string, unknown>
47
+ ) {
48
+ if (!profile || !isSendToStartingProfilingEnabled()) {
49
+ return
50
+ }
51
+
52
+ log.info("[kanna/send->starting][server]", JSON.stringify({
53
+ traceId: profile.traceId,
54
+ stage,
55
+ elapsedMs: elapsedProfileMs(profile.startedAt),
56
+ ...details,
57
+ }))
58
+ }
@@ -0,0 +1,213 @@
1
+ import { describe, it, expect, mock } from "bun:test"
2
+ import {
3
+ subagentPendingKey,
4
+ rejectPendingResolvers,
5
+ rejectPendingResolversForChat,
6
+ rejectPendingResolversForRun,
7
+ respondSubagentTool,
8
+ cancelSubagentRun,
9
+ type SubagentToolResponseDeps,
10
+ } from "./claude-subagent-tool-response"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function makeResolver() {
17
+ let resolveFn: ((v: unknown) => void) | undefined
18
+ let rejectFn: ((e: Error) => void) | undefined
19
+ const promise = new Promise<unknown>((res, rej) => {
20
+ resolveFn = res
21
+ rejectFn = rej
22
+ })
23
+ // Suppress unhandled-rejection noise in tests
24
+ promise.catch(() => undefined)
25
+ return {
26
+ promise,
27
+ resolve: (v: unknown) => resolveFn!(v),
28
+ reject: (e: Error) => rejectFn!(e),
29
+ }
30
+ }
31
+
32
+ function makeDeps(
33
+ overrides?: Partial<SubagentToolResponseDeps>,
34
+ ): SubagentToolResponseDeps {
35
+ return {
36
+ subagentPendingResolvers: new Map(),
37
+ store: {
38
+ appendSubagentEvent: mock(() => Promise.resolve()),
39
+ },
40
+ subagentOrchestrator: {
41
+ notifySubagentToolResolved: mock(() => undefined),
42
+ cancelRun: mock(() => undefined),
43
+ },
44
+ emitStateChange: mock(() => undefined),
45
+ ...overrides,
46
+ }
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // subagentPendingKey
51
+ // ---------------------------------------------------------------------------
52
+
53
+ describe("subagentPendingKey", () => {
54
+ it("concatenates chatId, runId, and toolUseId with ::", () => {
55
+ expect(subagentPendingKey("chat1", "run1", "tool1")).toBe("chat1::run1::tool1")
56
+ })
57
+
58
+ it("handles empty segments", () => {
59
+ expect(subagentPendingKey("", "", "")).toBe("::::")
60
+ })
61
+ })
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // rejectPendingResolvers
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe("rejectPendingResolvers", () => {
68
+ it("rejects entries matching predicate and removes them from the map", async () => {
69
+ const deps = makeDeps()
70
+ const r1 = makeResolver()
71
+ const r2 = makeResolver()
72
+ deps.subagentPendingResolvers.set("chat1::run1::t1", r1)
73
+ deps.subagentPendingResolvers.set("chat2::run2::t2", r2)
74
+
75
+ rejectPendingResolvers(
76
+ deps,
77
+ (k) => k.startsWith("chat1::"),
78
+ "test reason",
79
+ )
80
+
81
+ await expect(r1.promise).rejects.toThrow("test reason")
82
+ expect(deps.subagentPendingResolvers.has("chat1::run1::t1")).toBe(false)
83
+ // Non-matching entry untouched
84
+ expect(deps.subagentPendingResolvers.has("chat2::run2::t2")).toBe(true)
85
+ })
86
+
87
+ it("does nothing when no entries match predicate", () => {
88
+ const deps = makeDeps()
89
+ const r = makeResolver()
90
+ deps.subagentPendingResolvers.set("chat1::run1::t1", r)
91
+
92
+ rejectPendingResolvers(deps, () => false, "ignored")
93
+
94
+ expect(deps.subagentPendingResolvers.size).toBe(1)
95
+ })
96
+ })
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // rejectPendingResolversForChat
100
+ // ---------------------------------------------------------------------------
101
+
102
+ describe("rejectPendingResolversForChat", () => {
103
+ it("rejects all resolvers belonging to the given chatId", async () => {
104
+ const deps = makeDeps()
105
+ const r1 = makeResolver()
106
+ const r2 = makeResolver()
107
+ const rOther = makeResolver()
108
+ deps.subagentPendingResolvers.set("chatA::run1::t1", r1)
109
+ deps.subagentPendingResolvers.set("chatA::run2::t2", r2)
110
+ deps.subagentPendingResolvers.set("chatB::run1::t1", rOther)
111
+
112
+ rejectPendingResolversForChat(deps, "chatA")
113
+
114
+ await expect(r1.promise).rejects.toThrow("chat cancelled")
115
+ await expect(r2.promise).rejects.toThrow("chat cancelled")
116
+ expect(deps.subagentPendingResolvers.has("chatB::run1::t1")).toBe(true)
117
+ })
118
+ })
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // rejectPendingResolversForRun
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe("rejectPendingResolversForRun", () => {
125
+ it("rejects all resolvers belonging to the given run", async () => {
126
+ const deps = makeDeps()
127
+ const rTarget = makeResolver()
128
+ const rOtherRun = makeResolver()
129
+ deps.subagentPendingResolvers.set("chatA::run1::t1", rTarget)
130
+ deps.subagentPendingResolvers.set("chatA::run2::t1", rOtherRun)
131
+
132
+ rejectPendingResolversForRun(deps, "chatA", "run1")
133
+
134
+ await expect(rTarget.promise).rejects.toThrow("subagent run terminated")
135
+ expect(deps.subagentPendingResolvers.has("chatA::run2::t1")).toBe(true)
136
+ })
137
+ })
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // respondSubagentTool
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe("respondSubagentTool", () => {
144
+ it("resolves the pending resolver, appends event, notifies orchestrator, emits state", async () => {
145
+ const deps = makeDeps()
146
+ const resolver = makeResolver()
147
+ deps.subagentPendingResolvers.set("chatX::runY::toolZ", resolver)
148
+
149
+ const command = {
150
+ type: "chat.respondSubagentTool" as const,
151
+ chatId: "chatX",
152
+ runId: "runY",
153
+ toolUseId: "toolZ",
154
+ result: { content: "ok" },
155
+ }
156
+
157
+ await respondSubagentTool(deps, command)
158
+
159
+ // Resolver resolved with the result
160
+ await expect(resolver.promise).resolves.toEqual({ content: "ok" })
161
+ // Removed from the map
162
+ expect(deps.subagentPendingResolvers.has("chatX::runY::toolZ")).toBe(false)
163
+ // Event persisted
164
+ expect(deps.store.appendSubagentEvent).toHaveBeenCalledWith(
165
+ expect.objectContaining({
166
+ type: "subagent_tool_resolved",
167
+ chatId: "chatX",
168
+ runId: "runY",
169
+ toolUseId: "toolZ",
170
+ result: { content: "ok" },
171
+ resolution: "user",
172
+ }),
173
+ )
174
+ // Orchestrator notified
175
+ expect(deps.subagentOrchestrator.notifySubagentToolResolved).toHaveBeenCalledWith("runY")
176
+ // State emitted
177
+ expect(deps.emitStateChange).toHaveBeenCalledWith("chatX")
178
+ })
179
+
180
+ it("is idempotent: does nothing when resolver is not found", async () => {
181
+ const deps = makeDeps()
182
+
183
+ await respondSubagentTool(deps, {
184
+ type: "chat.respondSubagentTool",
185
+ chatId: "chatX",
186
+ runId: "runY",
187
+ toolUseId: "toolZ",
188
+ result: {},
189
+ })
190
+
191
+ expect(deps.store.appendSubagentEvent).not.toHaveBeenCalled()
192
+ expect(deps.subagentOrchestrator.notifySubagentToolResolved).not.toHaveBeenCalled()
193
+ expect(deps.emitStateChange).not.toHaveBeenCalled()
194
+ })
195
+ })
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // cancelSubagentRun
199
+ // ---------------------------------------------------------------------------
200
+
201
+ describe("cancelSubagentRun", () => {
202
+ it("delegates to orchestrator.cancelRun", () => {
203
+ const deps = makeDeps()
204
+
205
+ cancelSubagentRun(deps, {
206
+ type: "chat.cancelSubagentRun",
207
+ chatId: "chatA",
208
+ runId: "runB",
209
+ })
210
+
211
+ expect(deps.subagentOrchestrator.cancelRun).toHaveBeenCalledWith("chatA", "runB")
212
+ })
213
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Standalone functions for managing subagent pending tool-response resolvers.
3
+ *
4
+ * Extracted from AgentCoordinator to keep agent.ts below the 600-LOC target.
5
+ * No direct IO — all side-effects are injected via SubagentToolResponseDeps.
6
+ */
7
+
8
+ import type { AnyValue } from "../shared/errors"
9
+ import type { SubagentRunEvent } from "./events"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Deps interface
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Minimal surface of SubagentOrchestrator required by this module. */
16
+ export interface SubagentToolResponseOrchestratorDeps {
17
+ notifySubagentToolResolved(runId: string): void
18
+ cancelRun(chatId: string, runId: string): void
19
+ }
20
+
21
+ /** Minimal surface of EventStore required by this module. */
22
+ export interface SubagentToolResponseStoreDeps {
23
+ appendSubagentEvent(event: SubagentRunEvent): Promise<void>
24
+ }
25
+
26
+ /** Dependency bag injected into every function in this module. */
27
+ export interface SubagentToolResponseDeps {
28
+ /** The shared in-memory resolver map owned by AgentCoordinator. */
29
+ subagentPendingResolvers: Map<
30
+ string,
31
+ { resolve: (v: AnyValue) => void; reject: (e: Error) => void }
32
+ >
33
+ store: SubagentToolResponseStoreDeps
34
+ subagentOrchestrator: SubagentToolResponseOrchestratorDeps
35
+ emitStateChange: (chatId: string) => void
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Command shape re-exports (inline to avoid protocol import cycle)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ export type RespondSubagentToolCommand = {
43
+ type: "chat.respondSubagentTool"
44
+ chatId: string
45
+ runId: string
46
+ toolUseId: string
47
+ result: Record<string, unknown>
48
+ }
49
+
50
+ export type CancelSubagentRunCommand = {
51
+ type: "chat.cancelSubagentRun"
52
+ chatId: string
53
+ runId: string
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Pure helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Builds the map key used to look up a pending subagent tool resolver. */
61
+ export function subagentPendingKey(
62
+ chatId: string,
63
+ runId: string,
64
+ toolUseId: string,
65
+ ): string {
66
+ return `${chatId}::${runId}::${toolUseId}`
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Resolver management
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Rejects all pending resolvers whose key satisfies `predicate`.
75
+ * Mutates `deps.subagentPendingResolvers` in place.
76
+ */
77
+ export function rejectPendingResolvers(
78
+ deps: Pick<SubagentToolResponseDeps, "subagentPendingResolvers">,
79
+ predicate: (key: string) => boolean,
80
+ reason: string,
81
+ ): void {
82
+ for (const [key, resolver] of deps.subagentPendingResolvers) {
83
+ if (!predicate(key)) continue
84
+ deps.subagentPendingResolvers.delete(key)
85
+ resolver.reject(new Error(reason))
86
+ }
87
+ }
88
+
89
+ /** Rejects all pending resolvers that belong to `chatId`. */
90
+ export function rejectPendingResolversForChat(
91
+ deps: Pick<SubagentToolResponseDeps, "subagentPendingResolvers">,
92
+ chatId: string,
93
+ ): void {
94
+ const prefix = `${chatId}::`
95
+ rejectPendingResolvers(deps, (k) => k.startsWith(prefix), "chat cancelled")
96
+ }
97
+
98
+ /** Rejects all pending resolvers that belong to a specific run within `chatId`. */
99
+ export function rejectPendingResolversForRun(
100
+ deps: Pick<SubagentToolResponseDeps, "subagentPendingResolvers">,
101
+ chatId: string,
102
+ runId: string,
103
+ ): void {
104
+ const prefix = `${chatId}::${runId}::`
105
+ rejectPendingResolvers(
106
+ deps,
107
+ (k) => k.startsWith(prefix),
108
+ "subagent run terminated",
109
+ )
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Command handlers
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Handles a `chat.respondSubagentTool` command from the client.
118
+ *
119
+ * Resolves the pending resolver for the given tool-use, persists a
120
+ * `subagent_tool_resolved` event, notifies the orchestrator, and emits a
121
+ * state-change.
122
+ *
123
+ * Idempotent: a double-submit (client retry, concurrent WS messages, or a
124
+ * response arriving after the run already terminated) is silently ignored.
125
+ */
126
+ export async function respondSubagentTool(
127
+ deps: SubagentToolResponseDeps,
128
+ command: RespondSubagentToolCommand,
129
+ ): Promise<void> {
130
+ const key = subagentPendingKey(command.chatId, command.runId, command.toolUseId)
131
+ const resolver = deps.subagentPendingResolvers.get(key)
132
+ if (!resolver) {
133
+ // Idempotent: a double-submit (client retry, concurrent WS messages, or
134
+ // a response arriving after the run already terminated) should not
135
+ // surface a confusing error to the UI. Resolver-absent = already
136
+ // resolved or run died; nothing to do.
137
+ return
138
+ }
139
+ deps.subagentPendingResolvers.delete(key)
140
+ await deps.store.appendSubagentEvent({
141
+ v: 3,
142
+ type: "subagent_tool_resolved",
143
+ timestamp: Date.now(),
144
+ chatId: command.chatId,
145
+ runId: command.runId,
146
+ toolUseId: command.toolUseId,
147
+ result: command.result,
148
+ resolution: "user",
149
+ })
150
+ deps.subagentOrchestrator.notifySubagentToolResolved(command.runId)
151
+ resolver.resolve(command.result)
152
+ deps.emitStateChange(command.chatId)
153
+ }
154
+
155
+ /**
156
+ * Handles a `chat.cancelSubagentRun` command from the client.
157
+ *
158
+ * Cancels the named run via the orchestrator.
159
+ */
160
+ export function cancelSubagentRun(
161
+ deps: Pick<SubagentToolResponseDeps, "subagentOrchestrator">,
162
+ command: CancelSubagentRunCommand,
163
+ ): void {
164
+ deps.subagentOrchestrator.cancelRun(command.chatId, command.runId)
165
+ }