@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,210 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { ToolRequest } from "../shared/permission-policy"
3
+ import type { ToolRequestEvent } from "./events"
4
+ import {
5
+ applyToolRequestEvent,
6
+ deleteToolRequestsForChat,
7
+ getToolRequest,
8
+ listPendingToolRequests,
9
+ scanAllToolRequests,
10
+ } from "./event-store-tool-requests"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const TS = 1_700_000_000_000
17
+
18
+ function makeRequest(overrides: Partial<ToolRequest> = {}): ToolRequest {
19
+ return {
20
+ id: "req-1",
21
+ chatId: "chat-1",
22
+ sessionId: "session-1",
23
+ toolUseId: "tool-use-1",
24
+ toolName: "Bash",
25
+ arguments: { command: "ls" },
26
+ canonicalArgsHash: "abc123",
27
+ policyVerdict: "ask",
28
+ status: "pending",
29
+ createdAt: TS,
30
+ expiresAt: TS + 600_000,
31
+ ...overrides,
32
+ }
33
+ }
34
+
35
+ function putEvent(req: ToolRequest): Extract<ToolRequestEvent, { type: "tool_request_put" }> {
36
+ return { v: 3, type: "tool_request_put", timestamp: TS, request: req }
37
+ }
38
+
39
+ function resolvedEvent(
40
+ id: string,
41
+ overrides: Partial<Extract<ToolRequestEvent, { type: "tool_request_resolved" }>> = {},
42
+ ): Extract<ToolRequestEvent, { type: "tool_request_resolved" }> {
43
+ return {
44
+ v: 3,
45
+ type: "tool_request_resolved",
46
+ timestamp: TS + 1000,
47
+ id,
48
+ status: "answered",
49
+ resolvedAt: TS + 1000,
50
+ ...overrides,
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // applyToolRequestEvent — tool_request_put
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("applyToolRequestEvent / tool_request_put", () => {
59
+ test("inserts a defensive copy into the map", () => {
60
+ const map = new Map<string, ToolRequest>()
61
+ const req = makeRequest()
62
+ applyToolRequestEvent(map, putEvent(req))
63
+ expect(map.size).toBe(1)
64
+ expect(map.get("req-1")).toEqual(req)
65
+ // defensive copy — mutation of the original must not affect the stored value
66
+ req.status = "canceled"
67
+ expect(map.get("req-1")?.status).toBe("pending")
68
+ })
69
+
70
+ test("overwrites an existing entry for the same id", () => {
71
+ const map = new Map<string, ToolRequest>()
72
+ const first = makeRequest({ id: "req-1", toolName: "Read" })
73
+ const second = makeRequest({ id: "req-1", toolName: "Bash" })
74
+ applyToolRequestEvent(map, putEvent(first))
75
+ applyToolRequestEvent(map, putEvent(second))
76
+ expect(map.get("req-1")?.toolName).toBe("Bash")
77
+ expect(map.size).toBe(1)
78
+ })
79
+ })
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // applyToolRequestEvent — tool_request_resolved
83
+ // ---------------------------------------------------------------------------
84
+
85
+ describe("applyToolRequestEvent / tool_request_resolved", () => {
86
+ test("updates status, decision, resolvedAt, and mismatchReason", () => {
87
+ const map = new Map<string, ToolRequest>()
88
+ applyToolRequestEvent(map, putEvent(makeRequest()))
89
+ applyToolRequestEvent(map, resolvedEvent("req-1", {
90
+ status: "answered",
91
+ decision: { kind: "allow" },
92
+ resolvedAt: TS + 5000,
93
+ mismatchReason: undefined,
94
+ }))
95
+ const stored = map.get("req-1")
96
+ expect(stored?.status).toBe("answered")
97
+ expect(stored?.decision).toEqual({ kind: "allow" })
98
+ expect(stored?.resolvedAt).toBe(TS + 5000)
99
+ })
100
+
101
+ test("is a no-op when id is unknown", () => {
102
+ const map = new Map<string, ToolRequest>()
103
+ applyToolRequestEvent(map, resolvedEvent("nonexistent"))
104
+ expect(map.size).toBe(0)
105
+ })
106
+
107
+ test("preserves existing decision when event omits it", () => {
108
+ const map = new Map<string, ToolRequest>()
109
+ const req = makeRequest({ decision: { kind: "deny", reason: "blocked" } })
110
+ applyToolRequestEvent(map, putEvent(req))
111
+ applyToolRequestEvent(map, resolvedEvent("req-1", { status: "timeout", decision: undefined }))
112
+ expect(map.get("req-1")?.decision).toEqual({ kind: "deny", reason: "blocked" })
113
+ })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // getToolRequest
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("getToolRequest", () => {
121
+ test("returns a defensive copy when found", () => {
122
+ const map = new Map<string, ToolRequest>()
123
+ const req = makeRequest()
124
+ map.set("req-1", req)
125
+ const result = getToolRequest(map, "req-1")
126
+ expect(result).toEqual(req)
127
+ // defensive copy
128
+ result!.status = "canceled"
129
+ expect(map.get("req-1")?.status).toBe("pending")
130
+ })
131
+
132
+ test("returns null when not found", () => {
133
+ const map = new Map<string, ToolRequest>()
134
+ expect(getToolRequest(map, "missing")).toBeNull()
135
+ })
136
+ })
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // listPendingToolRequests
140
+ // ---------------------------------------------------------------------------
141
+
142
+ describe("listPendingToolRequests", () => {
143
+ test("returns only pending requests for the given chatId", () => {
144
+ const map = new Map<string, ToolRequest>()
145
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "pending" }))
146
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-1", status: "answered" }))
147
+ map.set("r3", makeRequest({ id: "r3", chatId: "chat-2", status: "pending" }))
148
+ const result = listPendingToolRequests(map, "chat-1")
149
+ expect(result).toHaveLength(1)
150
+ expect(result[0].id).toBe("r1")
151
+ })
152
+
153
+ test("returns empty array when no pending requests exist for chat", () => {
154
+ const map = new Map<string, ToolRequest>()
155
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "answered" }))
156
+ expect(listPendingToolRequests(map, "chat-1")).toEqual([])
157
+ })
158
+
159
+ test("returns defensive copies", () => {
160
+ const map = new Map<string, ToolRequest>()
161
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1", status: "pending" }))
162
+ const results = listPendingToolRequests(map, "chat-1")
163
+ results[0].status = "canceled"
164
+ expect(map.get("r1")?.status).toBe("pending")
165
+ })
166
+ })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // scanAllToolRequests
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe("scanAllToolRequests", () => {
173
+ test("returns defensive copies of every entry in the map", () => {
174
+ const map = new Map<string, ToolRequest>()
175
+ map.set("r1", makeRequest({ id: "r1" }))
176
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-2", status: "answered" }))
177
+ const all = scanAllToolRequests(map)
178
+ expect(all).toHaveLength(2)
179
+ // defensive copy
180
+ all[0].status = "canceled"
181
+ expect(map.get("r1")?.status).toBe("pending")
182
+ })
183
+
184
+ test("returns empty array for an empty map", () => {
185
+ expect(scanAllToolRequests(new Map())).toEqual([])
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // deleteToolRequestsForChat
191
+ // ---------------------------------------------------------------------------
192
+
193
+ describe("deleteToolRequestsForChat", () => {
194
+ test("removes all entries belonging to the chat", () => {
195
+ const map = new Map<string, ToolRequest>()
196
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-1" }))
197
+ map.set("r2", makeRequest({ id: "r2", chatId: "chat-1" }))
198
+ map.set("r3", makeRequest({ id: "r3", chatId: "chat-2" }))
199
+ deleteToolRequestsForChat(map, "chat-1")
200
+ expect(map.size).toBe(1)
201
+ expect(map.has("r3")).toBe(true)
202
+ })
203
+
204
+ test("leaves the map unchanged when no entries belong to the chat", () => {
205
+ const map = new Map<string, ToolRequest>()
206
+ map.set("r1", makeRequest({ id: "r1", chatId: "chat-2" }))
207
+ deleteToolRequestsForChat(map, "chat-1")
208
+ expect(map.size).toBe(1)
209
+ })
210
+ })
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tool-request read-model and write-path layers extracted from event-store.ts.
3
+ *
4
+ * All read functions are pure folds over the in-memory `toolRequestsById` map.
5
+ * Write functions (putToolRequest, resolveToolRequest) accept a ToolRequestWriteDeps
6
+ * so they can append to the event log without importing disk IO directly.
7
+ */
8
+ import type { ToolRequest, ToolRequestDecision, ToolRequestStatus } from "../shared/permission-policy"
9
+ import type { StoreEvent, ToolRequestEvent } from "./events"
10
+ import {
11
+ buildPutToolRequestEvent,
12
+ buildResolveToolRequestEvent,
13
+ } from "./event-store-write-ops"
14
+
15
+ // ─── Write-path deps ──────────────────────────────────────────────────────
16
+
17
+ export interface ToolRequestWriteDeps {
18
+ readonly toolRequestsById: Map<string, ToolRequest>
19
+ readonly toolRequestsLogPath: string
20
+ append: (filePath: string, event: StoreEvent) => Promise<void>
21
+ }
22
+
23
+ /**
24
+ * Apply a single `ToolRequestEvent` to the in-memory `toolRequestsById` map
25
+ * (mutates in place).
26
+ *
27
+ * Mirrors the two `case "tool_request_*"` branches that previously lived
28
+ * inside `EventStore.applyEvent`.
29
+ */
30
+ export function applyToolRequestEvent(
31
+ toolRequestsById: Map<string, ToolRequest>,
32
+ event: ToolRequestEvent,
33
+ ): void {
34
+ switch (event.type) {
35
+ case "tool_request_put": {
36
+ toolRequestsById.set(event.request.id, { ...event.request })
37
+ break
38
+ }
39
+ case "tool_request_resolved": {
40
+ const existing = toolRequestsById.get(event.id)
41
+ if (!existing) break
42
+ toolRequestsById.set(event.id, {
43
+ ...existing,
44
+ status: event.status,
45
+ decision: event.decision ?? existing.decision,
46
+ resolvedAt: event.resolvedAt,
47
+ mismatchReason: event.mismatchReason,
48
+ })
49
+ break
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Return a defensive copy of the tool request with the given id, or `null`
56
+ * if not found.
57
+ */
58
+ export function getToolRequest(
59
+ toolRequestsById: Map<string, ToolRequest>,
60
+ id: string,
61
+ ): ToolRequest | null {
62
+ const req = toolRequestsById.get(id)
63
+ return req ? { ...req } : null
64
+ }
65
+
66
+ /**
67
+ * Return defensive copies of all pending tool requests for the given chat.
68
+ */
69
+ export function listPendingToolRequests(
70
+ toolRequestsById: Map<string, ToolRequest>,
71
+ chatId: string,
72
+ ): ToolRequest[] {
73
+ const out: ToolRequest[] = []
74
+ for (const req of toolRequestsById.values()) {
75
+ if (req.chatId !== chatId) continue
76
+ if (req.status !== "pending") continue
77
+ out.push({ ...req })
78
+ }
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * Return defensive copies of every tool request in the map.
84
+ */
85
+ export function scanAllToolRequests(
86
+ toolRequestsById: Map<string, ToolRequest>,
87
+ ): ToolRequest[] {
88
+ return [...toolRequestsById.values()].map((req) => ({ ...req }))
89
+ }
90
+
91
+ /**
92
+ * Remove every tool request belonging to the given chat from the map
93
+ * (mutates in place). Called when a chat is deleted.
94
+ */
95
+ export function deleteToolRequestsForChat(
96
+ toolRequestsById: Map<string, ToolRequest>,
97
+ chatId: string,
98
+ ): void {
99
+ for (const [id, req] of toolRequestsById) {
100
+ if (req.chatId === chatId) {
101
+ toolRequestsById.delete(id)
102
+ }
103
+ }
104
+ }
105
+
106
+ // ─── Write-path functions ──────────────────────────────────────────────────
107
+
108
+ /** Persist + apply a new tool request. */
109
+ export async function putToolRequest(
110
+ deps: ToolRequestWriteDeps,
111
+ req: ToolRequest,
112
+ ): Promise<void> {
113
+ const event = buildPutToolRequestEvent(req)
114
+ applyToolRequestEvent(deps.toolRequestsById, event)
115
+ await deps.append(deps.toolRequestsLogPath, event)
116
+ }
117
+
118
+ /** Persist + apply a tool request resolution. */
119
+ export async function resolveToolRequest(
120
+ deps: ToolRequestWriteDeps,
121
+ id: string,
122
+ args: { status: ToolRequestStatus; decision?: ToolRequestDecision; resolvedAt: number; mismatchReason?: string },
123
+ ): Promise<void> {
124
+ const event = buildResolveToolRequestEvent(deps.toolRequestsById, id, args)
125
+ applyToolRequestEvent(deps.toolRequestsById, event)
126
+ await deps.append(deps.toolRequestsLogPath, event)
127
+ }
128
+
129
+ // Re-export types consumed by event-store.ts method signatures so callers
130
+ // can import them from one place.
131
+ export type { ToolRequest, ToolRequestDecision, ToolRequestStatus }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Complex chat-transcript write operations extracted from event-store.ts.
3
+ *
4
+ * Contains methods that directly manipulate the transcript JSONL file or
5
+ * perform multi-step chat mutations (fork, delete, prune, appendMessage).
6
+ * All IO is performed through the injected StorageBackend.
7
+ *
8
+ * This module is an adapter (.adapter.ts) because it performs disk IO.
9
+ * It must NOT import from event-store.ts (no circular deps).
10
+ */
11
+ import path from "node:path"
12
+ import { LOG_PREFIX } from "../shared/branding"
13
+ import { log } from "../shared/log"
14
+ import { STORE_VERSION } from "../shared/types"
15
+ import type { TranscriptEntry } from "../shared/types"
16
+ import type { ToolRequest } from "../shared/permission-policy"
17
+ import type { ChatEvent, ChatRecord, StoreEvent } from "./events"
18
+ import { cloneTranscriptEntries } from "./events"
19
+ import type { StorageBackend } from "./storage/backend"
20
+ import { getForkedChatTitle, logSendToStartingProfile } from "./event-store-helpers"
21
+ import {
22
+ buildArchiveChatEvent,
23
+ buildChatProviderEvent,
24
+ buildPendingForkSessionTokenEvent,
25
+ buildPlanModeEvent,
26
+ buildUnarchiveChatEvent,
27
+ } from "./event-store-write-ops"
28
+ import { deleteToolRequestsForChat } from "./event-store-tool-requests"
29
+ import { applyChatMessageMetadata } from "./event-store-chat-lifecycle"
30
+ import type { TranscriptCache } from "./event-store-messages.adapter"
31
+ import type { ChatOp } from "../shared/chat-ops"
32
+
33
+ const STALE_EMPTY_CHAT_MAX_AGE_MS = 30 * 60 * 1000
34
+
35
+ // ─── Deps interface ────────────────────────────────────────────────────────
36
+
37
+ export interface ChatTranscriptWriteDeps {
38
+ readonly storage: StorageBackend
39
+ readonly transcriptsDir: string
40
+ readonly dataDir: string
41
+ readonly transcriptCache: TranscriptCache
42
+ readonly seenMessageIdsByChatId: Map<string, Set<string>>
43
+ readonly chatsById: Map<string, ChatRecord>
44
+ readonly toolRequestsById: Map<string, ToolRequest>
45
+ readonly chatsLogPath: string
46
+ readonly turnsLogPath: string
47
+ /** Read the current write-chain promise. */
48
+ getWriteChain: () => Promise<void>
49
+ /** Replace the write-chain promise. */
50
+ setWriteChain: (p: Promise<void>) => void
51
+ /** Core append: writes event to disk + applies to in-memory state. */
52
+ append: <T extends StoreEvent>(filePath: string, event: T) => Promise<void>
53
+ /** Returns transcript entries for a chat (from cache or disk). */
54
+ getMessages: (chatId: string) => TranscriptEntry[]
55
+ /** Loads the transcript into cache (populating seen messageIds) without cloning. */
56
+ ensureTranscriptLoaded: (chatId: string) => void
57
+ /** Returns (or lazily creates) the seen-messageId dedup set for a chat. */
58
+ getSeenMessageIds: (chatId: string) => Set<string>
59
+ /** Returns pending tool requests for a chat. */
60
+ listPendingToolRequests: (chatId: string) => ToolRequest[]
61
+ /** Records a delta op for the `chat.ops` broadcast path. */
62
+ recordChatOp: (chatId: string, op: ChatOp) => void
63
+ /** Drops the chat's op-log (chat deleted/pruned). */
64
+ clearChatOps: (chatId: string) => void
65
+ }
66
+
67
+ // ─── Private helpers ───────────────────────────────────────────────────────
68
+
69
+ function transcriptPath(deps: ChatTranscriptWriteDeps, chatId: string): string {
70
+ return path.join(deps.transcriptsDir, `${chatId}.jsonl`)
71
+ }
72
+
73
+ function requireChat(deps: ChatTranscriptWriteDeps, chatId: string): ChatRecord {
74
+ const chat = deps.chatsById.get(chatId)
75
+ if (!chat || chat.deletedAt) throw new Error("Chat not found")
76
+ return chat
77
+ }
78
+
79
+ // ─── Exported functions ────────────────────────────────────────────────────
80
+
81
+ /** Removes the subagent-results directory for a deleted chat (best-effort). */
82
+ export async function removeSubagentResultsDir(
83
+ deps: ChatTranscriptWriteDeps,
84
+ projectId: string,
85
+ chatId: string,
86
+ ): Promise<void> {
87
+ const dir = path.join(
88
+ deps.dataDir, "projects", projectId, "chats", chatId, "subagent-results",
89
+ )
90
+ try {
91
+ await deps.storage.remove(dir, { recursive: true })
92
+ } catch (err) {
93
+ log.warn(`${LOG_PREFIX} subagent-results cleanup failed`, { chatId, err })
94
+ }
95
+ }
96
+
97
+ /** Forks a chat: creates a new chat sharing the source transcript and session. */
98
+ export async function forkChat(
99
+ deps: ChatTranscriptWriteDeps,
100
+ sourceChatId: string,
101
+ ): Promise<ChatRecord> {
102
+ const sourceChat = requireChat(deps, sourceChatId)
103
+ const sourceProvider = sourceChat.provider
104
+ if (!sourceProvider) throw new Error("Chat cannot be forked")
105
+
106
+ const sourceSessionToken =
107
+ sourceChat.sessionTokensByProvider[sourceProvider]
108
+ ?? (sourceChat.pendingForkSessionToken?.provider === sourceProvider
109
+ ? sourceChat.pendingForkSessionToken.token
110
+ : null)
111
+ if (!sourceSessionToken) throw new Error("Chat cannot be forked")
112
+
113
+ const chatId = crypto.randomUUID()
114
+ const createdAt = Date.now()
115
+ const createEvent: ChatEvent = {
116
+ v: STORE_VERSION,
117
+ type: "chat_created",
118
+ timestamp: createdAt,
119
+ chatId,
120
+ projectId: sourceChat.projectId,
121
+ title: getForkedChatTitle(sourceChat.title),
122
+ ...(sourceChat.stackId !== undefined ? { stackId: sourceChat.stackId } : {}),
123
+ ...(sourceChat.stackBindings !== undefined
124
+ ? { stackBindings: sourceChat.stackBindings.map((b) => ({ ...b })) }
125
+ : {}),
126
+ }
127
+ await deps.append(deps.chatsLogPath, createEvent)
128
+
129
+ const providerEv = buildChatProviderEvent(deps.chatsById, chatId, sourceProvider)
130
+ if (providerEv) await deps.append(deps.chatsLogPath, providerEv)
131
+
132
+ const planEv = buildPlanModeEvent(deps.chatsById, chatId, sourceChat.planMode)
133
+ if (planEv) await deps.append(deps.chatsLogPath, planEv)
134
+
135
+ const forkTokenEv = buildPendingForkSessionTokenEvent(
136
+ deps.chatsById, chatId, { provider: sourceProvider, token: sourceSessionToken },
137
+ )
138
+ if (forkTokenEv) await deps.append(deps.turnsLogPath, forkTokenEv)
139
+
140
+ const sourceEntries = deps.getMessages(sourceChatId)
141
+ if (sourceEntries.length > 0) {
142
+ const tPath = transcriptPath(deps, chatId)
143
+ const payload = sourceEntries.map((entry) => JSON.stringify(entry)).join("\n")
144
+ const newChain = deps.getWriteChain().then(async () => {
145
+ await deps.storage.mkdir(deps.transcriptsDir)
146
+ await deps.storage.writeText(tPath, `${payload}\n`)
147
+ const chat = deps.chatsById.get(chatId)
148
+ if (chat) {
149
+ chat.hasMessages = true
150
+ chat.updatedAt = Math.max(chat.updatedAt, createdAt)
151
+ }
152
+ if (deps.transcriptCache.has(chatId)) {
153
+ deps.transcriptCache.set(chatId, cloneTranscriptEntries(sourceEntries))
154
+ }
155
+ })
156
+ deps.setWriteChain(newChain)
157
+ await newChain
158
+ }
159
+
160
+ return deps.chatsById.get(chatId)!
161
+ }
162
+
163
+ /** Deletes a chat: appends a delete event, clears tool requests, and removes the results dir. */
164
+ export async function deleteChat(
165
+ deps: ChatTranscriptWriteDeps,
166
+ chatId: string,
167
+ ): Promise<void> {
168
+ const chat = requireChat(deps, chatId)
169
+ const projectId = chat.projectId
170
+ const event: ChatEvent = {
171
+ v: STORE_VERSION,
172
+ type: "chat_deleted",
173
+ timestamp: Date.now(),
174
+ chatId,
175
+ }
176
+ await deps.append(deps.chatsLogPath, event)
177
+ deleteToolRequestsForChat(deps.toolRequestsById, chatId)
178
+ deps.clearChatOps(chatId)
179
+ await removeSubagentResultsDir(deps, projectId, chatId)
180
+ }
181
+
182
+ /** Archives a chat. */
183
+ export async function archiveChat(
184
+ deps: ChatTranscriptWriteDeps,
185
+ chatId: string,
186
+ ): Promise<void> {
187
+ await deps.append(deps.chatsLogPath, buildArchiveChatEvent(deps.chatsById, chatId))
188
+ }
189
+
190
+ /** Unarchives a chat. */
191
+ export async function unarchiveChat(
192
+ deps: ChatTranscriptWriteDeps,
193
+ chatId: string,
194
+ ): Promise<void> {
195
+ await deps.append(deps.chatsLogPath, buildUnarchiveChatEvent(deps.chatsById, chatId))
196
+ }
197
+
198
+ /** Prunes empty chats that have been idle past the max age. Returns pruned chat IDs. */
199
+ export async function pruneStaleEmptyChats(
200
+ deps: ChatTranscriptWriteDeps,
201
+ args?: {
202
+ now?: number
203
+ maxAgeMs?: number
204
+ activeChatIds?: Iterable<string>
205
+ protectedChatIds?: Iterable<string>
206
+ },
207
+ ): Promise<string[]> {
208
+ const now = args?.now ?? Date.now()
209
+ const maxAgeMs = args?.maxAgeMs ?? STALE_EMPTY_CHAT_MAX_AGE_MS
210
+ const protectedChatIds = new Set([
211
+ ...(args?.activeChatIds ?? []),
212
+ ...(args?.protectedChatIds ?? []),
213
+ ])
214
+ const prunedChatIds: string[] = []
215
+
216
+ for (const chat of deps.chatsById.values()) {
217
+ if (chat.deletedAt || chat.archivedAt || protectedChatIds.has(chat.id)) continue
218
+ if (now - chat.createdAt < maxAgeMs) continue
219
+ if (chat.hasMessages) continue
220
+ if (deps.getMessages(chat.id).length > 0) {
221
+ chat.hasMessages = true
222
+ continue
223
+ }
224
+
225
+ const event: ChatEvent = {
226
+ v: STORE_VERSION,
227
+ type: "chat_deleted",
228
+ timestamp: now,
229
+ chatId: chat.id,
230
+ }
231
+ await deps.append(deps.chatsLogPath, event)
232
+
233
+ const tPath = transcriptPath(deps, chat.id)
234
+ await deps.storage.remove(tPath)
235
+ deps.transcriptCache.invalidate(chat.id)
236
+ deps.clearChatOps(chat.id)
237
+ await removeSubagentResultsDir(deps, chat.projectId, chat.id)
238
+
239
+ prunedChatIds.push(chat.id)
240
+ }
241
+
242
+ return prunedChatIds
243
+ }
244
+
245
+ /** Appends a transcript entry for a chat, with deduplication by messageId. */
246
+ export async function appendMessage(
247
+ deps: ChatTranscriptWriteDeps,
248
+ chatId: string,
249
+ entry: TranscriptEntry,
250
+ ): Promise<void> {
251
+ requireChat(deps, chatId)
252
+ const payload = `${JSON.stringify(entry)}\n`
253
+ const tPath = transcriptPath(deps, chatId)
254
+ const queuedAt = performance.now()
255
+ const newChain = deps.getWriteChain().then(async () => {
256
+ const startedAt = performance.now()
257
+ const queueDelayMs = Number((startedAt - queuedAt).toFixed(1))
258
+ const mid = entry.messageId
259
+ if (typeof mid === "string" && mid.length > 0) {
260
+ deps.ensureTranscriptLoaded(chatId)
261
+ const seen = deps.getSeenMessageIds(chatId)
262
+ if (seen.has(mid)) {
263
+ logSendToStartingProfile("event_store.append_message_dedup", {
264
+ chatId,
265
+ messageId: mid,
266
+ kind: entry.kind,
267
+ })
268
+ return
269
+ }
270
+ seen.add(mid)
271
+ }
272
+ await deps.storage.mkdir(deps.transcriptsDir)
273
+ const beforeAppendAt = performance.now()
274
+ await deps.storage.appendText(tPath, payload)
275
+ const afterAppendAt = performance.now()
276
+ applyChatMessageMetadata(deps.chatsById, chatId, entry)
277
+ deps.transcriptCache.appendTo(chatId, { ...entry })
278
+ deps.recordChatOp(chatId, { kind: "entries.append", entries: [{ ...entry }] })
279
+ logSendToStartingProfile("event_store.append_message", {
280
+ chatId,
281
+ entryId: entry._id,
282
+ kind: entry.kind,
283
+ payloadBytes: payload.length,
284
+ queueDelayMs,
285
+ appendMs: Number((afterAppendAt - beforeAppendAt).toFixed(1)),
286
+ totalMs: Number((afterAppendAt - queuedAt).toFixed(1)),
287
+ })
288
+ })
289
+ deps.setWriteChain(newChain)
290
+ return newChain
291
+ }