@cuongtran001/kanna 1.3.0 → 1.4.0

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 (200) hide show
  1. package/dist/client/assets/{arc-CAysfmX5.js → arc-BHj370W7.js} +1 -1
  2. package/dist/client/assets/{architectureDiagram-3BPJPVTR-BKqRYsm4.js → architectureDiagram-3BPJPVTR-1G88q1PY.js} +1 -1
  3. package/dist/client/assets/{blockDiagram-GPEHLZMM-Ce7Eorec.js → blockDiagram-GPEHLZMM-D0L3Co5v.js} +1 -1
  4. package/dist/client/assets/{c4Diagram-AAUBKEIU-CJ77zauH.js → c4Diagram-AAUBKEIU-CQtERl3H.js} +1 -1
  5. package/dist/client/assets/channel-BP9IMEvd.js +1 -0
  6. package/dist/client/assets/{chunk-2J33WTMH-Rk_3zLxJ.js → chunk-2J33WTMH-DxOwt09f.js} +1 -1
  7. package/dist/client/assets/{chunk-4BX2VUAB-DPIz6Nvf.js → chunk-4BX2VUAB-Wry66y9R.js} +1 -1
  8. package/dist/client/assets/{chunk-55IACEB6-D-TkbGh2.js → chunk-55IACEB6-D6p-Hi4x.js} +1 -1
  9. package/dist/client/assets/{chunk-727SXJPM-B4NOSIZV.js → chunk-727SXJPM-CRQZt23N.js} +1 -1
  10. package/dist/client/assets/{chunk-AQP2D5EJ-Cm_TGLYm.js → chunk-AQP2D5EJ-BzgTKOM9.js} +1 -1
  11. package/dist/client/assets/{chunk-FMBD7UC4-Bu45BUVh.js → chunk-FMBD7UC4-CKfD-91C.js} +1 -1
  12. package/dist/client/assets/{chunk-ND2GUHAM-C1Bw8_gg.js → chunk-ND2GUHAM-a71B27wc.js} +1 -1
  13. package/dist/client/assets/{chunk-QZHKN3VN-DueG15o0.js → chunk-QZHKN3VN-vv9gJwx0.js} +1 -1
  14. package/dist/client/assets/classDiagram-4FO5ZUOK--W6Wt7FF.js +1 -0
  15. package/dist/client/assets/classDiagram-v2-Q7XG4LA2--W6Wt7FF.js +1 -0
  16. package/dist/client/assets/{cose-bilkent-S5V4N54A-YgpMurXf.js → cose-bilkent-S5V4N54A-Cu-dCCaI.js} +1 -1
  17. package/dist/client/assets/{dagre-BM42HDAG-Dop7Oq67.js → dagre-BM42HDAG-D8coOWLK.js} +1 -1
  18. package/dist/client/assets/{diagram-2AECGRRQ-unMfBEJH.js → diagram-2AECGRRQ-NIwZ65jE.js} +1 -1
  19. package/dist/client/assets/{diagram-5GNKFQAL-D9EJ9qMI.js → diagram-5GNKFQAL-d46aE_le.js} +1 -1
  20. package/dist/client/assets/{diagram-KO2AKTUF-C5IykOIR.js → diagram-KO2AKTUF-xdm2UxWc.js} +1 -1
  21. package/dist/client/assets/{diagram-LMA3HP47-DJAuPngz.js → diagram-LMA3HP47-hDbLwC5K.js} +1 -1
  22. package/dist/client/assets/{diagram-OG6HWLK6-B3wHFtS-.js → diagram-OG6HWLK6-MHUGG7ki.js} +1 -1
  23. package/dist/client/assets/{erDiagram-TEJ5UH35-Dm7kt9o5.js → erDiagram-TEJ5UH35-Ro6n9X70.js} +1 -1
  24. package/dist/client/assets/{flowDiagram-I6XJVG4X-s9Lds3ny.js → flowDiagram-I6XJVG4X-DOZqvRMu.js} +1 -1
  25. package/dist/client/assets/{ganttDiagram-6RSMTGT7-CRQNYdAf.js → ganttDiagram-6RSMTGT7-Bn2NXYIy.js} +1 -1
  26. package/dist/client/assets/{gitGraphDiagram-PVQCEYII-DhYjK4Jb.js → gitGraphDiagram-PVQCEYII-DK44bfbC.js} +1 -1
  27. package/dist/client/assets/{index-C0U2kwuB.js → index-CWJpHyQy.js} +115 -115
  28. package/dist/client/assets/{index-CLsOWXff.css → index-Cyzd4zZx.css} +1 -1
  29. package/dist/client/assets/{index-CbSY9HfS.js → index-DU56d4oC.js} +1 -1
  30. package/dist/client/assets/{infoDiagram-5YYISTIA-CRKJ0PSH.js → infoDiagram-5YYISTIA-C2tW_Dwj.js} +1 -1
  31. package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-CEAkB0IY.js → ishikawaDiagram-YF4QCWOH-C__4Z1wz.js} +1 -1
  32. package/dist/client/assets/{journeyDiagram-JHISSGLW-CyOK5_DG.js → journeyDiagram-JHISSGLW-Bbn5EawR.js} +1 -1
  33. package/dist/client/assets/{kanban-definition-UN3LZRKU-BfKjiJSo.js → kanban-definition-UN3LZRKU-RgS5IRWl.js} +1 -1
  34. package/dist/client/assets/{linear-DLR6miy3.js → linear-DfI0qBO_.js} +1 -1
  35. package/dist/client/assets/{mermaid.core-D7AdC_lE.js → mermaid.core-BiN10VHc.js} +4 -4
  36. package/dist/client/assets/{mindmap-definition-RKZ34NQL-zYyJtDSa.js → mindmap-definition-RKZ34NQL-BQhibhh3.js} +1 -1
  37. package/dist/client/assets/{pieDiagram-4H26LBE5-BgP8PFlo.js → pieDiagram-4H26LBE5-BEfLRn7a.js} +1 -1
  38. package/dist/client/assets/{quadrantDiagram-W4KKPZXB-Di8h7J96.js → quadrantDiagram-W4KKPZXB-ba641Y2x.js} +1 -1
  39. package/dist/client/assets/{requirementDiagram-4Y6WPE33-DuTzFl3R.js → requirementDiagram-4Y6WPE33-BGtl-tVO.js} +1 -1
  40. package/dist/client/assets/{sankeyDiagram-5OEKKPKP-DBRoMm_T.js → sankeyDiagram-5OEKKPKP-DyziaSFr.js} +1 -1
  41. package/dist/client/assets/{sequenceDiagram-3UESZ5HK-Bmb-1HUN.js → sequenceDiagram-3UESZ5HK-BfYBAYga.js} +1 -1
  42. package/dist/client/assets/{stateDiagram-AJRCARHV-fnv5ayn-.js → stateDiagram-AJRCARHV-BfUfmLYA.js} +1 -1
  43. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CdGcgYEq.js +1 -0
  44. package/dist/client/assets/{timeline-definition-PNZ67QCA-ByWRdpQX.js → timeline-definition-PNZ67QCA-T6z7dEFl.js} +1 -1
  45. package/dist/client/assets/{vennDiagram-CIIHVFJN--2WGpVXW.js → vennDiagram-CIIHVFJN-BAwQbFFd.js} +1 -1
  46. package/dist/client/assets/{wardley-L42UT6IY-24zlnzxK.js → wardley-L42UT6IY-DF1cRKsP.js} +1 -1
  47. package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Cllvqb3K.js → wardleyDiagram-YWT4CUSO-BRtAZ8za.js} +1 -1
  48. package/dist/client/assets/{xychartDiagram-2RQKCTM6-ZcdC6nPE.js → xychartDiagram-2RQKCTM6-Brb-q76h.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.test.ts +22 -6
  55. package/src/server/agent.ts +35 -4987
  56. package/src/server/chat-op-log.test.ts +63 -0
  57. package/src/server/chat-op-log.ts +71 -0
  58. package/src/server/chat-ops-diff.test.ts +120 -0
  59. package/src/server/chat-ops-diff.ts +97 -0
  60. package/src/server/chat-ops-parity.test.ts +140 -0
  61. package/src/server/claude-autocontinue-commands.test.ts +494 -0
  62. package/src/server/claude-autocontinue-commands.ts +256 -0
  63. package/src/server/claude-cancel-handler.test.ts +472 -0
  64. package/src/server/claude-cancel-handler.ts +241 -0
  65. package/src/server/claude-chat-management.test.ts +395 -0
  66. package/src/server/claude-chat-management.ts +251 -0
  67. package/src/server/claude-harness-stream.test.ts +273 -0
  68. package/src/server/claude-harness-stream.ts +158 -0
  69. package/src/server/claude-loop-orch-commands.test.ts +579 -0
  70. package/src/server/claude-loop-orch-commands.ts +466 -0
  71. package/src/server/claude-message-normalizer.test.ts +213 -0
  72. package/src/server/claude-message-normalizer.ts +436 -0
  73. package/src/server/claude-prompt-helpers.test.ts +248 -0
  74. package/src/server/claude-prompt-helpers.ts +139 -0
  75. package/src/server/claude-sdk-queue.test.ts +169 -0
  76. package/src/server/claude-sdk-queue.ts +82 -0
  77. package/src/server/claude-send-command.test.ts +414 -0
  78. package/src/server/claude-send-command.ts +420 -0
  79. package/src/server/claude-session-config-helpers.test.ts +279 -0
  80. package/src/server/claude-session-config-helpers.ts +151 -0
  81. package/src/server/claude-session-config.test.ts +250 -0
  82. package/src/server/claude-session-config.ts +159 -0
  83. package/src/server/claude-session-error-handler.test.ts +498 -0
  84. package/src/server/claude-session-error-handler.ts +361 -0
  85. package/src/server/claude-session-lifecycle.test.ts +669 -0
  86. package/src/server/claude-session-lifecycle.ts +283 -0
  87. package/src/server/claude-session-rebuild.test.ts +224 -0
  88. package/src/server/claude-session-rebuild.ts +101 -0
  89. package/src/server/claude-session-runner.test.ts +523 -0
  90. package/src/server/claude-session-runner.ts +521 -0
  91. package/src/server/claude-session-spawner.test.ts +401 -0
  92. package/src/server/claude-session-spawner.ts +401 -0
  93. package/src/server/claude-session-start.test.ts +205 -0
  94. package/src/server/claude-session-start.ts +277 -0
  95. package/src/server/claude-session-state-queries.test.ts +344 -0
  96. package/src/server/claude-session-state-queries.ts +160 -0
  97. package/src/server/claude-session-state.ts +104 -0
  98. package/src/server/claude-slash-commands.test.ts +277 -0
  99. package/src/server/claude-slash-commands.ts +234 -0
  100. package/src/server/claude-spawn-helpers.test.ts +221 -0
  101. package/src/server/claude-spawn-helpers.ts +179 -0
  102. package/src/server/claude-steer-log.test.ts +243 -0
  103. package/src/server/claude-steer-log.ts +58 -0
  104. package/src/server/claude-subagent-tool-response.test.ts +213 -0
  105. package/src/server/claude-subagent-tool-response.ts +165 -0
  106. package/src/server/claude-subagent-wiring.test.ts +263 -0
  107. package/src/server/claude-subagent-wiring.ts +324 -0
  108. package/src/server/claude-tool-respond.test.ts +321 -0
  109. package/src/server/claude-tool-respond.ts +142 -0
  110. package/src/server/claude-turn-runner.test.ts +310 -0
  111. package/src/server/claude-turn-runner.ts +228 -0
  112. package/src/server/claude-turn-starter.test.ts +266 -0
  113. package/src/server/claude-turn-starter.ts +566 -0
  114. package/src/server/claude-usage-math.test.ts +169 -0
  115. package/src/server/claude-usage-math.ts +132 -0
  116. package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
  117. package/src/server/diff-store-branch-ops.adapter.ts +602 -0
  118. package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
  119. package/src/server/diff-store-commit-ops.adapter.ts +258 -0
  120. package/src/server/diff-store-errors.ts +85 -0
  121. package/src/server/diff-store-file-ops.adapter.ts +212 -0
  122. package/src/server/diff-store-git-branch.adapter.ts +686 -0
  123. package/src/server/diff-store-io.adapter.ts +27 -0
  124. package/src/server/diff-store-parse.test.ts +212 -0
  125. package/src/server/diff-store-parse.ts +122 -0
  126. package/src/server/diff-store-state.ts +82 -0
  127. package/src/server/diff-store.ts +76 -1854
  128. package/src/server/event-store-apply.test.ts +280 -0
  129. package/src/server/event-store-apply.ts +132 -0
  130. package/src/server/event-store-chat-lifecycle.test.ts +408 -0
  131. package/src/server/event-store-chat-lifecycle.ts +441 -0
  132. package/src/server/event-store-entity-write.ts +356 -0
  133. package/src/server/event-store-helpers.test.ts +230 -0
  134. package/src/server/event-store-helpers.ts +235 -0
  135. package/src/server/event-store-init.ts +276 -0
  136. package/src/server/event-store-messages.adapter.test.ts +532 -0
  137. package/src/server/event-store-messages.adapter.ts +455 -0
  138. package/src/server/event-store-orch-subscription.ts +124 -0
  139. package/src/server/event-store-orch.test.ts +462 -0
  140. package/src/server/event-store-orch.ts +312 -0
  141. package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
  142. package/src/server/event-store-peripheral-events.adapter.ts +105 -0
  143. package/src/server/event-store-snapshot.test.ts +597 -0
  144. package/src/server/event-store-snapshot.ts +658 -0
  145. package/src/server/event-store-subagent.test.ts +408 -0
  146. package/src/server/event-store-subagent.ts +198 -0
  147. package/src/server/event-store-tool-requests.test.ts +210 -0
  148. package/src/server/event-store-tool-requests.ts +131 -0
  149. package/src/server/event-store-transcript-write.adapter.ts +291 -0
  150. package/src/server/event-store-write-ops.test.ts +554 -0
  151. package/src/server/event-store-write-ops.ts +529 -0
  152. package/src/server/event-store.test.ts +75 -0
  153. package/src/server/event-store.ts +257 -2285
  154. package/src/server/events.ts +1 -1
  155. package/src/server/harness-types.ts +15 -1
  156. package/src/server/oauth-errors.ts +19 -0
  157. package/src/server/storage/backend.ts +7 -0
  158. package/src/server/storage/fs-storage.adapter.ts +21 -1
  159. package/src/server/storage/in-memory-storage.ts +12 -0
  160. package/src/server/ws-router-agent-ctrl.test.ts +187 -0
  161. package/src/server/ws-router-agent-ctrl.ts +137 -0
  162. package/src/server/ws-router-broadcast.ts +560 -0
  163. package/src/server/ws-router-chat.test.ts +461 -0
  164. package/src/server/ws-router-chat.ts +279 -0
  165. package/src/server/ws-router-defaults.test.ts +283 -0
  166. package/src/server/ws-router-defaults.ts +434 -0
  167. package/src/server/ws-router-diff.test.ts +167 -0
  168. package/src/server/ws-router-diff.ts +250 -0
  169. package/src/server/ws-router-envelope.ts +360 -0
  170. package/src/server/ws-router-misc.test.ts +422 -0
  171. package/src/server/ws-router-misc.ts +265 -0
  172. package/src/server/ws-router-orch.test.ts +190 -0
  173. package/src/server/ws-router-orch.ts +93 -0
  174. package/src/server/ws-router-project.test.ts +403 -0
  175. package/src/server/ws-router-project.ts +256 -0
  176. package/src/server/ws-router-push.test.ts +193 -0
  177. package/src/server/ws-router-push.ts +120 -0
  178. package/src/server/ws-router-settings.test.ts +173 -0
  179. package/src/server/ws-router-settings.ts +416 -0
  180. package/src/server/ws-router-skills.test.ts +194 -0
  181. package/src/server/ws-router-skills.ts +204 -0
  182. package/src/server/ws-router-utils.ts +238 -0
  183. package/src/server/ws-router.test.ts +90 -0
  184. package/src/server/ws-router.ts +340 -2254
  185. package/src/shared/app-settings-types.ts +365 -0
  186. package/src/shared/chat-ops.test.ts +138 -0
  187. package/src/shared/chat-ops.ts +71 -0
  188. package/src/shared/core-types.ts +27 -0
  189. package/src/shared/git-diff-types.ts +165 -0
  190. package/src/shared/mcp-types.ts +105 -0
  191. package/src/shared/protocol.ts +3 -2
  192. package/src/shared/provider-model-types.ts +437 -0
  193. package/src/shared/subagent-types.ts +198 -0
  194. package/src/shared/tool-call-types.ts +314 -0
  195. package/src/shared/transcript-types.ts +270 -0
  196. package/src/shared/types.ts +31 -1711
  197. package/dist/client/assets/channel-KQhRwDzJ.js +0 -1
  198. package/dist/client/assets/classDiagram-4FO5ZUOK-ChxT1GNq.js +0 -1
  199. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ChxT1GNq.js +0 -1
  200. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-D4X-ZnLV.js +0 -1
@@ -0,0 +1,554 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { ToolRequest } from "../shared/permission-policy"
3
+ import type { ChatRecord, ProjectRecord, StackRecord } from "./events"
4
+ import {
5
+ buildAddProjectToStackEvent,
6
+ buildArchiveChatEvent,
7
+ buildChatProviderEvent,
8
+ buildChatReadStateEvent,
9
+ buildChatSourceHashEvent,
10
+ buildCompactFailuresEvent,
11
+ buildCreateChatEvent,
12
+ buildCreateStackEvent,
13
+ buildEnqueueMessageResult,
14
+ buildOpenProjectResult,
15
+ buildPendingForkSessionTokenEvent,
16
+ buildPlanModeEvent,
17
+ buildPutToolRequestEvent,
18
+ buildRemoveProjectEvent,
19
+ buildRemoveProjectFromStackEvent,
20
+ buildRemoveQueuedMessageEvent,
21
+ buildRemoveStackEvent,
22
+ buildRenameStackEvent,
23
+ buildRenameChatEvent,
24
+ buildResolveToolRequestEvent,
25
+ buildSetProjectStarEvent,
26
+ buildTurnCancelledEvent,
27
+ buildTurnFailedEvent,
28
+ buildTurnFinishedEvent,
29
+ buildTurnStartedEvent,
30
+ buildUnarchiveChatEvent,
31
+ computeNewSidebarOrder,
32
+ } from "./event-store-write-ops"
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Helpers
36
+ // ---------------------------------------------------------------------------
37
+
38
+ const TS = 1_700_000_000_000
39
+
40
+ function makeChat(overrides?: Partial<ChatRecord>): ChatRecord {
41
+ return {
42
+ id: "chat-1",
43
+ projectId: "proj-1",
44
+ title: "Chat One",
45
+ createdAt: TS,
46
+ updatedAt: TS,
47
+ unread: false,
48
+ provider: null,
49
+ planMode: false,
50
+ sessionTokensByProvider: {},
51
+ sourceHash: null,
52
+ pendingForkSessionToken: null,
53
+ hasMessages: false,
54
+ lastTurnOutcome: null,
55
+ ...overrides,
56
+ }
57
+ }
58
+
59
+ function makeStack(overrides?: Partial<StackRecord>): StackRecord {
60
+ return {
61
+ id: "stack-1",
62
+ title: "Stack One",
63
+ projectIds: ["proj-1", "proj-2"],
64
+ createdAt: TS,
65
+ updatedAt: TS,
66
+ ...overrides,
67
+ }
68
+ }
69
+
70
+ function makeProject(id: string): ProjectRecord {
71
+ return { id, localPath: `/tmp/${id}`, title: id, createdAt: TS, updatedAt: TS }
72
+ }
73
+
74
+ function makeProjectsById(ids: string[] = ["proj-1", "proj-2"]): Map<string, ProjectRecord> {
75
+ return new Map(ids.map((id) => [id, makeProject(id)]))
76
+ }
77
+
78
+ function makeToolRequest(overrides?: Partial<ToolRequest>): ToolRequest {
79
+ return {
80
+ id: "req-1",
81
+ chatId: "chat-1",
82
+ sessionId: "sess-1",
83
+ toolUseId: "tool-use-1",
84
+ toolName: "AskUserQuestion",
85
+ arguments: {},
86
+ canonicalArgsHash: "hash",
87
+ policyVerdict: "ask",
88
+ status: "pending",
89
+ createdAt: TS,
90
+ expiresAt: TS + 600_000,
91
+ ...overrides,
92
+ }
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // buildOpenProjectResult
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("buildOpenProjectResult", () => {
100
+ test("returns existing project when already known", () => {
101
+ const existing = makeProject("proj-1")
102
+ const state = {
103
+ projectsById: new Map([["proj-1", existing]]),
104
+ projectIdsByPath: new Map([[existing.localPath, "proj-1"]]),
105
+ }
106
+ const result = buildOpenProjectResult(state, "/tmp/proj-1")
107
+ expect(result.kind).toBe("existing")
108
+ if (result.kind === "existing") {
109
+ expect(result.project).toBe(existing)
110
+ }
111
+ })
112
+
113
+ test("returns new event for unknown path", () => {
114
+ const state = {
115
+ projectsById: new Map<string, ProjectRecord>(),
116
+ projectIdsByPath: new Map<string, string>(),
117
+ }
118
+ const result = buildOpenProjectResult(state, "/tmp/new-project", "My Project")
119
+ expect(result.kind).toBe("new")
120
+ if (result.kind === "new") {
121
+ expect(result.event.type).toBe("project_opened")
122
+ expect(result.event.title).toBe("My Project")
123
+ }
124
+ })
125
+
126
+ test("uses path basename when no title given", () => {
127
+ const state = {
128
+ projectsById: new Map<string, ProjectRecord>(),
129
+ projectIdsByPath: new Map<string, string>(),
130
+ }
131
+ const result = buildOpenProjectResult(state, "/home/user/my-repo")
132
+ expect(result.kind).toBe("new")
133
+ if (result.kind === "new") {
134
+ expect(result.event.title).toBe("my-repo")
135
+ }
136
+ })
137
+ })
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // buildRemoveProjectEvent
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe("buildRemoveProjectEvent", () => {
144
+ test("returns project_removed event", () => {
145
+ const projectsById = new Map([["proj-1", makeProject("proj-1")]])
146
+ const event = buildRemoveProjectEvent(projectsById, "proj-1")
147
+ expect(event.type).toBe("project_removed")
148
+ })
149
+
150
+ test("throws if project not found", () => {
151
+ expect(() => buildRemoveProjectEvent(new Map(), "missing")).toThrow()
152
+ })
153
+ })
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // buildSetProjectStarEvent
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe("buildSetProjectStarEvent", () => {
160
+ test("returns project_star_set event with starredAt when starring", () => {
161
+ const projectsById = new Map([["proj-1", makeProject("proj-1")]])
162
+ const event = buildSetProjectStarEvent(projectsById, "proj-1", true)
163
+ expect(event.type).toBe("project_star_set")
164
+ if (event.type === "project_star_set") {
165
+ expect(event.starredAt).toBeGreaterThan(0)
166
+ }
167
+ })
168
+
169
+ test("returns null starredAt when unstarring", () => {
170
+ const projectsById = new Map([["proj-1", makeProject("proj-1")]])
171
+ const event = buildSetProjectStarEvent(projectsById, "proj-1", false)
172
+ if (event.type === "project_star_set") {
173
+ expect(event.starredAt).toBeNull()
174
+ }
175
+ })
176
+ })
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // computeNewSidebarOrder
180
+ // ---------------------------------------------------------------------------
181
+
182
+ describe("computeNewSidebarOrder", () => {
183
+ test("returns null when order is unchanged", () => {
184
+ const projectsById = makeProjectsById(["proj-1", "proj-2"])
185
+ const result = computeNewSidebarOrder(projectsById, ["proj-1", "proj-2"], ["proj-1", "proj-2"])
186
+ expect(result).toBeNull()
187
+ })
188
+
189
+ test("returns new order when changed", () => {
190
+ const projectsById = makeProjectsById(["proj-1", "proj-2"])
191
+ const result = computeNewSidebarOrder(projectsById, ["proj-1", "proj-2"], ["proj-2", "proj-1"])
192
+ expect(result).toEqual(["proj-2", "proj-1"])
193
+ })
194
+
195
+ test("filters out deleted and unknown projects", () => {
196
+ const projectsById = new Map([
197
+ ["proj-1", makeProject("proj-1")],
198
+ ["proj-deleted", { ...makeProject("proj-deleted"), deletedAt: TS }],
199
+ ])
200
+ const result = computeNewSidebarOrder(projectsById, [], ["proj-1", "proj-deleted", "unknown"])
201
+ expect(result).toEqual(["proj-1"])
202
+ })
203
+
204
+ test("deduplicates", () => {
205
+ const projectsById = makeProjectsById(["proj-1", "proj-2"])
206
+ const result = computeNewSidebarOrder(projectsById, [], ["proj-1", "proj-1", "proj-2"])
207
+ expect(result).toEqual(["proj-1", "proj-2"])
208
+ })
209
+ })
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Stack builders
213
+ // ---------------------------------------------------------------------------
214
+
215
+ describe("buildCreateStackEvent", () => {
216
+ test("builds stack_added event", () => {
217
+ const state = { projectsById: makeProjectsById(), stacksById: new Map<string, StackRecord>() }
218
+ const event = buildCreateStackEvent(state, "My Stack", ["proj-1", "proj-2"])
219
+ expect(event.type).toBe("stack_added")
220
+ expect(event.stackId).toBeDefined()
221
+ if (event.type === "stack_added") {
222
+ expect(event.title).toBe("My Stack")
223
+ }
224
+ })
225
+
226
+ test("throws when fewer than 2 projects", () => {
227
+ const state = { projectsById: makeProjectsById(), stacksById: new Map<string, StackRecord>() }
228
+ expect(() => buildCreateStackEvent(state, "S", ["proj-1"])).toThrow()
229
+ })
230
+
231
+ test("throws when title is blank", () => {
232
+ const state = { projectsById: makeProjectsById(), stacksById: new Map<string, StackRecord>() }
233
+ expect(() => buildCreateStackEvent(state, " ", ["proj-1", "proj-2"])).toThrow()
234
+ })
235
+
236
+ test("throws when duplicate projectIds", () => {
237
+ const state = { projectsById: makeProjectsById(["proj-1"]), stacksById: new Map<string, StackRecord>() }
238
+ expect(() => buildCreateStackEvent(state, "S", ["proj-1", "proj-1"])).toThrow()
239
+ })
240
+ })
241
+
242
+ describe("buildRenameStackEvent", () => {
243
+ test("returns null when title unchanged", () => {
244
+ const stacksById = new Map([["stack-1", makeStack()]])
245
+ expect(buildRenameStackEvent(stacksById, "stack-1", "Stack One")).toBeNull()
246
+ })
247
+
248
+ test("returns event when title changed", () => {
249
+ const stacksById = new Map([["stack-1", makeStack()]])
250
+ const event = buildRenameStackEvent(stacksById, "stack-1", "New Name")
251
+ expect(event?.type).toBe("stack_renamed")
252
+ if (event?.type === "stack_renamed") {
253
+ expect(event.title).toBe("New Name")
254
+ }
255
+ })
256
+ })
257
+
258
+ describe("buildRemoveStackEvent", () => {
259
+ test("returns stack_removed event", () => {
260
+ const stacksById = new Map([["stack-1", makeStack()]])
261
+ const event = buildRemoveStackEvent(stacksById, "stack-1")
262
+ expect(event?.type).toBe("stack_removed")
263
+ })
264
+
265
+ test("returns null if already deleted", () => {
266
+ const stacksById = new Map([["stack-1", makeStack({ deletedAt: TS })]])
267
+ expect(buildRemoveStackEvent(stacksById, "stack-1")).toBeNull()
268
+ })
269
+ })
270
+
271
+ describe("buildAddProjectToStackEvent", () => {
272
+ test("returns null if project already in stack", () => {
273
+ const state = {
274
+ projectsById: makeProjectsById(["proj-1", "proj-2"]),
275
+ stacksById: new Map([["stack-1", makeStack({ projectIds: ["proj-1", "proj-2"] })]]),
276
+ }
277
+ expect(buildAddProjectToStackEvent(state, "stack-1", "proj-1")).toBeNull()
278
+ })
279
+
280
+ test("returns event when project not yet in stack", () => {
281
+ const state = {
282
+ projectsById: makeProjectsById(["proj-1", "proj-2", "proj-3"]),
283
+ stacksById: new Map([["stack-1", makeStack({ projectIds: ["proj-1", "proj-2"] })]]),
284
+ }
285
+ const event = buildAddProjectToStackEvent(state, "stack-1", "proj-3")
286
+ expect(event?.type).toBe("stack_project_added")
287
+ })
288
+ })
289
+
290
+ describe("buildRemoveProjectFromStackEvent", () => {
291
+ test("returns null if project not in stack", () => {
292
+ const stacksById = new Map([["stack-1", makeStack({ projectIds: ["proj-1", "proj-2"] })]])
293
+ expect(buildRemoveProjectFromStackEvent(stacksById, "stack-1", "proj-99")).toBeNull()
294
+ })
295
+
296
+ test("throws when would drop below 2 members", () => {
297
+ const stacksById = new Map([["stack-1", makeStack({ projectIds: ["proj-1", "proj-2"] })]])
298
+ expect(() => buildRemoveProjectFromStackEvent(stacksById, "stack-1", "proj-1")).toThrow()
299
+ })
300
+
301
+ test("returns event when stack has 3+ members", () => {
302
+ const stacksById = new Map([["stack-1", makeStack({ projectIds: ["proj-1", "proj-2", "proj-3"] })]])
303
+ const event = buildRemoveProjectFromStackEvent(stacksById, "stack-1", "proj-3")
304
+ expect(event?.type).toBe("stack_project_removed")
305
+ })
306
+ })
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Chat builders
310
+ // ---------------------------------------------------------------------------
311
+
312
+ describe("buildCreateChatEvent", () => {
313
+ test("builds chat_created event", () => {
314
+ const state = { projectsById: makeProjectsById(), stacksById: new Map<string, StackRecord>() }
315
+ const event = buildCreateChatEvent(state, "proj-1")
316
+ expect(event.type).toBe("chat_created")
317
+ expect(event.chatId).toBeDefined()
318
+ if (event.type === "chat_created") {
319
+ expect(event.projectId).toBe("proj-1")
320
+ }
321
+ })
322
+
323
+ test("throws when project not found", () => {
324
+ const state = { projectsById: new Map<string, ProjectRecord>(), stacksById: new Map<string, StackRecord>() }
325
+ expect(() => buildCreateChatEvent(state, "missing")).toThrow()
326
+ })
327
+ })
328
+
329
+ describe("buildRenameChatEvent", () => {
330
+ test("returns null if title unchanged", () => {
331
+ const chatsById = new Map([["chat-1", makeChat({ title: "Same" })]])
332
+ expect(buildRenameChatEvent(chatsById, "chat-1", "Same")).toBeNull()
333
+ })
334
+
335
+ test("returns null if title is blank", () => {
336
+ const chatsById = new Map([["chat-1", makeChat()]])
337
+ expect(buildRenameChatEvent(chatsById, "chat-1", " ")).toBeNull()
338
+ })
339
+
340
+ test("returns event when title changed", () => {
341
+ const chatsById = new Map([["chat-1", makeChat()]])
342
+ const event = buildRenameChatEvent(chatsById, "chat-1", "New Name")
343
+ expect(event?.type).toBe("chat_renamed")
344
+ })
345
+ })
346
+
347
+ describe("buildArchiveChatEvent / buildUnarchiveChatEvent", () => {
348
+ test("archive returns chat_archived", () => {
349
+ const chatsById = new Map([["chat-1", makeChat()]])
350
+ expect(buildArchiveChatEvent(chatsById, "chat-1").type).toBe("chat_archived")
351
+ })
352
+
353
+ test("unarchive returns chat_unarchived", () => {
354
+ const chatsById = new Map([["chat-1", makeChat()]])
355
+ expect(buildUnarchiveChatEvent(chatsById, "chat-1").type).toBe("chat_unarchived")
356
+ })
357
+
358
+ test("throws if chat not found", () => {
359
+ expect(() => buildArchiveChatEvent(new Map(), "missing")).toThrow()
360
+ })
361
+ })
362
+
363
+ describe("buildChatProviderEvent", () => {
364
+ test("returns null if provider unchanged", () => {
365
+ const chatsById = new Map([["chat-1", makeChat({ provider: "claude" })]])
366
+ expect(buildChatProviderEvent(chatsById, "chat-1", "claude")).toBeNull()
367
+ })
368
+
369
+ test("returns event when provider changes", () => {
370
+ const chatsById = new Map([["chat-1", makeChat({ provider: null })]])
371
+ const event = buildChatProviderEvent(chatsById, "chat-1", "claude")
372
+ expect(event?.type).toBe("chat_provider_set")
373
+ })
374
+ })
375
+
376
+ describe("buildPlanModeEvent", () => {
377
+ test("returns null if planMode unchanged", () => {
378
+ const chatsById = new Map([["chat-1", makeChat({ planMode: false })]])
379
+ expect(buildPlanModeEvent(chatsById, "chat-1", false)).toBeNull()
380
+ })
381
+
382
+ test("returns event when planMode changes", () => {
383
+ const chatsById = new Map([["chat-1", makeChat({ planMode: false })]])
384
+ expect(buildPlanModeEvent(chatsById, "chat-1", true)?.type).toBe("chat_plan_mode_set")
385
+ })
386
+ })
387
+
388
+ describe("buildCompactFailuresEvent", () => {
389
+ test("returns null if count unchanged", () => {
390
+ const chatsById = new Map([["chat-1", makeChat({ compactFailureCount: 0 })]])
391
+ expect(buildCompactFailuresEvent(chatsById, "chat-1", 0)).toBeNull()
392
+ })
393
+
394
+ test("returns event when count changes", () => {
395
+ const chatsById = new Map([["chat-1", makeChat()]])
396
+ expect(buildCompactFailuresEvent(chatsById, "chat-1", 1)?.type).toBe("chat_compact_failures_set")
397
+ })
398
+ })
399
+
400
+ describe("buildChatReadStateEvent", () => {
401
+ test("returns null if unread unchanged", () => {
402
+ const chatsById = new Map([["chat-1", makeChat({ unread: false })]])
403
+ expect(buildChatReadStateEvent(chatsById, "chat-1", false)).toBeNull()
404
+ })
405
+
406
+ test("returns event when unread changes", () => {
407
+ const chatsById = new Map([["chat-1", makeChat({ unread: false })]])
408
+ expect(buildChatReadStateEvent(chatsById, "chat-1", true)?.type).toBe("chat_read_state_set")
409
+ })
410
+ })
411
+
412
+ describe("buildChatSourceHashEvent", () => {
413
+ test("returns null if sourceHash unchanged", () => {
414
+ const chatsById = new Map([["chat-1", makeChat({ sourceHash: "abc" })]])
415
+ expect(buildChatSourceHashEvent(chatsById, "chat-1", "abc")).toBeNull()
416
+ })
417
+
418
+ test("returns event when sourceHash changes", () => {
419
+ const chatsById = new Map([["chat-1", makeChat({ sourceHash: null })]])
420
+ expect(buildChatSourceHashEvent(chatsById, "chat-1", "abc")?.type).toBe("chat_source_hash_set")
421
+ })
422
+ })
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Queued message builders
426
+ // ---------------------------------------------------------------------------
427
+
428
+ describe("buildEnqueueMessageResult", () => {
429
+ test("builds event and queuedMessage with generated id", () => {
430
+ const chatsById = new Map([["chat-1", makeChat()]])
431
+ const { event, queuedMessage } = buildEnqueueMessageResult(chatsById, "chat-1", {
432
+ content: "hello",
433
+ attachments: [],
434
+ provider: "claude",
435
+ model: "claude-3-5-sonnet",
436
+ })
437
+ expect(event.type).toBe("queued_message_enqueued")
438
+ expect(queuedMessage.id).toBeDefined()
439
+ expect(queuedMessage.content).toBe("hello")
440
+ })
441
+
442
+ test("uses provided id if given", () => {
443
+ const chatsById = new Map([["chat-1", makeChat()]])
444
+ const { queuedMessage } = buildEnqueueMessageResult(chatsById, "chat-1", {
445
+ id: "fixed-id",
446
+ content: "hello",
447
+ attachments: [],
448
+ provider: "claude",
449
+ model: "claude-3-5-sonnet",
450
+ })
451
+ expect(queuedMessage.id).toBe("fixed-id")
452
+ })
453
+ })
454
+
455
+ describe("buildRemoveQueuedMessageEvent", () => {
456
+ test("returns queued_message_removed event", () => {
457
+ const chatsById = new Map([["chat-1", makeChat()]])
458
+ const queuedMessagesByChatId = new Map([
459
+ ["chat-1", [{ id: "msg-1", content: "hi", attachments: [], createdAt: TS, provider: "claude" as const, model: "m" }]],
460
+ ])
461
+ const event = buildRemoveQueuedMessageEvent(chatsById, queuedMessagesByChatId, "chat-1", "msg-1")
462
+ expect(event.type).toBe("queued_message_removed")
463
+ if (event.type === "queued_message_removed") {
464
+ expect(event.queuedMessageId).toBe("msg-1")
465
+ }
466
+ })
467
+
468
+ test("throws if message not found", () => {
469
+ const chatsById = new Map([["chat-1", makeChat()]])
470
+ const empty = new Map<string, { id: string; content: string; attachments: []; createdAt: number; provider: "claude"; model: string }[]>()
471
+ expect(() => buildRemoveQueuedMessageEvent(chatsById, empty, "chat-1", "missing")).toThrow()
472
+ })
473
+ })
474
+
475
+ // ---------------------------------------------------------------------------
476
+ // Turn builders
477
+ // ---------------------------------------------------------------------------
478
+
479
+ describe("buildTurnStartedEvent", () => {
480
+ test("builds turn_started", () => {
481
+ const chatsById = new Map([["chat-1", makeChat()]])
482
+ expect(buildTurnStartedEvent(chatsById, "chat-1").type).toBe("turn_started")
483
+ })
484
+
485
+ test("throws if chat not found", () => {
486
+ expect(() => buildTurnStartedEvent(new Map(), "missing")).toThrow()
487
+ })
488
+ })
489
+
490
+ describe("buildTurnFinishedEvent", () => {
491
+ test("builds turn_finished", () => {
492
+ const chatsById = new Map([["chat-1", makeChat()]])
493
+ expect(buildTurnFinishedEvent(chatsById, "chat-1").type).toBe("turn_finished")
494
+ })
495
+ })
496
+
497
+ describe("buildTurnFailedEvent", () => {
498
+ test("builds turn_failed with error", () => {
499
+ const chatsById = new Map([["chat-1", makeChat()]])
500
+ const event = buildTurnFailedEvent(chatsById, "chat-1", "some error")
501
+ expect(event.type).toBe("turn_failed")
502
+ if (event.type === "turn_failed") {
503
+ expect(event.error).toBe("some error")
504
+ }
505
+ })
506
+ })
507
+
508
+ describe("buildTurnCancelledEvent", () => {
509
+ test("builds turn_cancelled", () => {
510
+ const chatsById = new Map([["chat-1", makeChat()]])
511
+ expect(buildTurnCancelledEvent(chatsById, "chat-1").type).toBe("turn_cancelled")
512
+ })
513
+ })
514
+
515
+ describe("buildPendingForkSessionTokenEvent", () => {
516
+ test("returns null if token unchanged (both null)", () => {
517
+ const chatsById = new Map([["chat-1", makeChat({ pendingForkSessionToken: null })]])
518
+ expect(buildPendingForkSessionTokenEvent(chatsById, "chat-1", null)).toBeNull()
519
+ })
520
+
521
+ test("returns event when setting a token", () => {
522
+ const chatsById = new Map([["chat-1", makeChat({ pendingForkSessionToken: null })]])
523
+ const event = buildPendingForkSessionTokenEvent(chatsById, "chat-1", { provider: "claude", token: "tok-abc" })
524
+ expect(event?.type).toBe("pending_fork_session_token_set")
525
+ })
526
+ })
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // Tool-request builders
530
+ // ---------------------------------------------------------------------------
531
+
532
+ describe("buildPutToolRequestEvent", () => {
533
+ test("builds tool_request_put", () => {
534
+ const event = buildPutToolRequestEvent(makeToolRequest())
535
+ expect(event.type).toBe("tool_request_put")
536
+ })
537
+ })
538
+
539
+ describe("buildResolveToolRequestEvent", () => {
540
+ test("builds tool_request_resolved", () => {
541
+ const toolRequestsById = new Map([["req-1", makeToolRequest()]])
542
+ const event = buildResolveToolRequestEvent(toolRequestsById, "req-1", {
543
+ status: "answered",
544
+ resolvedAt: TS,
545
+ })
546
+ expect(event.type).toBe("tool_request_resolved")
547
+ })
548
+
549
+ test("throws if id unknown", () => {
550
+ expect(() =>
551
+ buildResolveToolRequestEvent(new Map(), "unknown", { status: "answered", resolvedAt: TS }),
552
+ ).toThrow()
553
+ })
554
+ })