@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,279 @@
1
+ /**
2
+ * ws-router-chat.ts
3
+ *
4
+ * WS command handlers for chat lifecycle operations:
5
+ * chat.create, chat.fork, chat.rename, chat.archive, chat.unarchive,
6
+ * chat.delete, chat.markRead, chat.setPolicyOverride, chat.setDraftProtection,
7
+ * chat.send, chat.cancel, chat.stopDraining, chat.loadHistory,
8
+ * chat.respondTool, chat.toolRequestAnswer,
9
+ * chat.respondSubagentTool, chat.cancelSubagentRun
10
+ *
11
+ * Extracted from ws-router.ts.
12
+ */
13
+ import { PROTOCOL_VERSION } from "../shared/types"
14
+ import type { ChatHistoryPage, StackBinding } from "../shared/types"
15
+ import { isRecord } from "../shared/errors"
16
+ import type { ClientCommand, ServerEnvelope } from "../shared/protocol"
17
+ import type { ChatPermissionPolicyOverride, ToolRequestDecision } from "../shared/permission-policy"
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Dep interfaces (duck-typed; avoids circular imports with ws-router.ts)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /** Subset of EventStore methods consumed by chat WS commands. */
24
+ export interface ChatStoreDep {
25
+ createChat(
26
+ projectId: string,
27
+ opts?: { stackId?: string | null; stackBindings?: StackBinding[] },
28
+ ): Promise<{ id: string }>
29
+ renameChat(chatId: string, title: string): Promise<void>
30
+ archiveChat(chatId: string): Promise<void>
31
+ unarchiveChat(chatId: string): Promise<void>
32
+ deleteChat(chatId: string): Promise<void>
33
+ setChatReadState(chatId: string, unread: boolean): Promise<void>
34
+ setChatPolicyOverride(chatId: string, policyOverride: ChatPermissionPolicyOverride | null): Promise<void>
35
+ getChat(chatId: string): { id: string } | null | undefined
36
+ getMessagesPageBefore(chatId: string, beforeCursor: string, limit: number): ChatHistoryPage
37
+ getToolRequest(toolRequestId: string): { chatId: string } | undefined | null
38
+ }
39
+
40
+ /** Subset of ToolCallbackService methods consumed by chat WS commands. */
41
+ export interface ChatToolCallbackServiceDep {
42
+ cancelAllForChat(chatId: string, reason: string): Promise<void>
43
+ answer(toolRequestId: string, decision: ToolRequestDecision): Promise<void>
44
+ }
45
+
46
+ /** Subset of AgentCoordinator methods consumed by chat WS commands. */
47
+ export interface ChatAgentDep {
48
+ send(command: Extract<ClientCommand, { type: "chat.send" }>): Promise<{ chatId?: string | null }>
49
+ forkChat(chatId: string): Promise<unknown>
50
+ cancel(chatId: string): Promise<void>
51
+ cancelAutoContinue(chatId: string, scheduleId: string, reason: string): Promise<void>
52
+ listLiveSchedules(chatId: string): Iterable<string>
53
+ closeChat(chatId: string): Promise<void>
54
+ stopDraining(chatId: string): Promise<void>
55
+ respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>): Promise<void>
56
+ respondSubagentTool(
57
+ command: Extract<ClientCommand, { type: "chat.respondSubagentTool" }>,
58
+ ): Promise<void>
59
+ cancelSubagentRun(
60
+ command: Extract<ClientCommand, { type: "chat.cancelSubagentRun" }>,
61
+ ): Promise<void>
62
+ getActiveTurnProfile(
63
+ chatId: string,
64
+ ): { traceId?: string | null; startedAt?: number | null } | null | undefined
65
+ toolCallbackService?: ChatToolCallbackServiceDep | null
66
+ }
67
+
68
+ /** Analytics subset consumed by chat WS commands. */
69
+ export interface ChatAnalyticsDep {
70
+ track(event: string): void
71
+ }
72
+
73
+ /** Full deps bundle for chat WS commands. */
74
+ export interface ChatCommandDeps {
75
+ store: ChatStoreDep
76
+ agent: ChatAgentDep
77
+ analytics: ChatAnalyticsDep
78
+ /**
79
+ * Mutate per-connection draft protection — wraps
80
+ * `ws.data.protectedDraftChatIds = new Set(chatIds)` at the call site.
81
+ */
82
+ setDraftProtection: (chatIds: string[]) => void
83
+ /**
84
+ * Send-to-starting profiling hook (no-op when profiling is disabled).
85
+ * Wraps `logSendToStartingProfile` at the call site.
86
+ */
87
+ logSendProfilingFn: (
88
+ traceId: string | null | undefined,
89
+ startedAt: number | null | undefined,
90
+ stage: string,
91
+ details?: Record<string, unknown>,
92
+ ) => void
93
+ /** Pre-bound to the current WebSocket; returns the byte count sent. */
94
+ send: (envelope: ServerEnvelope) => number
95
+ /** Broadcast a chat-specific snapshot + sidebar snapshot. */
96
+ broadcastChatAndSidebar: (chatId: string) => Promise<void>
97
+ /** Broadcast sidebar snapshot to all connected clients. */
98
+ broadcastSidebar: () => Promise<void>
99
+ /**
100
+ * Broadcast ALL snapshots to all connected clients.
101
+ * Used for the chat.setDraftProtection fall-through broadcast.
102
+ */
103
+ broadcastAll: () => Promise<void>
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Command dispatcher
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Handle one chat WS command.
112
+ *
113
+ * Returns `true` when the command was handled (caller should `return`).
114
+ * Returns `false` when the command type is outside this module's scope.
115
+ */
116
+ export async function handleChatCommand(
117
+ deps: ChatCommandDeps,
118
+ command: ClientCommand,
119
+ id: string,
120
+ ): Promise<boolean> {
121
+ const {
122
+ store,
123
+ agent,
124
+ analytics,
125
+ setDraftProtection,
126
+ logSendProfilingFn,
127
+ send,
128
+ broadcastChatAndSidebar,
129
+ broadcastSidebar,
130
+ broadcastAll,
131
+ } = deps
132
+
133
+ switch (command.type) {
134
+ case "chat.create": {
135
+ const chat = await store.createChat(command.projectId, {
136
+ stackId: command.stackId,
137
+ stackBindings: command.stackBindings,
138
+ })
139
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
140
+ analytics.track("chat_created")
141
+ await broadcastChatAndSidebar(chat.id)
142
+ return true
143
+ }
144
+ case "chat.fork": {
145
+ const result = await agent.forkChat(command.chatId)
146
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result })
147
+ await broadcastSidebar()
148
+ return true
149
+ }
150
+ case "chat.rename": {
151
+ await store.renameChat(command.chatId, command.title)
152
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
153
+ await broadcastChatAndSidebar(command.chatId)
154
+ return true
155
+ }
156
+ case "chat.archive": {
157
+ await store.archiveChat(command.chatId)
158
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
159
+ await broadcastSidebar()
160
+ return true
161
+ }
162
+ case "chat.unarchive": {
163
+ await store.unarchiveChat(command.chatId)
164
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
165
+ await broadcastChatAndSidebar(command.chatId)
166
+ return true
167
+ }
168
+ case "chat.delete": {
169
+ await agent.cancel(command.chatId)
170
+ for (const scheduleId of agent.listLiveSchedules(command.chatId)) {
171
+ await agent.cancelAutoContinue(command.chatId, scheduleId, "chat_deleted")
172
+ }
173
+ await agent.closeChat(command.chatId)
174
+ if (agent.toolCallbackService) {
175
+ await agent.toolCallbackService.cancelAllForChat(command.chatId, "chat_deleted")
176
+ }
177
+ await store.deleteChat(command.chatId)
178
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
179
+ analytics.track("chat_deleted")
180
+ await broadcastSidebar()
181
+ return true
182
+ }
183
+ case "chat.markRead": {
184
+ await store.setChatReadState(command.chatId, false)
185
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
186
+ await broadcastChatAndSidebar(command.chatId)
187
+ return true
188
+ }
189
+ case "chat.setPolicyOverride": {
190
+ await store.setChatPolicyOverride(command.chatId, command.policyOverride ?? null)
191
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
192
+ await broadcastChatAndSidebar(command.chatId)
193
+ return true
194
+ }
195
+ case "chat.setDraftProtection": {
196
+ setDraftProtection(command.chatIds)
197
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
198
+ // Preserves the original break→broadcastSnapshots fall-through behavior.
199
+ await broadcastAll()
200
+ return true
201
+ }
202
+ case "chat.send": {
203
+ const result = await agent.send(command)
204
+ const profile = command.clientTraceId && result.chatId
205
+ ? agent.getActiveTurnProfile(result.chatId)
206
+ : null
207
+ logSendProfilingFn(profile?.traceId ?? command.clientTraceId, profile?.startedAt, "ws.chat_send_ack", {
208
+ chatId: result.chatId ?? null,
209
+ })
210
+ const payloadBytes = send({ v: PROTOCOL_VERSION, type: "ack", id, result })
211
+ logSendProfilingFn(profile?.traceId ?? command.clientTraceId, profile?.startedAt, "ws.chat_send_ack_completed", {
212
+ chatId: result.chatId ?? null,
213
+ payloadBytes,
214
+ })
215
+ return true
216
+ }
217
+ case "chat.cancel": {
218
+ await agent.cancel(command.chatId)
219
+ // Resolve any open ask-style tool-callback prompts for this chat
220
+ // so the model's tool_use does not hang on a stranded pending. The
221
+ // session-close path no longer fires this cascade because it also
222
+ // ran on transparent respawns (rotation / idle sweep) — see
223
+ // makeClaudeSessionHandle.close() in agent.ts.
224
+ if (agent.toolCallbackService) {
225
+ await agent.toolCallbackService.cancelAllForChat(command.chatId, "chat_cancelled")
226
+ }
227
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
228
+ return true
229
+ }
230
+ case "chat.stopDraining": {
231
+ await agent.stopDraining(command.chatId)
232
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
233
+ return true
234
+ }
235
+ case "chat.loadHistory": {
236
+ const chat = store.getChat(command.chatId)
237
+ if (!chat) throw new Error("Chat not found")
238
+ const page = store.getMessagesPageBefore(command.chatId, command.beforeCursor, command.limit)
239
+ send({ v: PROTOCOL_VERSION, type: "ack", id, result: page })
240
+ return true
241
+ }
242
+ case "chat.respondTool": {
243
+ await agent.respondTool(command)
244
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
245
+ return true
246
+ }
247
+ case "chat.toolRequestAnswer": {
248
+ const toolCallbackSvc = agent.toolCallbackService
249
+ if (!toolCallbackSvc) throw new Error("tool callback service unavailable")
250
+ const validKinds = new Set(["allow", "deny", "answer"])
251
+ if (
252
+ !isRecord(command.decision) ||
253
+ !validKinds.has(typeof command.decision.kind === "string" ? command.decision.kind : "")
254
+ ) {
255
+ throw new Error("Invalid tool request decision kind")
256
+ }
257
+ const existing = store.getToolRequest(command.toolRequestId)
258
+ if (!existing || existing.chatId !== command.chatId) {
259
+ throw new Error("Tool request does not belong to this chat")
260
+ }
261
+ await toolCallbackSvc.answer(command.toolRequestId, command.decision)
262
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
263
+ await broadcastChatAndSidebar(command.chatId)
264
+ return true
265
+ }
266
+ case "chat.respondSubagentTool": {
267
+ await agent.respondSubagentTool(command)
268
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
269
+ return true
270
+ }
271
+ case "chat.cancelSubagentRun": {
272
+ await agent.cancelSubagentRun(command)
273
+ send({ v: PROTOCOL_VERSION, type: "ack", id })
274
+ return true
275
+ }
276
+ default:
277
+ return false
278
+ }
279
+ }
@@ -0,0 +1,283 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ buildFallbackDiffStore,
4
+ buildFallbackLlmProvider,
5
+ buildInitialAppSettingsSnapshot,
6
+ buildResolvedAppSettings,
7
+ mergeAppSettingsPatch,
8
+ } from "./ws-router-defaults"
9
+ import type { AppSettingsSnapshot, SubagentInput } from "../shared/types"
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+
15
+ function makeSnapshot(): AppSettingsSnapshot {
16
+ return buildInitialAppSettingsSnapshot()
17
+ }
18
+
19
+ const BASE_SUBAGENT_INPUT: SubagentInput = {
20
+ name: "Bot",
21
+ description: "",
22
+ provider: "claude",
23
+ model: "claude-opus-4-7",
24
+ systemPrompt: "",
25
+ modelOptions: { reasoningEffort: "high", contextWindow: "200k" },
26
+ contextScope: "full-transcript",
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // mergeAppSettingsPatch
31
+ // ---------------------------------------------------------------------------
32
+
33
+ describe("mergeAppSettingsPatch", () => {
34
+ test("subagents.create appends a new subagent with generated id and trims name", () => {
35
+ const snapshot = makeSnapshot()
36
+ const result = mergeAppSettingsPatch(snapshot, {
37
+ subagents: {
38
+ create: { ...BASE_SUBAGENT_INPUT, name: " My Bot ", triggerMode: "manual" },
39
+ },
40
+ })
41
+ expect(result.subagents).toHaveLength(1)
42
+ const s = result.subagents[0]
43
+ expect(s).toBeDefined()
44
+ if (!s) return
45
+ expect(s.name).toBe("My Bot") // trimmed
46
+ expect(s.triggerMode).toBe("manual")
47
+ expect(typeof s.id).toBe("string")
48
+ expect(s.id.length).toBeGreaterThan(0)
49
+ })
50
+
51
+ test("subagents.create defaults triggerMode to 'auto' when omitted", () => {
52
+ const snapshot = makeSnapshot()
53
+ const result = mergeAppSettingsPatch(snapshot, {
54
+ subagents: { create: BASE_SUBAGENT_INPUT },
55
+ })
56
+ expect(result.subagents[0]?.triggerMode).toBe("auto")
57
+ })
58
+
59
+ test("subagents.update patches matching subagent name", () => {
60
+ const withSubagent = mergeAppSettingsPatch(makeSnapshot(), {
61
+ subagents: { create: BASE_SUBAGENT_INPUT },
62
+ })
63
+ const id = withSubagent.subagents[0]!.id
64
+ const updated = mergeAppSettingsPatch(withSubagent, {
65
+ subagents: { update: { id, patch: { name: "New Name" } } },
66
+ })
67
+ expect(updated.subagents[0]?.name).toBe("New Name")
68
+ })
69
+
70
+ test("subagents.delete removes the matching subagent", () => {
71
+ const withSubagent = mergeAppSettingsPatch(makeSnapshot(), {
72
+ subagents: { create: BASE_SUBAGENT_INPUT },
73
+ })
74
+ const id = withSubagent.subagents[0]!.id
75
+ const result = mergeAppSettingsPatch(withSubagent, { subagents: { delete: { id } } })
76
+ expect(result.subagents).toHaveLength(0)
77
+ })
78
+
79
+ test("terminal and editor are deep-merged (unset fields preserved)", () => {
80
+ const snapshot = makeSnapshot()
81
+ const result = mergeAppSettingsPatch(snapshot, {
82
+ terminal: { scrollbackLines: 2_000 },
83
+ editor: { preset: "vscode" },
84
+ })
85
+ expect(result.terminal.scrollbackLines).toBe(2_000)
86
+ expect(result.terminal.minColumnWidth).toBe(450) // preserved from base
87
+ expect(result.editor.preset).toBe("vscode")
88
+ expect(result.editor.commandTemplate).toBe("cursor {path}") // preserved from base
89
+ })
90
+
91
+ test("providerDefaults.claude modelOptions are deep-merged", () => {
92
+ const snapshot = makeSnapshot()
93
+ const result = mergeAppSettingsPatch(snapshot, {
94
+ providerDefaults: {
95
+ claude: { modelOptions: { reasoningEffort: "low", contextWindow: "200k" } },
96
+ },
97
+ })
98
+ expect(result.providerDefaults.claude.modelOptions.reasoningEffort).toBe("low")
99
+ // contextWindow preserved since we included it in the patch
100
+ expect(result.providerDefaults.claude.modelOptions.contextWindow).toBe("200k")
101
+ // unrelated provider unchanged
102
+ expect(result.providerDefaults.codex.modelOptions.reasoningEffort).toBe("high")
103
+ })
104
+
105
+ test("claudeDriver lifecycle is deep-merged and preference is preserved", () => {
106
+ const snapshot = makeSnapshot()
107
+ const result = mergeAppSettingsPatch(snapshot, {
108
+ claudeDriver: { lifecycle: { idleTimeoutMs: 9_000 } },
109
+ })
110
+ expect(result.claudeDriver.preference).toBe(snapshot.claudeDriver.preference)
111
+ expect(result.claudeDriver.lifecycle).toMatchObject({ idleTimeoutMs: 9_000 })
112
+ })
113
+
114
+ test("subagentRuntime.defaultLoopSubagentId null clears the value", () => {
115
+ const snapshot = {
116
+ ...makeSnapshot(),
117
+ subagentRuntime: { runTimeoutMs: 600_000, defaultLoopSubagentId: "abc" },
118
+ }
119
+ const result = mergeAppSettingsPatch(snapshot, {
120
+ subagentRuntime: { defaultLoopSubagentId: null },
121
+ })
122
+ expect(result.subagentRuntime.defaultLoopSubagentId).toBeNull()
123
+ })
124
+
125
+ test("returns a new snapshot object (does not mutate input)", () => {
126
+ const snapshot = makeSnapshot()
127
+ const result = mergeAppSettingsPatch(snapshot, { analyticsEnabled: false })
128
+ expect(result).not.toBe(snapshot)
129
+ expect(snapshot.analyticsEnabled).toBe(true) // original unchanged
130
+ expect(result.analyticsEnabled).toBe(false)
131
+ })
132
+ })
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // buildFallbackDiffStore
136
+ // ---------------------------------------------------------------------------
137
+
138
+ describe("buildFallbackDiffStore", () => {
139
+ test("returns an object with all required DiffStore methods", () => {
140
+ const store = buildFallbackDiffStore()
141
+ const methods = [
142
+ "getProjectSnapshot", "refreshSnapshot", "initializeGit",
143
+ "getGitHubPublishInfo", "checkGitHubRepoAvailability", "publishToGitHub",
144
+ "listBranches", "previewMergeBranch", "mergeBranch", "syncBranch",
145
+ "checkoutBranch", "createBranch", "generateCommitMessage", "commitFiles",
146
+ "discardFile", "ignoreFile", "readPatch",
147
+ ] as const
148
+ for (const m of methods) {
149
+ expect(typeof store[m]).toBe("function")
150
+ }
151
+ })
152
+
153
+ test("getProjectSnapshot returns an unknown status with empty files", () => {
154
+ const store = buildFallbackDiffStore()
155
+ const snap = store.getProjectSnapshot("any-project-id")
156
+ expect(snap.status).toBe("unknown")
157
+ expect(snap.files).toEqual([])
158
+ })
159
+
160
+ test("listBranches resolves with empty arrays and unavailable pullRequestsStatus", async () => {
161
+ const result = await buildFallbackDiffStore().listBranches()
162
+ expect(result.recent).toEqual([])
163
+ expect(result.pullRequestsStatus).toBe("unavailable")
164
+ })
165
+
166
+ test("readPatch resolves with empty patch string", async () => {
167
+ const result = await buildFallbackDiffStore().readPatch()
168
+ expect(result.patch).toBe("")
169
+ })
170
+ })
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // buildFallbackLlmProvider
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("buildFallbackLlmProvider", () => {
177
+ test("returns an object with read, write, validate methods", () => {
178
+ const provider = buildFallbackLlmProvider()
179
+ expect(typeof provider.read).toBe("function")
180
+ expect(typeof provider.write).toBe("function")
181
+ expect(typeof provider.validate).toBe("function")
182
+ })
183
+
184
+ test("read resolves with disabled openai shape", async () => {
185
+ const snap = await buildFallbackLlmProvider().read()
186
+ expect(snap.provider).toBe("openai")
187
+ expect(snap.enabled).toBe(false)
188
+ expect(snap.resolvedBaseUrl).toBe("https://api.openai.com/v1")
189
+ })
190
+
191
+ test("write computes resolvedBaseUrl for openrouter", async () => {
192
+ const snap = await buildFallbackLlmProvider().write({
193
+ provider: "openrouter",
194
+ apiKey: "k",
195
+ model: "m",
196
+ baseUrl: "",
197
+ })
198
+ expect(snap.resolvedBaseUrl).toBe("https://openrouter.ai/api/v1")
199
+ })
200
+
201
+ test("write uses custom baseUrl for 'custom' provider", async () => {
202
+ const snap = await buildFallbackLlmProvider().write({
203
+ provider: "custom",
204
+ apiKey: "",
205
+ model: "m",
206
+ baseUrl: "https://my-endpoint.example.com/v1",
207
+ })
208
+ expect(snap.resolvedBaseUrl).toBe("https://my-endpoint.example.com/v1")
209
+ })
210
+
211
+ test("validate always returns ok:false", async () => {
212
+ const result = await buildFallbackLlmProvider().validate({
213
+ provider: "openai",
214
+ apiKey: "k",
215
+ model: "m",
216
+ baseUrl: "",
217
+ })
218
+ expect(result.ok).toBe(false)
219
+ })
220
+ })
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // buildResolvedAppSettings
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe("buildResolvedAppSettings", () => {
227
+ test("getSnapshot returns built-in fallback when no appSettings provided", () => {
228
+ const resolved = buildResolvedAppSettings(undefined)
229
+ const snap = resolved.getSnapshot()
230
+ expect(snap.analyticsEnabled).toBe(true)
231
+ expect(snap.subagents).toEqual([])
232
+ expect(snap.customMcpServers).toEqual([])
233
+ })
234
+
235
+ test("writePatch mutates the in-memory fallback when no appSettings", async () => {
236
+ const resolved = buildResolvedAppSettings(undefined)
237
+ expect(resolved.getSnapshot().analyticsEnabled).toBe(true)
238
+ const after = await resolved.writePatch({ analyticsEnabled: false })
239
+ expect(after.analyticsEnabled).toBe(false)
240
+ // getSnapshot reflects the mutation
241
+ expect(resolved.getSnapshot().analyticsEnabled).toBe(false)
242
+ })
243
+
244
+ test("onChange returns callable noop when no appSettings", () => {
245
+ const resolved = buildResolvedAppSettings(undefined)
246
+ const dispose = resolved.onChange(() => {})
247
+ expect(typeof dispose).toBe("function")
248
+ expect(() => dispose()).not.toThrow()
249
+ })
250
+
251
+ test("getSnapshot delegates to real appSettings when provided", () => {
252
+ const mockSnapshot = { ...makeSnapshot(), analyticsEnabled: false }
253
+ const fakeManager = { getSnapshot: () => mockSnapshot, write: async () => mockSnapshot }
254
+ const resolved = buildResolvedAppSettings(fakeManager)
255
+ expect(resolved.getSnapshot().analyticsEnabled).toBe(false)
256
+ })
257
+
258
+ test("createSubagent falls back to writePatch when manager lacks createSubagent", async () => {
259
+ const resolved = buildResolvedAppSettings(undefined)
260
+ const result = await resolved.createSubagent({ ...BASE_SUBAGENT_INPUT, name: "FallbackBot" })
261
+ // Should be the newly created subagent, not a NOT_FOUND error
262
+ if ("code" in result) {
263
+ throw new Error(`Expected subagent but got error: ${result.message}`)
264
+ }
265
+ expect(result.name).toBe("FallbackBot")
266
+ expect(resolved.getSnapshot().subagents).toHaveLength(1)
267
+ })
268
+
269
+ test("write delegates to real appSettings when provided", async () => {
270
+ let written: { analyticsEnabled: boolean } | null = null
271
+ const mockSnapshot = makeSnapshot()
272
+ const fakeManager = {
273
+ getSnapshot: () => mockSnapshot,
274
+ write: async (value: { analyticsEnabled: boolean }) => {
275
+ written = value
276
+ return mockSnapshot
277
+ },
278
+ }
279
+ const resolved = buildResolvedAppSettings(fakeManager)
280
+ await resolved.write({ analyticsEnabled: false })
281
+ expect(written!).toEqual({ analyticsEnabled: false })
282
+ })
283
+ })