@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,365 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { QueuedChatMessage, TranscriptEntry } from "../shared/types"
3
+ import type { StorageBackend } from "./storage/backend"
4
+ import type { ToolRequest } from "../shared/permission-policy"
5
+ import type { ChatRecord } from "./events"
6
+ import {
7
+ getChatCount,
8
+ getMessages,
9
+ getMessagesPageBefore,
10
+ getQueuedMessage,
11
+ getQueuedMessages,
12
+ getRecentChatHistory,
13
+ getRecentMessagesPage,
14
+ getSeenMessageIds,
15
+ loadTranscriptFromDisk,
16
+ type CachedTranscriptRef,
17
+ type MessageReadDeps,
18
+ } from "./event-store-messages.adapter"
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function makeStorage(files: Map<string, string> = new Map()): StorageBackend {
25
+ return {
26
+ mkdir: async () => {},
27
+ exists: async (p) => files.has(p),
28
+ existsSync: (p) => files.has(p),
29
+ size: async (p) => files.get(p)?.length ?? 0,
30
+ readText: async (p) => files.get(p) ?? "",
31
+ readTextSync: (p) => files.get(p) ?? "",
32
+ writeText: async (p, v) => { files.set(p, v) },
33
+ appendText: async (p, v) => { files.set(p, (files.get(p) ?? "") + v) },
34
+ rename: async () => {},
35
+ remove: async () => {},
36
+ }
37
+ }
38
+
39
+ function makeTranscriptEntry(_chatId = "chat-1", kind: "user_prompt" | "assistant_text" = "user_prompt", extra: Partial<TranscriptEntry> = {}): TranscriptEntry {
40
+ const _id = `${kind}-${Math.random()}`
41
+ if (kind === "user_prompt") {
42
+ return { _id, createdAt: 1000, kind: "user_prompt", content: "hello", ...extra } as TranscriptEntry
43
+ }
44
+ return { _id, createdAt: 1001, kind: "assistant_text", text: "world", ...extra } as TranscriptEntry
45
+ }
46
+
47
+ function makeQueuedMessage(overrides: Partial<QueuedChatMessage> = {}): QueuedChatMessage {
48
+ return {
49
+ id: "qm-1",
50
+ content: "queued message",
51
+ attachments: [],
52
+ createdAt: 2000,
53
+ ...overrides,
54
+ }
55
+ }
56
+
57
+ function makeChatRecord(overrides: Partial<ChatRecord> = {}): ChatRecord {
58
+ return {
59
+ id: "chat-1",
60
+ projectId: "proj-1",
61
+ title: "Chat 1",
62
+ createdAt: 1000,
63
+ updatedAt: 1000,
64
+ unread: false,
65
+ provider: null,
66
+ planMode: false,
67
+ sessionTokensByProvider: {},
68
+ sourceHash: null,
69
+ lastTurnOutcome: null,
70
+ ...overrides,
71
+ }
72
+ }
73
+
74
+ function makeDeps(overrides: Partial<MessageReadDeps> = {}): MessageReadDeps {
75
+ return {
76
+ storage: makeStorage(),
77
+ transcriptsDir: "/data/transcripts",
78
+ cachedTranscriptRef: { value: null },
79
+ legacyMessagesByChatId: new Map(),
80
+ seenMessageIdsByChatId: new Map(),
81
+ queuedMessagesByChatId: new Map(),
82
+ chatsById: new Map(),
83
+ listPendingToolRequests: (_chatId) => [],
84
+ ...overrides,
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // getSeenMessageIds
90
+ // ---------------------------------------------------------------------------
91
+
92
+ describe("getSeenMessageIds", () => {
93
+ test("creates an empty set for unknown chatId", () => {
94
+ const deps = makeDeps()
95
+ const set = getSeenMessageIds(deps, "chat-x")
96
+ expect(set.size).toBe(0)
97
+ })
98
+
99
+ test("returns the same set on repeated calls for the same chatId", () => {
100
+ const deps = makeDeps()
101
+ const s1 = getSeenMessageIds(deps, "chat-x")
102
+ s1.add("msg-1")
103
+ const s2 = getSeenMessageIds(deps, "chat-x")
104
+ expect(s2.has("msg-1")).toBe(true)
105
+ expect(s1).toBe(s2)
106
+ })
107
+
108
+ test("returns distinct sets for different chatIds", () => {
109
+ const deps = makeDeps()
110
+ const sa = getSeenMessageIds(deps, "chat-a")
111
+ const sb = getSeenMessageIds(deps, "chat-b")
112
+ expect(sa).not.toBe(sb)
113
+ })
114
+ })
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // loadTranscriptFromDisk
118
+ // ---------------------------------------------------------------------------
119
+
120
+ describe("loadTranscriptFromDisk", () => {
121
+ test("returns empty array when file does not exist", () => {
122
+ const deps = makeDeps()
123
+ expect(loadTranscriptFromDisk(deps, "chat-1")).toEqual([])
124
+ })
125
+
126
+ test("returns empty array for empty file", () => {
127
+ const files = new Map([["/data/transcripts/chat-1.jsonl", ""]])
128
+ const deps = makeDeps({ storage: makeStorage(files) })
129
+ expect(loadTranscriptFromDisk(deps, "chat-1")).toEqual([])
130
+ })
131
+
132
+ test("parses entries from JSONL file", () => {
133
+ const e1 = makeTranscriptEntry("chat-1", "user_prompt")
134
+ const e2 = makeTranscriptEntry("chat-1", "assistant_text")
135
+ const content = `${JSON.stringify(e1)}\n${JSON.stringify(e2)}\n`
136
+ const files = new Map([["/data/transcripts/chat-1.jsonl", content]])
137
+ const deps = makeDeps({ storage: makeStorage(files) })
138
+
139
+ const result = loadTranscriptFromDisk(deps, "chat-1")
140
+ expect(result.length).toBe(2)
141
+ expect(result[0]!._id).toBe(e1._id)
142
+ expect(result[1]!._id).toBe(e2._id)
143
+ })
144
+
145
+ test("populates seenMessageIds set for entries with messageId", () => {
146
+ const e1: TranscriptEntry = { ...makeTranscriptEntry("chat-1"), messageId: "mid-1" } as TranscriptEntry & { messageId: string }
147
+ const content = `${JSON.stringify(e1)}\n`
148
+ const files = new Map([["/data/transcripts/chat-1.jsonl", content]])
149
+ const seenMessageIdsByChatId = new Map<string, Set<string>>()
150
+ const deps = makeDeps({ storage: makeStorage(files), seenMessageIdsByChatId })
151
+
152
+ loadTranscriptFromDisk(deps, "chat-1")
153
+
154
+ const seen = seenMessageIdsByChatId.get("chat-1")
155
+ expect(seen?.has("mid-1")).toBe(true)
156
+ })
157
+ })
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // getMessages
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("getMessages", () => {
164
+ test("returns from cache when chatId matches", () => {
165
+ const e1 = makeTranscriptEntry("chat-1")
166
+ const cachedTranscriptRef: CachedTranscriptRef = {
167
+ value: { chatId: "chat-1", entries: [e1] },
168
+ }
169
+ const deps = makeDeps({ cachedTranscriptRef })
170
+
171
+ const result = getMessages(deps, "chat-1")
172
+ expect(result[0]!._id).toBe(e1._id)
173
+ })
174
+
175
+ test("reads from legacy map and populates cache", () => {
176
+ const e1 = makeTranscriptEntry("chat-2")
177
+ const legacyMessagesByChatId = new Map([["chat-2", [e1]]])
178
+ const cachedTranscriptRef: CachedTranscriptRef = { value: null }
179
+ const deps = makeDeps({ legacyMessagesByChatId, cachedTranscriptRef })
180
+
181
+ const result = getMessages(deps, "chat-2")
182
+ expect(result[0]!._id).toBe(e1._id)
183
+ expect(cachedTranscriptRef.value?.chatId).toBe("chat-2")
184
+ })
185
+
186
+ test("loads from disk when no cache and no legacy data", () => {
187
+ const e1 = makeTranscriptEntry("chat-3")
188
+ const content = `${JSON.stringify(e1)}\n`
189
+ const files = new Map([["/data/transcripts/chat-3.jsonl", content]])
190
+ const cachedTranscriptRef: CachedTranscriptRef = { value: null }
191
+ const deps = makeDeps({ storage: makeStorage(files), cachedTranscriptRef })
192
+
193
+ const result = getMessages(deps, "chat-3")
194
+ expect(result[0]!._id).toBe(e1._id)
195
+ expect(cachedTranscriptRef.value?.chatId).toBe("chat-3")
196
+ })
197
+
198
+ test("returns a clone (not the cached reference)", () => {
199
+ const e1 = makeTranscriptEntry("chat-1")
200
+ const entries = [e1]
201
+ const cachedTranscriptRef: CachedTranscriptRef = {
202
+ value: { chatId: "chat-1", entries },
203
+ }
204
+ const deps = makeDeps({ cachedTranscriptRef })
205
+
206
+ const r1 = getMessages(deps, "chat-1")
207
+ const r2 = getMessages(deps, "chat-1")
208
+ expect(r1).not.toBe(r2)
209
+ expect(r1).not.toBe(entries)
210
+ })
211
+ })
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // getQueuedMessages
215
+ // ---------------------------------------------------------------------------
216
+
217
+ describe("getQueuedMessages", () => {
218
+ test("returns empty array for unknown chat", () => {
219
+ const deps = makeDeps()
220
+ expect(getQueuedMessages(deps, "chat-x")).toEqual([])
221
+ })
222
+
223
+ test("returns messages with cloned attachments", () => {
224
+ const msg = makeQueuedMessage({ id: "qm-1", attachments: [] })
225
+ const queuedMessagesByChatId = new Map([["chat-1", [msg]]])
226
+ const deps = makeDeps({ queuedMessagesByChatId })
227
+
228
+ const result = getQueuedMessages(deps, "chat-1")
229
+ expect(result[0]!.id).toBe("qm-1")
230
+ expect(result[0]!.attachments).not.toBe(msg.attachments)
231
+ })
232
+ })
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // getQueuedMessage
236
+ // ---------------------------------------------------------------------------
237
+
238
+ describe("getQueuedMessage", () => {
239
+ test("returns null for unknown queuedMessageId", () => {
240
+ const msg = makeQueuedMessage({ id: "qm-1" })
241
+ const queuedMessagesByChatId = new Map([["chat-1", [msg]]])
242
+ const deps = makeDeps({ queuedMessagesByChatId })
243
+ expect(getQueuedMessage(deps, "chat-1", "qm-99")).toBeNull()
244
+ })
245
+
246
+ test("returns the matching message", () => {
247
+ const msg1 = makeQueuedMessage({ id: "qm-1", content: "first" })
248
+ const msg2 = makeQueuedMessage({ id: "qm-2", content: "second" })
249
+ const queuedMessagesByChatId = new Map([["chat-1", [msg1, msg2]]])
250
+ const deps = makeDeps({ queuedMessagesByChatId })
251
+
252
+ const result = getQueuedMessage(deps, "chat-1", "qm-2")
253
+ expect(result?.content).toBe("second")
254
+ })
255
+ })
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // getRecentMessagesPage
259
+ // ---------------------------------------------------------------------------
260
+
261
+ describe("getRecentMessagesPage", () => {
262
+ test("returns empty page for limit 0", () => {
263
+ const deps = makeDeps()
264
+ const page = getRecentMessagesPage(deps, "chat-1", 0)
265
+ expect(page.messages).toEqual([])
266
+ expect(page.hasOlder).toBe(false)
267
+ expect(page.olderCursor).toBeNull()
268
+ })
269
+
270
+ test("returns up to limit messages", () => {
271
+ const entries = [1, 2, 3, 4, 5].map((i) => ({
272
+ _id: `e-${i}`,
273
+ createdAt: i * 100,
274
+ kind: "assistant_text" as const,
275
+ text: `msg ${i}`,
276
+ }))
277
+ const cachedTranscriptRef: CachedTranscriptRef = {
278
+ value: { chatId: "chat-1", entries },
279
+ }
280
+ const deps = makeDeps({ cachedTranscriptRef })
281
+ const page = getRecentMessagesPage(deps, "chat-1", 3)
282
+ expect(page.messages.length).toBe(3)
283
+ })
284
+
285
+ test("hasOlder is true when there are more messages than limit", () => {
286
+ const entries = [1, 2, 3, 4, 5].map((i) => ({
287
+ _id: `e-${i}`,
288
+ createdAt: i * 100,
289
+ kind: "assistant_text" as const,
290
+ text: `msg ${i}`,
291
+ }))
292
+ const cachedTranscriptRef: CachedTranscriptRef = {
293
+ value: { chatId: "chat-1", entries },
294
+ }
295
+ const deps = makeDeps({ cachedTranscriptRef })
296
+ const page = getRecentMessagesPage(deps, "chat-1", 3)
297
+ expect(page.hasOlder).toBe(true)
298
+ })
299
+ })
300
+
301
+ // ---------------------------------------------------------------------------
302
+ // getMessagesPageBefore
303
+ // ---------------------------------------------------------------------------
304
+
305
+ describe("getMessagesPageBefore", () => {
306
+ test("returns empty page for limit 0", () => {
307
+ const deps = makeDeps()
308
+ const page = getMessagesPageBefore(deps, "chat-1", "cursor:0", 0)
309
+ expect(page.messages).toEqual([])
310
+ expect(page.hasOlder).toBe(false)
311
+ })
312
+ })
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // getRecentChatHistory
316
+ // ---------------------------------------------------------------------------
317
+
318
+ describe("getRecentChatHistory", () => {
319
+ test("includes pending tool request entries", () => {
320
+ const req = {
321
+ id: "req-1",
322
+ chatId: "chat-1",
323
+ toolName: "Bash",
324
+ arguments: { command: "ls" },
325
+ createdAt: 9000,
326
+ } as unknown as ToolRequest
327
+ const listPendingToolRequests = (_chatId: string) => [req]
328
+ const deps = makeDeps({ listPendingToolRequests })
329
+
330
+ const { messages } = getRecentChatHistory(deps, "chat-1", 50)
331
+ const pendingEntry = messages.find((m) => m.kind === "pending_tool_request")
332
+ expect(pendingEntry).toBeDefined()
333
+ expect(pendingEntry?.kind === "pending_tool_request" && pendingEntry.toolName).toBe("Bash")
334
+ })
335
+
336
+ test("returns history snapshot", () => {
337
+ const deps = makeDeps()
338
+ const result = getRecentChatHistory(deps, "chat-1", 50)
339
+ expect(result.history).toBeDefined()
340
+ expect(result.messages).toBeDefined()
341
+ })
342
+ })
343
+
344
+ // ---------------------------------------------------------------------------
345
+ // getChatCount
346
+ // ---------------------------------------------------------------------------
347
+
348
+ describe("getChatCount", () => {
349
+ test("returns 0 for project with no chats", () => {
350
+ const deps = makeDeps()
351
+ expect(getChatCount(deps, "proj-1")).toBe(0)
352
+ })
353
+
354
+ test("counts only non-deleted, non-archived chats for the project", () => {
355
+ const chatsById = new Map([
356
+ ["c1", makeChatRecord({ id: "c1", projectId: "proj-1" })],
357
+ ["c2", makeChatRecord({ id: "c2", projectId: "proj-1", deletedAt: 1000 })],
358
+ ["c3", makeChatRecord({ id: "c3", projectId: "proj-1", archivedAt: 1000 })],
359
+ ["c4", makeChatRecord({ id: "c4", projectId: "proj-2" })],
360
+ ])
361
+ const deps = makeDeps({ chatsById })
362
+ expect(getChatCount(deps, "proj-1")).toBe(1)
363
+ expect(getChatCount(deps, "proj-2")).toBe(1)
364
+ })
365
+ })
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Message and transcript read operations extracted from event-store.ts.
3
+ *
4
+ * `loadTranscriptFromDisk` performs synchronous disk IO, so this module is an
5
+ * adapter (`.adapter.ts`). All other functions in this file are pure in-memory
6
+ * reads — they are co-located here for cohesion because they share the same
7
+ * deps interface and depend on `loadTranscriptFromDisk`.
8
+ *
9
+ * This module must NOT import from event-store.ts (no circular deps).
10
+ */
11
+ import path from "node:path"
12
+ import type { ChatHistoryPage, QueuedChatMessage, TranscriptEntry } from "../shared/types"
13
+ import type { StorageBackend } from "./storage/backend"
14
+ import type { ToolRequest } from "../shared/permission-policy"
15
+ import type { ChatRecord, StoreState } from "./events"
16
+ import { cloneTranscriptEntries } from "./events"
17
+ import {
18
+ coalesceContextWindowUpdates,
19
+ decodeCursor,
20
+ getHistorySnapshot,
21
+ getMessagesPageFromEntries,
22
+ } from "./event-store-helpers"
23
+
24
+ // ─── Mutable ref for the transcript cache ────────────────────────────────
25
+
26
+ /** Wraps the mutable `cachedTranscript` field so extracted functions can read and replace it. */
27
+ export interface CachedTranscriptRef {
28
+ value: { chatId: string; entries: TranscriptEntry[] } | null
29
+ }
30
+
31
+ // ─── Deps interface ────────────────────────────────────────────────────────
32
+
33
+ export interface MessageReadDeps {
34
+ readonly storage: StorageBackend
35
+ readonly transcriptsDir: string
36
+ readonly cachedTranscriptRef: CachedTranscriptRef
37
+ readonly legacyMessagesByChatId: Map<string, TranscriptEntry[]>
38
+ readonly seenMessageIdsByChatId: Map<string, Set<string>>
39
+ readonly queuedMessagesByChatId: StoreState["queuedMessagesByChatId"]
40
+ readonly chatsById: Map<string, ChatRecord>
41
+ listPendingToolRequests: (chatId: string) => ToolRequest[]
42
+ }
43
+
44
+ // ─── Private helpers ───────────────────────────────────────────────────────
45
+
46
+ function transcriptPath(deps: MessageReadDeps, chatId: string): string {
47
+ return path.join(deps.transcriptsDir, `${chatId}.jsonl`)
48
+ }
49
+
50
+ // ─── Exported functions ────────────────────────────────────────────────────
51
+
52
+ /** Returns (or lazily creates) the seen-messageId dedup set for a chat. */
53
+ export function getSeenMessageIds(deps: MessageReadDeps, chatId: string): Set<string> {
54
+ let set = deps.seenMessageIdsByChatId.get(chatId)
55
+ if (!set) {
56
+ set = new Set<string>()
57
+ deps.seenMessageIdsByChatId.set(chatId, set)
58
+ }
59
+ return set
60
+ }
61
+
62
+ /**
63
+ * Reads transcript entries from disk synchronously.
64
+ * Populates the seenMessageIds set as a side-effect.
65
+ */
66
+ export function loadTranscriptFromDisk(
67
+ deps: MessageReadDeps,
68
+ chatId: string,
69
+ ): TranscriptEntry[] {
70
+ const tPath = transcriptPath(deps, chatId)
71
+ if (!deps.storage.existsSync(tPath)) {
72
+ return []
73
+ }
74
+
75
+ const text = deps.storage.readTextSync(tPath)
76
+ if (!text.trim()) return []
77
+
78
+ const entries: TranscriptEntry[] = []
79
+ const seen = getSeenMessageIds(deps, chatId)
80
+ for (const rawLine of text.split("\n")) {
81
+ const line = rawLine.trim()
82
+ if (!line) continue
83
+ const entry: TranscriptEntry & { messageId?: string } = JSON.parse(line)
84
+ entries.push(entry)
85
+ const mid = entry.messageId
86
+ if (typeof mid === "string" && mid.length > 0) {
87
+ seen.add(mid)
88
+ }
89
+ }
90
+ return entries
91
+ }
92
+
93
+ /**
94
+ * Returns cloned transcript entries for `chatId`, using the in-memory cache
95
+ * or loading from disk as needed.
96
+ */
97
+ export function getMessages(deps: MessageReadDeps, chatId: string): TranscriptEntry[] {
98
+ if (deps.cachedTranscriptRef.value?.chatId === chatId) {
99
+ return cloneTranscriptEntries(deps.cachedTranscriptRef.value.entries)
100
+ }
101
+
102
+ const legacyEntries = deps.legacyMessagesByChatId.get(chatId)
103
+ if (legacyEntries) {
104
+ deps.cachedTranscriptRef.value = { chatId, entries: cloneTranscriptEntries(legacyEntries) }
105
+ return cloneTranscriptEntries(deps.cachedTranscriptRef.value.entries)
106
+ }
107
+
108
+ const entries = loadTranscriptFromDisk(deps, chatId)
109
+ deps.cachedTranscriptRef.value = { chatId, entries }
110
+ return cloneTranscriptEntries(entries)
111
+ }
112
+
113
+ /** Returns queued messages for a chat, with attachment arrays cloned. */
114
+ export function getQueuedMessages(
115
+ deps: MessageReadDeps,
116
+ chatId: string,
117
+ ): QueuedChatMessage[] {
118
+ const entries = deps.queuedMessagesByChatId.get(chatId) ?? []
119
+ return entries.map((entry) => ({
120
+ ...entry,
121
+ attachments: [...entry.attachments],
122
+ }))
123
+ }
124
+
125
+ /** Returns a single queued message by id, or null. */
126
+ export function getQueuedMessage(
127
+ deps: MessageReadDeps,
128
+ chatId: string,
129
+ queuedMessageId: string,
130
+ ): QueuedChatMessage | null {
131
+ return getQueuedMessages(deps, chatId).find((entry) => entry.id === queuedMessageId) ?? null
132
+ }
133
+
134
+ /** Returns the most recent page of transcript messages. */
135
+ export function getRecentMessagesPage(
136
+ deps: MessageReadDeps,
137
+ chatId: string,
138
+ limit: number,
139
+ ): ChatHistoryPage {
140
+ if (limit <= 0) {
141
+ return { messages: [], hasOlder: false, olderCursor: null }
142
+ }
143
+
144
+ const entries = coalesceContextWindowUpdates(getMessages(deps, chatId))
145
+ const page = getMessagesPageFromEntries(entries, limit)
146
+
147
+ return {
148
+ messages: page.entries,
149
+ hasOlder: page.hasOlder,
150
+ olderCursor: page.olderCursor,
151
+ }
152
+ }
153
+
154
+ /** Returns a page of transcript messages before the given cursor. */
155
+ export function getMessagesPageBefore(
156
+ deps: MessageReadDeps,
157
+ chatId: string,
158
+ beforeCursor: string,
159
+ limit: number,
160
+ ): ChatHistoryPage {
161
+ if (limit <= 0) {
162
+ return { messages: [], hasOlder: false, olderCursor: null }
163
+ }
164
+
165
+ // Coalesce identically to getRecentMessagesPage so cursors (which index the
166
+ // coalesced array) stay consistent across recent + load-older paging.
167
+ const beforeIndex = decodeCursor(beforeCursor)
168
+ const entries = coalesceContextWindowUpdates(getMessages(deps, chatId))
169
+ const page = getMessagesPageFromEntries(entries, limit, beforeIndex)
170
+
171
+ return {
172
+ messages: page.entries,
173
+ hasOlder: page.hasOlder,
174
+ olderCursor: page.olderCursor,
175
+ }
176
+ }
177
+
178
+ /** Returns merged transcript + pending tool request entries, plus a history snapshot. */
179
+ export function getRecentChatHistory(
180
+ deps: MessageReadDeps,
181
+ chatId: string,
182
+ recentLimit: number,
183
+ ) {
184
+ const page = getRecentMessagesPage(deps, chatId, recentLimit)
185
+ const pending = deps.listPendingToolRequests(chatId)
186
+ const pendingEntries: TranscriptEntry[] = pending.map((req) => ({
187
+ _id: `pending-tool-request-${req.id}`,
188
+ createdAt: req.createdAt,
189
+ kind: "pending_tool_request",
190
+ toolRequestId: req.id,
191
+ toolName: req.toolName,
192
+ arguments: req.arguments,
193
+ }))
194
+ const merged = [...page.messages, ...pendingEntries]
195
+ return {
196
+ messages: merged,
197
+ history: getHistorySnapshot(
198
+ {
199
+ entries: merged,
200
+ hasOlder: page.hasOlder,
201
+ olderCursor: page.olderCursor,
202
+ },
203
+ recentLimit,
204
+ ),
205
+ }
206
+ }
207
+
208
+ /** Returns the count of active (non-deleted, non-archived) chats for a project. */
209
+ export function getChatCount(deps: MessageReadDeps, projectId: string): number {
210
+ return [...deps.chatsById.values()].filter(
211
+ (chat) => chat.projectId === projectId && !chat.deletedAt && !chat.archivedAt,
212
+ ).length
213
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Orchestration event write-path + subscription management extracted from
3
+ * event-store.ts.
4
+ *
5
+ * Wraps the pure read functions from event-store-orch.ts and adds:
6
+ * - `appendOrchestrationEvent` — sync apply + async disk enqueue
7
+ * - `subscribeOrchRuns` / `notifyOrchRunsChanged` — observer pattern
8
+ *
9
+ * All functions use injected deps so they carry no direct IO. EventStore
10
+ * is the sole owner of the underlying maps and write-chain.
11
+ *
12
+ * Must NOT import from event-store.ts (no circular deps).
13
+ */
14
+ import { LOG_PREFIX } from "../shared/branding"
15
+ import { log } from "../shared/log"
16
+ import type { OrchRunSnapshot } from "../shared/orchestration-types"
17
+ import type { OrchestrationEvent, OrchRunRecord } from "./events"
18
+ import {
19
+ gatedOrchTasks as gatedOrchTasksFn,
20
+ getAllOrchRunSnapshots,
21
+ getOrchLastPhaseOutput as getOrchLastPhaseOutputFn,
22
+ getOrchRunEvents as getOrchRunEventsFn,
23
+ getOrchRunSnapshot,
24
+ getOrchTaskSpec as getOrchTaskSpecFn,
25
+ nonTerminalOrchTasks as nonTerminalOrchTasksFn,
26
+ } from "./event-store-orch"
27
+
28
+ // ─── Deps interface ────────────────────────────────────────────────────────
29
+
30
+ export interface OrchSubscriptionDeps {
31
+ readonly orchRunsById: Map<string, OrchRunRecord>
32
+ readonly orchLogPath: string
33
+ readonly orchRunsSubscribers: Set<() => void>
34
+ applyEvent: (event: OrchestrationEvent) => void
35
+ enqueueDiskAppend: (filePath: string, payload: string) => void
36
+ }
37
+
38
+ // ─── Subscription state ───────────────────────────────────────────────────
39
+
40
+ /** Create the mutable subscription state held by EventStore. */
41
+ export function createOrchSubscriptionState(): { orchRunsSubscribers: Set<() => void> } {
42
+ return { orchRunsSubscribers: new Set() }
43
+ }
44
+
45
+ // ─── Write path ────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Apply synchronously, then enqueue the disk append — the sync apply is what
49
+ * makes an orchestration claim atomic within one event-loop turn (same
50
+ * pattern as appendSubagentEvent).
51
+ */
52
+ export function appendOrchestrationEvent(
53
+ deps: OrchSubscriptionDeps,
54
+ event: OrchestrationEvent,
55
+ ): Promise<void> {
56
+ deps.applyEvent(event)
57
+ deps.enqueueDiskAppend(deps.orchLogPath, `${JSON.stringify(event)}\n`)
58
+ notifyOrchRunsChanged(deps)
59
+ return Promise.resolve()
60
+ }
61
+
62
+ /**
63
+ * Observe orchestration read-model changes. The callback fires after each
64
+ * live orch event is applied (not during boot replay — no subscribers yet).
65
+ * Returns an unsubscribe fn.
66
+ */
67
+ export function subscribeOrchRuns(deps: OrchSubscriptionDeps, cb: () => void): () => void {
68
+ deps.orchRunsSubscribers.add(cb)
69
+ return () => { deps.orchRunsSubscribers.delete(cb) }
70
+ }
71
+
72
+ function notifyOrchRunsChanged(deps: OrchSubscriptionDeps): void {
73
+ for (const cb of deps.orchRunsSubscribers) {
74
+ try { cb() } catch (err) { log.warn(`${LOG_PREFIX} orch-runs subscriber threw`, { err }) }
75
+ }
76
+ }
77
+
78
+ // ─── Read-model thin wrappers ──────────────────────────────────────────────
79
+
80
+ export function getOrchRun(
81
+ orchRunsById: Map<string, OrchRunRecord>,
82
+ runId: string,
83
+ ): OrchRunSnapshot | null {
84
+ return getOrchRunSnapshot(orchRunsById, runId)
85
+ }
86
+
87
+ export function getOrchRuns(orchRunsById: Map<string, OrchRunRecord>): OrchRunSnapshot[] {
88
+ return getAllOrchRunSnapshots(orchRunsById)
89
+ }
90
+
91
+ export function nonTerminalOrchTasks(
92
+ orchRunsById: Map<string, OrchRunRecord>,
93
+ ): Iterable<{ runId: string; taskId: string; state: "claimed" | "running" }> {
94
+ return nonTerminalOrchTasksFn(orchRunsById)
95
+ }
96
+
97
+ export function gatedOrchTasks(
98
+ orchRunsById: Map<string, OrchRunRecord>,
99
+ ): Iterable<{ runId: string; taskId: string; phaseIndex: number }> {
100
+ return gatedOrchTasksFn(orchRunsById)
101
+ }
102
+
103
+ export function getOrchTaskSpec(
104
+ orchRunsById: Map<string, OrchRunRecord>,
105
+ runId: string,
106
+ taskId: string,
107
+ ): { prompt: string; scopePaths: string[] } | null {
108
+ return getOrchTaskSpecFn(orchRunsById, runId, taskId)
109
+ }
110
+
111
+ export function getOrchLastPhaseOutput(
112
+ orchRunsById: Map<string, OrchRunRecord>,
113
+ runId: string,
114
+ taskId: string,
115
+ ): string | null {
116
+ return getOrchLastPhaseOutputFn(orchRunsById, runId, taskId)
117
+ }
118
+
119
+ export function getOrchRunEvents(
120
+ orchRunsById: Map<string, OrchRunRecord>,
121
+ runId: string,
122
+ ): OrchestrationEvent[] {
123
+ return getOrchRunEventsFn(orchRunsById, runId)
124
+ }