@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,241 @@
1
+ /**
2
+ * Standalone cancel handler for AgentCoordinator.
3
+ *
4
+ * Extracted from agent.ts so the chat-cancellation logic lives in its
5
+ * own testable module. The coordinator delegates to `cancelChat` by
6
+ * passing an object literal that satisfies `CancelHandlerDeps`.
7
+ *
8
+ * Side-effect seal: this module contains NO direct IO (no node:fs, no HTTP
9
+ * calls, no Bun primitives). Every effectful operation is injected through
10
+ * the deps interface.
11
+ */
12
+
13
+ import type { ClaudeDriverPreference, TranscriptEntry } from "../shared/types"
14
+ import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
15
+ import type { HarnessTurn } from "./harness-types"
16
+ import { logClaudeSteer } from "./claude-steer-log"
17
+ import { discardedToolResult } from "./claude-sdk-queue"
18
+ import { timestamped } from "./claude-message-normalizer"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Structural sub-interfaces — only the slices this module calls.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Subset of the draining-streams map used by the cancel handler. */
25
+ interface DrainingStreamsMap {
26
+ get(chatId: string): { turn: HarnessTurn } | undefined
27
+ delete(chatId: string): boolean
28
+ }
29
+
30
+ /** Subset of EventStore used by the cancel handler. */
31
+ interface CancelStore {
32
+ appendMessage(chatId: string, entry: TranscriptEntry): Promise<void>
33
+ recordTurnCancelled(chatId: string): Promise<void>
34
+ }
35
+
36
+ /** Subset of the activeTurns map used by the cancel handler. */
37
+ interface ActiveTurnsMap {
38
+ get(chatId: string): ActiveTurn | undefined
39
+ delete(chatId: string): boolean
40
+ }
41
+
42
+ /** Subset of the claudeSessions map used by the cancel handler. */
43
+ interface ClaudeSessionsMap {
44
+ get(chatId: string): ClaudeSessionState | undefined
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Dependency bundle injected by AgentCoordinator
49
+ // ---------------------------------------------------------------------------
50
+
51
+ export interface CancelHandlerDeps {
52
+ /** The draining-streams map. The handler reads and deletes from it. */
53
+ drainingStreams: DrainingStreamsMap
54
+
55
+ /** Reject all pending `canUseTool` Promises waiting on user input for this chat. */
56
+ rejectPendingResolversForChat(chatId: string): void
57
+
58
+ /** Signal the subagent orchestrator to cancel all runs for this chat. */
59
+ cancelChatInOrchestrator(chatId: string): void
60
+
61
+ /** The active-turns map. The handler reads and deletes from it. */
62
+ activeTurns: ActiveTurnsMap
63
+
64
+ /** Store — for appending transcript entries and recording the cancelled turn. */
65
+ store: CancelStore
66
+
67
+ /** The claude-sessions map. Read-only from the handler's perspective. */
68
+ claudeSessions: ClaudeSessionsMap
69
+
70
+ /** Emit a state-change event for a chat. */
71
+ emitStateChange(chatId: string): void
72
+
73
+ /** Return the currently resolved Claude driver preference. */
74
+ resolveClaudeDriverPreference(): ClaudeDriverPreference
75
+
76
+ /**
77
+ * Tear down a Claude session and (by default) release any OAuth-pool
78
+ * reservation for this chat. Delegates to closeClaudeSession in
79
+ * claude-session-lifecycle.ts.
80
+ */
81
+ closeClaudeSession(chatId: string, session: ClaudeSessionState): void
82
+
83
+ /** Dequeue and start the next message waiting in the chat queue, if any. */
84
+ maybeStartNextQueuedMessage(chatId: string): Promise<void>
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Exported standalone function
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Cancel an active turn for a chat.
93
+ *
94
+ * 1. Closes any draining stream for the chat.
95
+ * 2. Rejects pending `canUseTool` Promises and signals the subagent orchestrator.
96
+ * 3. If there is an active turn:
97
+ * a. Guards against concurrent cancel calls.
98
+ * b. Discards any pending tool request and appends a `tool_result` entry.
99
+ * c. Appends an `interrupted` transcript entry and records `turn_cancelled`.
100
+ * d. Removes the turn from `activeTurns` so the UI reflects cancellation.
101
+ * e. Drains the cancelled prompt's seq from the Claude session's pending queue.
102
+ * f. Emits a state-change event.
103
+ * g. Interrupts and closes the underlying stream (best-effort, 5 s timeout).
104
+ * h. For PTY driver: drops the dead Claude session from the map.
105
+ * i. Optionally drains the message queue (unless `skipQueueDrain` is set).
106
+ *
107
+ * @param deps Injected dependencies — all coordinator state arrives through here.
108
+ * @param chatId The chat to cancel.
109
+ * @param options
110
+ * - `hideInterrupted` If true, the `interrupted` transcript entry is hidden.
111
+ * - `skipQueueDrain` If true, skip the `maybeStartNextQueuedMessage` call.
112
+ * Used by callers (e.g. `steer`) that handle dequeue themselves.
113
+ */
114
+ export async function cancelChat(
115
+ deps: CancelHandlerDeps,
116
+ chatId: string,
117
+ options?: { hideInterrupted?: boolean; skipQueueDrain?: boolean },
118
+ ): Promise<void> {
119
+ // Also clean up any draining stream for this chat.
120
+ const draining = deps.drainingStreams.get(chatId)
121
+ if (draining) {
122
+ draining.turn.close()
123
+ deps.drainingStreams.delete(chatId)
124
+ }
125
+
126
+ // Reject any subagent canUseTool Promises waiting on a user response in
127
+ // this chat, and signal the orchestrator. Both happen unconditionally —
128
+ // a chat may have no active main-turn (e.g. just an @mention with the
129
+ // main turn already ended) while subagents are still running. Without
130
+ // this, the SDK's canUseTool callback hangs forever, wedging the
131
+ // subagent session and leaking the resolver entry.
132
+ deps.rejectPendingResolversForChat(chatId)
133
+ deps.cancelChatInOrchestrator(chatId)
134
+
135
+ const active = deps.activeTurns.get(chatId)
136
+ if (!active) return
137
+
138
+ logClaudeSteer("cancel_requested", {
139
+ chatId,
140
+ provider: active.provider,
141
+ activePromptSeq: active.claudePromptSeq ?? null,
142
+ })
143
+
144
+ // Guard against concurrent cancel() calls — only the first one does work.
145
+ if (active.cancelRequested) return
146
+ active.cancelRequested = true
147
+
148
+ const pendingTool = active.pendingTool
149
+ active.pendingTool = null
150
+
151
+ if (pendingTool) {
152
+ const result = discardedToolResult(pendingTool.tool)
153
+ await deps.store.appendMessage(
154
+ chatId,
155
+ timestamped({
156
+ kind: "tool_result",
157
+ toolId: pendingTool.toolUseId,
158
+ content: result,
159
+ }),
160
+ )
161
+ if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
162
+ pendingTool.resolve(result)
163
+ }
164
+ }
165
+
166
+ await deps.store.appendMessage(
167
+ chatId,
168
+ timestamped({ kind: "interrupted", hidden: options?.hideInterrupted }),
169
+ )
170
+ await deps.store.recordTurnCancelled(chatId)
171
+ active.cancelRecorded = true
172
+ active.hasFinalResult = true
173
+
174
+ // Remove from activeTurns immediately so the UI reflects the cancellation
175
+ // right away, rather than waiting for interrupt() which may hang.
176
+ deps.activeTurns.delete(chatId)
177
+
178
+ // Drain the cancelled prompt's seq from the Claude session's pending
179
+ // queue. The SDK does not always echo a `result.subtype=cancelled` for
180
+ // an interrupted prompt — when the stream just ends, the seq would
181
+ // otherwise linger and cause a FIFO mismatch when the next turn's
182
+ // result arrives, leaving the chat stuck in "running".
183
+ if (active.provider === "claude" && active.claudePromptSeq != null) {
184
+ const session = deps.claudeSessions.get(chatId)
185
+ if (session) {
186
+ const idx = session.pendingPromptSeqs.indexOf(active.claudePromptSeq)
187
+ if (idx >= 0) session.pendingPromptSeqs.splice(idx, 1)
188
+ // The SDK driver's `interrupt()` emits a tail `result` with
189
+ // subtype `error_during_execution` (empty text) after the splice
190
+ // above. Mark it pending so runClaudeSession suppresses that one
191
+ // result instead of rendering "An unknown error occurred." The
192
+ // `interrupted` entry above is the user-visible cancellation.
193
+ session.cancelledResultPending += 1
194
+ }
195
+ }
196
+
197
+ deps.emitStateChange(chatId)
198
+ logClaudeSteer("cancel_active_turn_deleted", {
199
+ chatId,
200
+ provider: active.provider,
201
+ activePromptSeq: active.claudePromptSeq ?? null,
202
+ })
203
+
204
+ // Now attempt to interrupt/close the underlying stream in the background.
205
+ // This is best-effort — the turn is already removed from active state above,
206
+ // and runTurn()'s finally block will also call close().
207
+ try {
208
+ await Promise.race([
209
+ active.turn.interrupt(),
210
+ new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
211
+ ])
212
+ } catch {
213
+ // interrupt() failed — force close
214
+ }
215
+ active.turn.close()
216
+
217
+ // For Claude under the PTY driver, `active.turn` is a ghost facade over
218
+ // the long-lived `claudeSessions` entry and its `close()` is a no-op.
219
+ // The PTY driver's `interrupt()` sends SIGINT which terminates the CLI,
220
+ // so the underlying session is dead — drop it from the map so the next
221
+ // turn respawns a fresh `claude --resume <sessionToken>` (preserves
222
+ // transcript context). For the SDK driver, `interrupt()` is honored
223
+ // in-band without killing the worker, so reuse is still valid.
224
+ if (active.provider === "claude" && deps.resolveClaudeDriverPreference() === "pty") {
225
+ const session = deps.claudeSessions.get(chatId)
226
+ if (session) {
227
+ deps.closeClaudeSession(chatId, session)
228
+ }
229
+ }
230
+
231
+ // Drain the queue. A queued message must auto-start after cancel; the
232
+ // result-success branch in runClaudeSession is the only other place this
233
+ // is called, and it can never fire for a cancelled turn (active has been
234
+ // deleted above before the result event arrives).
235
+ //
236
+ // `skipQueueDrain` is passed by callers that handle dequeue themselves
237
+ // (e.g. `steer`, which dequeues the head message with the steer wrapper).
238
+ if (!options?.skipQueueDrain) {
239
+ await deps.maybeStartNextQueuedMessage(chatId)
240
+ }
241
+ }
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Tests for the extracted chat management functions.
3
+ * All deps are injected stubs — no IO, no Claude harness.
4
+ */
5
+
6
+ import { describe, it, expect, mock } from "bun:test"
7
+ import {
8
+ stopDraining,
9
+ closeChat,
10
+ steer,
11
+ dequeue,
12
+ forkChat,
13
+ generateTitleInBackground,
14
+ type ChatManagementDeps,
15
+ } from "./claude-chat-management"
16
+ import type { QueuedChatMessage } from "../shared/types"
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Minimal stub helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ function makeQueuedMessage(overrides?: Partial<QueuedChatMessage>): QueuedChatMessage {
23
+ return {
24
+ id: "msg-1",
25
+ content: "hello",
26
+ attachments: [],
27
+ createdAt: 0,
28
+ provider: "claude",
29
+ model: "claude-opus-4-5",
30
+ planMode: false,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function makeDeps(overrides?: Partial<ChatManagementDeps>): ChatManagementDeps {
36
+ return {
37
+ activeTurns: {
38
+ has: mock(() => false),
39
+ get: mock(() => undefined),
40
+ },
41
+ drainingStreams: {
42
+ get: mock(() => undefined),
43
+ has: mock(() => false),
44
+ delete: mock(() => false),
45
+ },
46
+ claudeSessions: {
47
+ get: mock(() => undefined),
48
+ },
49
+ autoResumeByChat: {
50
+ delete: mock(() => false),
51
+ },
52
+ store: {
53
+ getQueuedMessage: mock((_chatId: string, _id: string) => null),
54
+ removeQueuedMessage: mock(async () => {}),
55
+ requireChat: mock((_chatId: string) => ({
56
+ title: "Chat title",
57
+ provider: "claude" as const,
58
+ sessionTokensByProvider: { claude: "tok-123" },
59
+ pendingForkSessionToken: null,
60
+ })),
61
+ forkChat: mock(async (_chatId: string) => ({ id: "forked-id" })),
62
+ renameChat: mock(async () => {}),
63
+ },
64
+ analytics: {
65
+ track: mock(() => {}),
66
+ },
67
+ cancel: mock(async () => {}),
68
+ closeClaudeSession: mock(() => {}),
69
+ emitStateChange: mock(() => {}),
70
+ generateTitle: mock(async () => ({ title: "New Title", usedFallback: false, failureMessage: null })),
71
+ reportBackgroundError: null,
72
+ dequeueAndStartQueuedMessage: mock(async () => {}),
73
+ ...overrides,
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // stopDraining
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe("stopDraining", () => {
82
+ it("is a no-op when no draining stream exists", async () => {
83
+ const deps = makeDeps()
84
+ await stopDraining(deps, "chat-1")
85
+ expect(deps.emitStateChange).not.toHaveBeenCalled()
86
+ expect(deps.drainingStreams.delete).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it("closes the turn, removes the stream, and emits state change", async () => {
90
+ const close = mock(() => {})
91
+ const deps = makeDeps({
92
+ drainingStreams: {
93
+ get: mock(() => ({ turn: { close } })),
94
+ has: mock(() => true),
95
+ delete: mock(() => true),
96
+ },
97
+ })
98
+ await stopDraining(deps, "chat-1")
99
+ expect(close).toHaveBeenCalledTimes(1)
100
+ expect(deps.drainingStreams.delete).toHaveBeenCalledWith("chat-1")
101
+ expect(deps.emitStateChange).toHaveBeenCalledWith("chat-1")
102
+ })
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // closeChat
107
+ // ---------------------------------------------------------------------------
108
+
109
+ describe("closeChat", () => {
110
+ it("stops draining, clears auto-resume, and emits state change", async () => {
111
+ const deps = makeDeps()
112
+ await closeChat(deps, "chat-1")
113
+ expect(deps.autoResumeByChat.delete).toHaveBeenCalledWith("chat-1")
114
+ expect(deps.emitStateChange).toHaveBeenCalledWith("chat-1")
115
+ })
116
+
117
+ it("closes the claude session when one exists", async () => {
118
+ const fakeSession = { sessionId: "s1" } as never
119
+ const deps = makeDeps({
120
+ claudeSessions: { get: mock(() => fakeSession) },
121
+ })
122
+ await closeChat(deps, "chat-1")
123
+ expect(deps.closeClaudeSession).toHaveBeenCalledWith("chat-1", fakeSession)
124
+ })
125
+
126
+ it("skips closeClaudeSession when no session exists", async () => {
127
+ const deps = makeDeps()
128
+ await closeChat(deps, "chat-1")
129
+ expect(deps.closeClaudeSession).not.toHaveBeenCalled()
130
+ })
131
+ })
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // steer
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("steer", () => {
138
+ it("throws when queued message not found", async () => {
139
+ const deps = makeDeps()
140
+ await expect(
141
+ steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" }),
142
+ ).rejects.toThrow("Queued message not found")
143
+ })
144
+
145
+ it("cancels active turn then dequeues when chat is active", async () => {
146
+ const qm = makeQueuedMessage()
147
+ // steer() calls has() 4 times: (1) in the log object, (2) the real guard that
148
+ // triggers cancel, (3) in the after-cancel log, (4) the throw guard.
149
+ // We need calls 1+2 → true (chat is active), calls 3+4 → false (idle after cancel).
150
+ let hasCallCount = 0
151
+ const cancelFn = mock(async () => {})
152
+ const dequeueStartFn = mock(async () => {})
153
+ const deps: ChatManagementDeps = {
154
+ ...makeDeps(),
155
+ activeTurns: {
156
+ has: (_chatId: string) => hasCallCount++ < 2, // first 2 calls → active; rest → idle
157
+ get: () => undefined,
158
+ },
159
+ store: {
160
+ ...makeDeps().store,
161
+ getQueuedMessage: (_chatId: string, _id: string) => qm,
162
+ },
163
+ cancel: cancelFn,
164
+ dequeueAndStartQueuedMessage: dequeueStartFn,
165
+ }
166
+
167
+ await steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" })
168
+ expect(cancelFn).toHaveBeenCalledWith("c1", { hideInterrupted: true, skipQueueDrain: true })
169
+ expect(dequeueStartFn).toHaveBeenCalledWith("c1", qm, { steered: true })
170
+ })
171
+
172
+ it("throws when chat is still active after cancel", async () => {
173
+ const qm = makeQueuedMessage()
174
+ const deps: ChatManagementDeps = {
175
+ ...makeDeps(),
176
+ activeTurns: {
177
+ has: () => true, // always active — cancel didn't help
178
+ get: () => undefined,
179
+ },
180
+ store: {
181
+ ...makeDeps().store,
182
+ getQueuedMessage: (_chatId: string, _id: string) => qm,
183
+ },
184
+ }
185
+ await expect(
186
+ steer(deps, { type: "message.steer", chatId: "c1", queuedMessageId: "qm-1" }),
187
+ ).rejects.toThrow("Chat is still running")
188
+ })
189
+ })
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // dequeue
193
+ // ---------------------------------------------------------------------------
194
+
195
+ describe("dequeue", () => {
196
+ it("throws when queued message not found", async () => {
197
+ const deps = makeDeps()
198
+ await expect(
199
+ dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" }),
200
+ ).rejects.toThrow("Queued message not found")
201
+ })
202
+
203
+ it("throws when proactive compact is running", async () => {
204
+ const qm = makeQueuedMessage()
205
+ const deps = makeDeps({
206
+ activeTurns: {
207
+ has: mock(() => true),
208
+ get: mock(() => ({ proactiveCompactInjection: true })),
209
+ },
210
+ store: {
211
+ getQueuedMessage: mock(() => qm),
212
+ removeQueuedMessage: mock(async () => {}),
213
+ requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
214
+ forkChat: mock(async () => ({ id: "" })),
215
+ renameChat: mock(async () => {}),
216
+ },
217
+ })
218
+ await expect(
219
+ dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" }),
220
+ ).rejects.toThrow("Cannot remove queued message while compact is running")
221
+ })
222
+
223
+ it("removes the queued message when safe", async () => {
224
+ const qm = makeQueuedMessage()
225
+ const deps = makeDeps({
226
+ store: {
227
+ getQueuedMessage: mock(() => qm),
228
+ removeQueuedMessage: mock(async () => {}),
229
+ requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
230
+ forkChat: mock(async () => ({ id: "" })),
231
+ renameChat: mock(async () => {}),
232
+ },
233
+ })
234
+ await dequeue(deps, { type: "message.dequeue", chatId: "c1", queuedMessageId: "qm-1" })
235
+ expect(deps.store.removeQueuedMessage).toHaveBeenCalledWith("c1", "qm-1")
236
+ })
237
+ })
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // forkChat
241
+ // ---------------------------------------------------------------------------
242
+
243
+ describe("forkChat", () => {
244
+ it("throws when chat is active", async () => {
245
+ const deps = makeDeps({
246
+ activeTurns: { has: mock(() => true), get: mock(() => undefined) },
247
+ })
248
+ await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must be idle before forking")
249
+ })
250
+
251
+ it("throws when chat is draining", async () => {
252
+ const deps = makeDeps({
253
+ drainingStreams: { has: mock(() => true), get: mock(() => undefined), delete: mock(() => false) },
254
+ })
255
+ await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must be idle before forking")
256
+ })
257
+
258
+ it("throws when chat has no provider", async () => {
259
+ const deps = makeDeps({
260
+ store: {
261
+ getQueuedMessage: mock(() => null),
262
+ removeQueuedMessage: mock(async () => {}),
263
+ requireChat: mock(() => ({ title: "", provider: null, sessionTokensByProvider: {}, pendingForkSessionToken: null })),
264
+ forkChat: mock(async () => ({ id: "" })),
265
+ renameChat: mock(async () => {}),
266
+ },
267
+ })
268
+ await expect(forkChat(deps, "c1")).rejects.toThrow("Chat must have a provider before forking")
269
+ })
270
+
271
+ it("throws when chat has no session token", async () => {
272
+ const deps = makeDeps({
273
+ store: {
274
+ getQueuedMessage: mock(() => null),
275
+ removeQueuedMessage: mock(async () => {}),
276
+ requireChat: mock(() => ({
277
+ title: "",
278
+ provider: "claude" as const,
279
+ sessionTokensByProvider: { claude: null },
280
+ pendingForkSessionToken: null,
281
+ })),
282
+ forkChat: mock(async () => ({ id: "" })),
283
+ renameChat: mock(async () => {}),
284
+ },
285
+ })
286
+ await expect(forkChat(deps, "c1")).rejects.toThrow("Chat has no session to fork")
287
+ })
288
+
289
+ it("forks and tracks analytics on success", async () => {
290
+ const deps = makeDeps()
291
+ const result = await forkChat(deps, "c1")
292
+ expect(result).toEqual({ chatId: "forked-id" })
293
+ expect(deps.analytics.track).toHaveBeenCalledWith("chat_created")
294
+ })
295
+
296
+ it("accepts pendingForkSessionToken as the fork source", async () => {
297
+ const deps = makeDeps({
298
+ store: {
299
+ getQueuedMessage: mock(() => null),
300
+ removeQueuedMessage: mock(async () => {}),
301
+ requireChat: mock(() => ({
302
+ title: "",
303
+ provider: "claude" as const,
304
+ sessionTokensByProvider: { claude: null },
305
+ pendingForkSessionToken: { provider: "claude" as const, token: "pending-tok" },
306
+ })),
307
+ forkChat: mock(async () => ({ id: "forked-2" })),
308
+ renameChat: mock(async () => {}),
309
+ },
310
+ })
311
+ const result = await forkChat(deps, "c1")
312
+ expect(result).toEqual({ chatId: "forked-2" })
313
+ })
314
+ })
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // generateTitleInBackground
318
+ // ---------------------------------------------------------------------------
319
+
320
+ describe("generateTitleInBackground", () => {
321
+ it("renames chat when title generation succeeds and title still matches", async () => {
322
+ const deps = makeDeps({
323
+ generateTitle: mock(async () => ({ title: "AI Title", usedFallback: false, failureMessage: null })),
324
+ store: {
325
+ getQueuedMessage: mock(() => null),
326
+ removeQueuedMessage: mock(async () => {}),
327
+ requireChat: mock(() => ({
328
+ title: "original",
329
+ provider: null,
330
+ sessionTokensByProvider: {},
331
+ pendingForkSessionToken: null,
332
+ })),
333
+ forkChat: mock(async () => ({ id: "" })),
334
+ renameChat: mock(async () => {}),
335
+ },
336
+ })
337
+ await generateTitleInBackground(deps, "c1", "message content", "/path", "original")
338
+ expect(deps.store.renameChat).toHaveBeenCalledWith("c1", "AI Title")
339
+ expect(deps.emitStateChange).toHaveBeenCalledWith("c1")
340
+ })
341
+
342
+ it("skips rename when title has changed since the call was queued", async () => {
343
+ const deps = makeDeps({
344
+ generateTitle: mock(async () => ({ title: "AI Title", usedFallback: false, failureMessage: null })),
345
+ store: {
346
+ getQueuedMessage: mock(() => null),
347
+ removeQueuedMessage: mock(async () => {}),
348
+ requireChat: mock(() => ({
349
+ title: "already changed",
350
+ provider: null,
351
+ sessionTokensByProvider: {},
352
+ pendingForkSessionToken: null,
353
+ })),
354
+ forkChat: mock(async () => ({ id: "" })),
355
+ renameChat: mock(async () => {}),
356
+ },
357
+ })
358
+ await generateTitleInBackground(deps, "c1", "message content", "/path", "original")
359
+ expect(deps.store.renameChat).not.toHaveBeenCalled()
360
+ })
361
+
362
+ it("skips rename when usedFallback is true", async () => {
363
+ const deps = makeDeps({
364
+ generateTitle: mock(async () => ({ title: "fallback", usedFallback: true, failureMessage: null })),
365
+ })
366
+ await generateTitleInBackground(deps, "c1", "content", "/path", "original")
367
+ expect(deps.store.renameChat).not.toHaveBeenCalled()
368
+ })
369
+
370
+ it("reports error on failure without throwing", async () => {
371
+ const report = mock((_msg: string) => {})
372
+ const deps = makeDeps({
373
+ generateTitle: mock(async () => { throw new Error("API error") }),
374
+ reportBackgroundError: report,
375
+ })
376
+ await expect(
377
+ generateTitleInBackground(deps, "c1", "content", "/path", "original"),
378
+ ).resolves.toBeUndefined()
379
+ expect(report).toHaveBeenCalledWith(
380
+ expect.stringContaining("[title-generation] chat c1 failed background title generation: API error"),
381
+ )
382
+ })
383
+
384
+ it("reports provider failure message without renaming", async () => {
385
+ const report = mock((_msg: string) => {})
386
+ const deps = makeDeps({
387
+ generateTitle: mock(async () => ({ title: "title", usedFallback: false, failureMessage: "quota exceeded" })),
388
+ reportBackgroundError: report,
389
+ })
390
+ await generateTitleInBackground(deps, "c1", "content", "/path", "original")
391
+ expect(report).toHaveBeenCalledWith(
392
+ expect.stringContaining("failed provider title generation: quota exceeded"),
393
+ )
394
+ })
395
+ })