@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,472 @@
1
+ /**
2
+ * Tests for the extracted cancelChat standalone function.
3
+ *
4
+ * Each test builds a minimal `CancelHandlerDeps` fake and asserts the
5
+ * correct behaviour without any real IO or OS calls.
6
+ */
7
+
8
+ import { describe, test, expect, beforeEach } from "bun:test"
9
+ import { cancelChat, type CancelHandlerDeps } from "./claude-cancel-handler"
10
+ import type { ActiveTurn, ClaudeSessionState } from "./claude-session-state"
11
+ import type { HarnessTurn, ClaudeSessionHandle } from "./harness-types"
12
+ import type { TranscriptEntry } from "../shared/types"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Minimal HarnessTurn (used for ActiveTurn.turn). */
19
+ function makeFakeTurn(overrides: Partial<HarnessTurn> = {}): HarnessTurn {
20
+ return {
21
+ provider: "claude",
22
+ stream: (async function* () {})() as AsyncIterable<never>,
23
+ interrupt: async () => {},
24
+ close: () => {},
25
+ ...overrides,
26
+ }
27
+ }
28
+
29
+ /** Minimal ClaudeSessionHandle (used for ClaudeSessionState.session). */
30
+ function makeFakeHandle(): ClaudeSessionHandle {
31
+ return {
32
+ provider: "claude",
33
+ stream: (async function* () {})() as AsyncIterable<never>,
34
+ interrupt: async () => {},
35
+ close: () => {},
36
+ sendPrompt: async () => {},
37
+ setModel: async () => {},
38
+ setPermissionMode: async () => {},
39
+ getSupportedCommands: async () => [],
40
+ }
41
+ }
42
+
43
+ function makeActiveTurn(overrides: Partial<ActiveTurn> = {}): ActiveTurn {
44
+ return {
45
+ chatId: "chat-1",
46
+ provider: "claude",
47
+ turn: makeFakeTurn(),
48
+ model: "claude-opus-4",
49
+ planMode: false,
50
+ status: "running",
51
+ pendingTool: null,
52
+ postToolFollowUp: null,
53
+ hasFinalResult: false,
54
+ cancelRequested: false,
55
+ cancelRecorded: false,
56
+ waitStartedAt: null,
57
+ userMessageId: null,
58
+ ...overrides,
59
+ }
60
+ }
61
+
62
+ function makeSession(overrides: Partial<ClaudeSessionState> = {}): ClaudeSessionState {
63
+ return {
64
+ id: "sess-1",
65
+ chatId: "chat-1",
66
+ session: makeFakeHandle(),
67
+ localPath: "/home/user/project",
68
+ additionalDirectories: [],
69
+ model: "claude-opus-4",
70
+ planMode: false,
71
+ sessionToken: null,
72
+ accountInfoLoaded: false,
73
+ nextPromptSeq: 1,
74
+ pendingPromptSeqs: [],
75
+ activeTokenId: null,
76
+ oauthKeyMasked: null,
77
+ oauthLabel: null,
78
+ openrouterKeyMasked: null,
79
+ openrouterModel: null,
80
+ lastUsedAt: Date.now(),
81
+ backgroundTaskIds: new Set<string>(),
82
+ backgroundTaskDeadlineAt: 0,
83
+ loopArmedAtSpawn: false,
84
+ workflowsDirRegistered: false,
85
+ cancelledResultPending: 0,
86
+ suppressSessionTokenPersist: false,
87
+ ...overrides,
88
+ }
89
+ }
90
+
91
+ type DepOverrides = {
92
+ drainingStreams?: Map<string, { turn: HarnessTurn }>
93
+ activeTurns?: Map<string, ActiveTurn>
94
+ claudeSessions?: Map<string, ClaudeSessionState>
95
+ appendedMessages?: TranscriptEntry[]
96
+ turnCancelledFor?: string[]
97
+ stateChanges?: string[]
98
+ rejectCalled?: string[]
99
+ orchestratorCancelled?: string[]
100
+ closedSessions?: string[]
101
+ queueDrained?: string[]
102
+ driver?: "sdk" | "pty"
103
+ }
104
+
105
+ function makeDeps(overrides: DepOverrides = {}): CancelHandlerDeps {
106
+ const drainingStreams: Map<string, { turn: HarnessTurn }> = overrides.drainingStreams ?? new Map()
107
+ const activeTurns: Map<string, ActiveTurn> = overrides.activeTurns ?? new Map()
108
+ const claudeSessions: Map<string, ClaudeSessionState> = overrides.claudeSessions ?? new Map()
109
+ const appendedMessages = overrides.appendedMessages ?? []
110
+ const turnCancelledFor = overrides.turnCancelledFor ?? []
111
+ const stateChanges = overrides.stateChanges ?? []
112
+ const rejectCalled = overrides.rejectCalled ?? []
113
+ const orchestratorCancelled = overrides.orchestratorCancelled ?? []
114
+ const closedSessions = overrides.closedSessions ?? []
115
+ const queueDrained = overrides.queueDrained ?? []
116
+ const driver = overrides.driver ?? "sdk"
117
+
118
+ return {
119
+ drainingStreams,
120
+ rejectPendingResolversForChat: (chatId) => { rejectCalled.push(chatId) },
121
+ cancelChatInOrchestrator: (chatId) => { orchestratorCancelled.push(chatId) },
122
+ activeTurns,
123
+ store: {
124
+ appendMessage: async (_chatId, entry) => { appendedMessages.push(entry) },
125
+ recordTurnCancelled: async (chatId) => { turnCancelledFor.push(chatId) },
126
+ },
127
+ claudeSessions,
128
+ emitStateChange: (chatId) => { stateChanges.push(chatId) },
129
+ resolveClaudeDriverPreference: () => driver,
130
+ closeClaudeSession: (chatId) => { closedSessions.push(chatId) },
131
+ maybeStartNextQueuedMessage: async (chatId) => { queueDrained.push(chatId) },
132
+ }
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // No active turn
137
+ // ---------------------------------------------------------------------------
138
+
139
+ describe("no active turn", () => {
140
+ test("resolvers are rejected and orchestrator signalled even with no active turn", async () => {
141
+ const rejectCalled: string[] = []
142
+ const orchestratorCancelled: string[] = []
143
+ const deps = makeDeps({ rejectCalled, orchestratorCancelled })
144
+ await cancelChat(deps, "chat-1")
145
+ expect(rejectCalled).toContain("chat-1")
146
+ expect(orchestratorCancelled).toContain("chat-1")
147
+ })
148
+
149
+ test("no messages appended when no active turn", async () => {
150
+ const appendedMessages: TranscriptEntry[] = []
151
+ const deps = makeDeps({ appendedMessages })
152
+ await cancelChat(deps, "chat-1")
153
+ expect(appendedMessages.length).toBe(0)
154
+ })
155
+
156
+ test("queue is not drained when no active turn", async () => {
157
+ const queueDrained: string[] = []
158
+ const deps = makeDeps({ queueDrained })
159
+ await cancelChat(deps, "chat-1")
160
+ expect(queueDrained.length).toBe(0)
161
+ })
162
+
163
+ test("closes and removes a draining stream if present", async () => {
164
+ let closed = false
165
+ const fakeTurn = makeFakeTurn({ close: () => { closed = true } })
166
+ const drainingStreams = new Map([["chat-1", { turn: fakeTurn }]])
167
+ const deps = makeDeps({ drainingStreams })
168
+ await cancelChat(deps, "chat-1")
169
+ expect(closed).toBe(true)
170
+ expect(drainingStreams.has("chat-1")).toBe(false)
171
+ })
172
+ })
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Active turn — concurrent cancel guard
176
+ // ---------------------------------------------------------------------------
177
+
178
+ describe("concurrent cancel guard", () => {
179
+ test("second call is no-op when cancelRequested is already true", async () => {
180
+ const appendedMessages: TranscriptEntry[] = []
181
+ const active = makeActiveTurn({ cancelRequested: true })
182
+ const activeTurns = new Map([["chat-1", active]])
183
+ const deps = makeDeps({ activeTurns, appendedMessages })
184
+ await cancelChat(deps, "chat-1")
185
+ // No messages should be appended because the guard returns early
186
+ expect(appendedMessages.length).toBe(0)
187
+ })
188
+ })
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Active turn — transcript entries
192
+ // ---------------------------------------------------------------------------
193
+
194
+ describe("transcript entries", () => {
195
+ let appendedMessages: TranscriptEntry[]
196
+ let turnCancelledFor: string[]
197
+ let activeTurns: Map<string, ActiveTurn>
198
+
199
+ beforeEach(() => {
200
+ appendedMessages = []
201
+ turnCancelledFor = []
202
+ const active = makeActiveTurn()
203
+ activeTurns = new Map([["chat-1", active]])
204
+ })
205
+
206
+ test("appends interrupted entry with hidden=undefined by default", async () => {
207
+ const deps = makeDeps({ activeTurns, appendedMessages, turnCancelledFor })
208
+ await cancelChat(deps, "chat-1")
209
+ const interrupted = appendedMessages.find((m) => m.kind === "interrupted")
210
+ expect(interrupted).toBeDefined()
211
+ expect((interrupted as { hidden?: boolean })?.hidden).toBeUndefined()
212
+ })
213
+
214
+ test("appends interrupted entry with hidden=true when hideInterrupted is set", async () => {
215
+ const deps = makeDeps({ activeTurns, appendedMessages, turnCancelledFor })
216
+ await cancelChat(deps, "chat-1", { hideInterrupted: true })
217
+ const interrupted = appendedMessages.find((m) => m.kind === "interrupted")
218
+ expect((interrupted as { hidden?: boolean })?.hidden).toBe(true)
219
+ })
220
+
221
+ test("records turn_cancelled via store.recordTurnCancelled", async () => {
222
+ const deps = makeDeps({ activeTurns, appendedMessages, turnCancelledFor })
223
+ await cancelChat(deps, "chat-1")
224
+ expect(turnCancelledFor).toContain("chat-1")
225
+ })
226
+
227
+ test("sets cancelRecorded and hasFinalResult on active turn", async () => {
228
+ const active = makeActiveTurn()
229
+ const turns = new Map([["chat-1", active]])
230
+ const deps = makeDeps({ activeTurns: turns, appendedMessages, turnCancelledFor })
231
+ await cancelChat(deps, "chat-1")
232
+ expect(active.cancelRecorded).toBe(true)
233
+ expect(active.hasFinalResult).toBe(true)
234
+ })
235
+
236
+ test("removes chat from activeTurns", async () => {
237
+ const active = makeActiveTurn()
238
+ const turns = new Map([["chat-1", active]])
239
+ const deps = makeDeps({ activeTurns: turns, appendedMessages, turnCancelledFor })
240
+ await cancelChat(deps, "chat-1")
241
+ expect(turns.has("chat-1")).toBe(false)
242
+ })
243
+ })
244
+
245
+ // ---------------------------------------------------------------------------
246
+ // Active turn — pending tool handling
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("pending tool", () => {
250
+ test("appends tool_result entry when pendingTool is set", async () => {
251
+ const appendedMessages: TranscriptEntry[] = []
252
+ const active = makeActiveTurn({
253
+ pendingTool: {
254
+ toolUseId: "tool-use-1",
255
+ tool: {
256
+ kind: "tool",
257
+ toolKind: "ask_user_question",
258
+ toolName: "AskUserQuestion",
259
+ toolId: "tool-use-1",
260
+ input: { questions: [] },
261
+ },
262
+ resolve: () => {},
263
+ },
264
+ })
265
+ const activeTurns = new Map([["chat-1", active]])
266
+ const deps = makeDeps({ activeTurns, appendedMessages })
267
+ await cancelChat(deps, "chat-1")
268
+ const toolResult = appendedMessages.find((m) => m.kind === "tool_result")
269
+ expect(toolResult).toBeDefined()
270
+ expect((toolResult as { toolId?: string })?.toolId).toBe("tool-use-1")
271
+ })
272
+
273
+ test("resolves pendingTool when provider=codex and toolKind=exit_plan_mode", async () => {
274
+ let resolved: unknown = undefined
275
+ const active = makeActiveTurn({
276
+ provider: "codex",
277
+ pendingTool: {
278
+ toolUseId: "tool-use-2",
279
+ tool: {
280
+ kind: "tool",
281
+ toolKind: "exit_plan_mode",
282
+ toolName: "ExitPlanMode",
283
+ toolId: "tool-use-2",
284
+ input: {},
285
+ },
286
+ resolve: (result) => { resolved = result },
287
+ },
288
+ })
289
+ const activeTurns = new Map([["chat-1", active]])
290
+ const deps = makeDeps({ activeTurns })
291
+ await cancelChat(deps, "chat-1")
292
+ expect(resolved).toMatchObject({ discarded: true })
293
+ })
294
+
295
+ test("does NOT resolve pendingTool when provider=claude (non-codex)", async () => {
296
+ let resolved = false
297
+ const active = makeActiveTurn({
298
+ provider: "claude",
299
+ pendingTool: {
300
+ toolUseId: "tool-use-3",
301
+ tool: {
302
+ kind: "tool",
303
+ toolKind: "exit_plan_mode",
304
+ toolName: "ExitPlanMode",
305
+ toolId: "tool-use-3",
306
+ input: {},
307
+ },
308
+ resolve: () => { resolved = true },
309
+ },
310
+ })
311
+ const activeTurns = new Map([["chat-1", active]])
312
+ const deps = makeDeps({ activeTurns })
313
+ await cancelChat(deps, "chat-1")
314
+ expect(resolved).toBe(false)
315
+ })
316
+ })
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Claude session prompt-seq drain
320
+ // ---------------------------------------------------------------------------
321
+
322
+ describe("claude session prompt-seq drain", () => {
323
+ test("removes claudePromptSeq from pendingPromptSeqs", async () => {
324
+ const session = makeSession({ pendingPromptSeqs: [1, 2, 3] })
325
+ const claudeSessions = new Map([["chat-1", session]])
326
+ const active = makeActiveTurn({ provider: "claude", claudePromptSeq: 2 })
327
+ const activeTurns = new Map([["chat-1", active]])
328
+ const deps = makeDeps({ activeTurns, claudeSessions })
329
+ await cancelChat(deps, "chat-1")
330
+ expect(session.pendingPromptSeqs).not.toContain(2)
331
+ expect(session.pendingPromptSeqs).toContain(1)
332
+ expect(session.pendingPromptSeqs).toContain(3)
333
+ })
334
+
335
+ test("increments cancelledResultPending", async () => {
336
+ const session = makeSession({ pendingPromptSeqs: [5], cancelledResultPending: 0 })
337
+ const claudeSessions = new Map([["chat-1", session]])
338
+ const active = makeActiveTurn({ provider: "claude", claudePromptSeq: 5 })
339
+ const activeTurns = new Map([["chat-1", active]])
340
+ const deps = makeDeps({ activeTurns, claudeSessions })
341
+ await cancelChat(deps, "chat-1")
342
+ expect(session.cancelledResultPending).toBe(1)
343
+ })
344
+
345
+ test("does not mutate session when provider is codex", async () => {
346
+ const session = makeSession({ pendingPromptSeqs: [9], cancelledResultPending: 0 })
347
+ const claudeSessions = new Map([["chat-1", session]])
348
+ const active = makeActiveTurn({ provider: "codex", claudePromptSeq: 9 })
349
+ const activeTurns = new Map([["chat-1", active]])
350
+ const deps = makeDeps({ activeTurns, claudeSessions })
351
+ await cancelChat(deps, "chat-1")
352
+ // codex provider → seq drain branch not entered
353
+ expect(session.pendingPromptSeqs).toContain(9)
354
+ expect(session.cancelledResultPending).toBe(0)
355
+ })
356
+
357
+ test("still increments cancelledResultPending when seq not in pending list", async () => {
358
+ const session = makeSession({ pendingPromptSeqs: [1, 3], cancelledResultPending: 0 })
359
+ const claudeSessions = new Map([["chat-1", session]])
360
+ const active = makeActiveTurn({ provider: "claude", claudePromptSeq: 99 })
361
+ const activeTurns = new Map([["chat-1", active]])
362
+ const deps = makeDeps({ activeTurns, claudeSessions })
363
+ await cancelChat(deps, "chat-1")
364
+ // seq 99 not in list — splice is a no-op, but cancelledResultPending still increments
365
+ expect(session.pendingPromptSeqs).toEqual([1, 3])
366
+ expect(session.cancelledResultPending).toBe(1)
367
+ })
368
+ })
369
+
370
+ // ---------------------------------------------------------------------------
371
+ // State change event
372
+ // ---------------------------------------------------------------------------
373
+
374
+ describe("state change", () => {
375
+ test("emitStateChange is called after removing activeTurn", async () => {
376
+ const stateChanges: string[] = []
377
+ const active = makeActiveTurn()
378
+ const activeTurns = new Map([["chat-1", active]])
379
+ const deps = makeDeps({ activeTurns, stateChanges })
380
+ await cancelChat(deps, "chat-1")
381
+ expect(stateChanges).toContain("chat-1")
382
+ })
383
+ })
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Interrupt and PTY session cleanup
387
+ // ---------------------------------------------------------------------------
388
+
389
+ describe("interrupt and close", () => {
390
+ test("calls interrupt() and close() on the active turn", async () => {
391
+ let interrupted = false
392
+ let closed = false
393
+ const turn = makeFakeTurn({
394
+ interrupt: async () => { interrupted = true },
395
+ close: () => { closed = true },
396
+ })
397
+ const active = makeActiveTurn({ turn })
398
+ const activeTurns = new Map([["chat-1", active]])
399
+ const deps = makeDeps({ activeTurns })
400
+ await cancelChat(deps, "chat-1")
401
+ expect(interrupted).toBe(true)
402
+ expect(closed).toBe(true)
403
+ })
404
+
405
+ test("does not throw when interrupt() rejects", async () => {
406
+ const turn = makeFakeTurn({
407
+ interrupt: async () => { throw new Error("interrupt failed") },
408
+ })
409
+ const active = makeActiveTurn({ turn })
410
+ const activeTurns = new Map([["chat-1", active]])
411
+ const deps = makeDeps({ activeTurns })
412
+ // Should not throw
413
+ await expect(cancelChat(deps, "chat-1")).resolves.toBeUndefined()
414
+ })
415
+
416
+ test("closes Claude session on PTY driver for claude provider", async () => {
417
+ const closedSessions: string[] = []
418
+ const session = makeSession({ chatId: "chat-1" })
419
+ const claudeSessions = new Map([["chat-1", session]])
420
+ const active = makeActiveTurn({ provider: "claude" })
421
+ const activeTurns = new Map([["chat-1", active]])
422
+ const deps = makeDeps({ activeTurns, claudeSessions, closedSessions, driver: "pty" })
423
+ await cancelChat(deps, "chat-1")
424
+ expect(closedSessions).toContain("chat-1")
425
+ })
426
+
427
+ test("does NOT close Claude session on SDK driver", async () => {
428
+ const closedSessions: string[] = []
429
+ const session = makeSession({ chatId: "chat-1" })
430
+ const claudeSessions = new Map([["chat-1", session]])
431
+ const active = makeActiveTurn({ provider: "claude" })
432
+ const activeTurns = new Map([["chat-1", active]])
433
+ const deps = makeDeps({ activeTurns, claudeSessions, closedSessions, driver: "sdk" })
434
+ await cancelChat(deps, "chat-1")
435
+ expect(closedSessions.length).toBe(0)
436
+ })
437
+
438
+ test("does NOT close Claude session for codex provider even on PTY", async () => {
439
+ const closedSessions: string[] = []
440
+ const session = makeSession({ chatId: "chat-1" })
441
+ const claudeSessions = new Map([["chat-1", session]])
442
+ const active = makeActiveTurn({ provider: "codex" })
443
+ const activeTurns = new Map([["chat-1", active]])
444
+ const deps = makeDeps({ activeTurns, claudeSessions, closedSessions, driver: "pty" })
445
+ await cancelChat(deps, "chat-1")
446
+ expect(closedSessions.length).toBe(0)
447
+ })
448
+ })
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Queue drain options
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("queue drain", () => {
455
+ test("drains queue by default", async () => {
456
+ const queueDrained: string[] = []
457
+ const active = makeActiveTurn()
458
+ const activeTurns = new Map([["chat-1", active]])
459
+ const deps = makeDeps({ activeTurns, queueDrained })
460
+ await cancelChat(deps, "chat-1")
461
+ expect(queueDrained).toContain("chat-1")
462
+ })
463
+
464
+ test("skips queue drain when skipQueueDrain is true", async () => {
465
+ const queueDrained: string[] = []
466
+ const active = makeActiveTurn()
467
+ const activeTurns = new Map([["chat-1", active]])
468
+ const deps = makeDeps({ activeTurns, queueDrained })
469
+ await cancelChat(deps, "chat-1", { skipQueueDrain: true })
470
+ expect(queueDrained.length).toBe(0)
471
+ })
472
+ })