@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,436 @@
1
+ /**
2
+ * claude-message-normalizer.ts
3
+ *
4
+ * Pure message normalisation layer: converts raw Claude SDK stream messages
5
+ * (ClaudeRawSdkMessage) → typed TranscriptEntry[]. No IO, no side effects.
6
+ *
7
+ * Extracted from agent.ts — see adr (decompose-large-files chunk).
8
+ */
9
+
10
+ import type { TranscriptEntry } from "../shared/types"
11
+ import { type AnyValue, isRecord } from "../shared/errors"
12
+ import { normalizeToolCall } from "../shared/tools"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Utility helpers (private to this module)
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function stringFromUnknown<T>(value: T): string {
19
+ if (typeof value === "string") return value
20
+ if (value === undefined || value === null) return ""
21
+ try {
22
+ // JSON.stringify returns the JS value `undefined` (not a string) for
23
+ // functions/symbols, which would drop the `result` key on persist and
24
+ // break the `result: string` contract; coerce to "" in that case.
25
+ return JSON.stringify(value, null, 2) ?? ""
26
+ } catch {
27
+ return String(value)
28
+ }
29
+ }
30
+
31
+ function normalizeMcpServerEntry(s: AnyValue): { name: string; status: string } {
32
+ if (typeof s === "string") return { name: s, status: "connected" }
33
+ if (isRecord(s) && typeof s.name === "string") {
34
+ return { name: s.name, status: typeof s.status === "string" ? s.status : "connected" }
35
+ }
36
+ return { name: String(s), status: "connected" }
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // timestamped — stamps any Omit<TranscriptEntry, "_id" | "createdAt"> with ids
41
+ // ---------------------------------------------------------------------------
42
+
43
+ export function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
44
+ entry: T,
45
+ createdAt = Date.now()
46
+ ) {
47
+ return {
48
+ _id: crypto.randomUUID(),
49
+ createdAt,
50
+ ...entry,
51
+ }
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Raw SDK message interfaces
56
+ // ---------------------------------------------------------------------------
57
+
58
+ // Minimal structural interface for raw SDK JSONL messages. All properties are
59
+ // optional so that both real SDK types and partial test fixtures are assignable.
60
+ // No `any` or `unknown` — every accessed field is typed concretely.
61
+ interface ClaudeRawContentBlock {
62
+ type?: string
63
+ text?: string
64
+ thinking?: string
65
+ signature?: string
66
+ name?: string
67
+ id?: string
68
+ // input is structurally opaque; passed straight through to normalizeToolCall
69
+ input?: AnyValue
70
+ tool_use_id?: string
71
+ // content is opaque (tool_result bodies have nested structures) — passed
72
+ // through as-is to ToolResultEntry.content which accepts any value.
73
+ content?: object | string | null
74
+ is_error?: boolean
75
+ }
76
+ interface ClaudeRawMessageBody {
77
+ id?: string
78
+ content?: ClaudeRawContentBlock[] | string
79
+ role?: string
80
+ model?: string
81
+ stop_reason?: string | null
82
+ usage?: AnyValue
83
+ }
84
+ export interface ClaudeRawSdkMessage {
85
+ type?: string
86
+ subtype?: string
87
+ uuid?: string
88
+ model?: string
89
+ tools?: string[]
90
+ agents?: string[]
91
+ slash_commands?: string[]
92
+ mcp_servers?: AnyValue[]
93
+ message?: ClaudeRawMessageBody
94
+ isApiErrorMessage?: boolean
95
+ apiErrorStatus?: number
96
+ request_id?: string
97
+ requestId?: string
98
+ is_error?: boolean
99
+ duration_ms?: number
100
+ result?: string
101
+ total_cost_usd?: number
102
+ status?: string | null
103
+ summary?: string
104
+ skip_transcript?: boolean
105
+ durationMs?: number
106
+ pendingWorkflowCount?: number
107
+ usage?: AnyValue
108
+ modelUsage?: AnyValue
109
+ // SDK rate-limit event fields
110
+ rate_limit_info?: Record<string, string | number | boolean | null>
111
+ session_id?: string
112
+ stop_reason?: string | null
113
+ // Task-notification fields
114
+ task_id?: string
115
+ output_file?: string
116
+ tool_use_id?: string
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public helpers
121
+ // ---------------------------------------------------------------------------
122
+
123
+ export function getClaudeAssistantMessageUsageId(message: ClaudeRawSdkMessage): string | null {
124
+ if (typeof message?.message?.id === "string" && message.message.id) {
125
+ return message.message.id
126
+ }
127
+ if (typeof message?.uuid === "string" && message.uuid) {
128
+ return message.uuid
129
+ }
130
+ return null
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Constants
135
+ // ---------------------------------------------------------------------------
136
+
137
+ // Benign turn-end markers the Claude CLI emits as model "<synthetic>" messages
138
+ // (the CVH-family constants in the CLI binary) when a turn ends with nothing to
139
+ // say. They carry isApiErrorMessage:false and carry zero information, so they
140
+ // are dropped entirely — never rendered as a red api_error card and never as an
141
+ // assistant_text bubble. In PTY channel-delivered turns the CLI emits one at the
142
+ // start of every turn; surfacing it as assistant_text flipped the UI out of its
143
+ // waiting state before the real reply streamed (spinner vanished, placeholder
144
+ // read as the answer). See adr-20260607-drop-synthetic-no-response-marker.
145
+ const SYNTHETIC_NON_ERROR_PLACEHOLDERS: ReadonlySet<string> = new Set([
146
+ "No response requested.",
147
+ "No action needed.",
148
+ "Nothing needed from you.",
149
+ ])
150
+
151
+ // Claude CLI hard-refusals (Usage-Policy / real-time cyber-safeguard block)
152
+ // arrive as a model "<synthetic>" message with stop_reason "refusal" and one of
153
+ // these phrases in the text. Used to split a deliberate refusal out of the
154
+ // generic api_error bucket. See adr-20260607-surface-policy-refusal-entry.
155
+ const POLICY_REFUSAL_TEXT_MARKERS: readonly string[] = [
156
+ "violate our Usage Policy",
157
+ "unable to respond to this request",
158
+ ]
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Private normalisation helpers
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export function normalizeToolContent(c: AnyValue): string | Record<string, unknown> | readonly unknown[] | null {
165
+ if (c === null || c === undefined) return null
166
+ if (typeof c === "string") return c
167
+ if (Array.isArray(c)) return c
168
+ if (isRecord(c)) return c
169
+ return null
170
+ }
171
+
172
+ // Type-bridge: ClaudeRawSdkMessage is a structural duck-type — all fields optional.
173
+ // Any SDK message object satisfies it at runtime via dynamic field access.
174
+ function isSdkToClaudeMessage(m: object): m is ClaudeRawSdkMessage {
175
+ void m
176
+ return true
177
+ }
178
+
179
+ export { isSdkToClaudeMessage }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Main normalisation function
183
+ // ---------------------------------------------------------------------------
184
+
185
+ export function normalizeClaudeStreamMessage(message: ClaudeRawSdkMessage): TranscriptEntry[] {
186
+ const debugRaw = JSON.stringify(message)
187
+ const messageId = typeof message.uuid === "string" ? message.uuid : undefined
188
+
189
+ if (message.type === "system" && message.subtype === "init") {
190
+ return [
191
+ timestamped({
192
+ kind: "system_init",
193
+ messageId,
194
+ provider: "claude",
195
+ model: typeof message.model === "string" ? message.model : "unknown",
196
+ tools: Array.isArray(message.tools) ? message.tools : [],
197
+ agents: Array.isArray(message.agents) ? message.agents : [],
198
+ slashCommands: Array.isArray(message.slash_commands)
199
+ ? message.slash_commands.filter((entry: string) => !entry.startsWith("._"))
200
+ : [],
201
+ mcpServers: Array.isArray(message.mcp_servers)
202
+ ? message.mcp_servers.map((s: AnyValue) => normalizeMcpServerEntry(s))
203
+ : [],
204
+ debugRaw,
205
+ }),
206
+ ]
207
+ }
208
+
209
+ if (message.type === "assistant" && Array.isArray(message.message?.content)) {
210
+ const joinedText = message.message.content
211
+ .filter((c): c is ClaudeRawContentBlock & { text: string } => c.type === "text" && typeof c.text === "string")
212
+ .map((c) => c.text)
213
+ .join("")
214
+ // The Claude CLI reuses model "<synthetic>" for two distinct purposes:
215
+ // genuine API errors AND benign turn-end placeholders ("No response
216
+ // requested." etc., the CVH-family constants in the CLI binary). The benign
217
+ // markers carry isApiErrorMessage:false, so a bare synthetic model is NOT
218
+ // sufficient to classify as an error — only treat it as api_error when the
219
+ // flag is set, or the synthetic text is not a known benign placeholder.
220
+ const isSyntheticModel = message.message?.model === "<synthetic>"
221
+ const isBenignSyntheticPlaceholder = isSyntheticModel
222
+ && SYNTHETIC_NON_ERROR_PLACEHOLDERS.has(joinedText.trim())
223
+ if (
224
+ message.isApiErrorMessage === true
225
+ || (isSyntheticModel && !isBenignSyntheticPlaceholder)
226
+ ) {
227
+ const statusFromField = typeof message.apiErrorStatus === "number" ? message.apiErrorStatus : undefined
228
+ const statusFromText = (() => {
229
+ const match = /API Error:\s*(\d{3})/i.exec(joinedText)
230
+ return match ? Number.parseInt(match[1], 10) : undefined
231
+ })()
232
+ let requestId: string | undefined
233
+ if (typeof message.request_id === "string") {
234
+ requestId = message.request_id
235
+ } else if (typeof message.requestId === "string") {
236
+ requestId = message.requestId
237
+ } else {
238
+ requestId = undefined
239
+ }
240
+ // A deliberate model refusal (Usage-Policy / cyber-safeguard block) is NOT
241
+ // a transport error — it carries stop_reason "refusal" and/or the policy
242
+ // phrase. Surface it as its own `policy_refusal` kind so the UI labels it
243
+ // "Blocked — Usage Policy" instead of a generic red API-error card that
244
+ // reads like a network failure. See adr-20260607-surface-policy-refusal-entry.
245
+ const isPolicyRefusal =
246
+ message.message?.stop_reason === "refusal"
247
+ || POLICY_REFUSAL_TEXT_MARKERS.some((marker) => joinedText.includes(marker))
248
+ if (isPolicyRefusal) {
249
+ return [timestamped({
250
+ kind: "policy_refusal",
251
+ messageId,
252
+ text: joinedText,
253
+ requestId,
254
+ debugRaw,
255
+ })]
256
+ }
257
+ return [timestamped({
258
+ kind: "api_error",
259
+ messageId,
260
+ status: statusFromField ?? statusFromText ?? 0,
261
+ text: joinedText,
262
+ requestId,
263
+ debugRaw,
264
+ })]
265
+ }
266
+ // Benign synthetic turn-end marker (not an api_error): drop it. The api_error
267
+ // branch above already claimed any isApiErrorMessage:true message, so a real
268
+ // error carrying the same text still surfaces. Turn termination is driven by
269
+ // the separate system/turn_duration → result message, not this placeholder.
270
+ if (isBenignSyntheticPlaceholder) {
271
+ return []
272
+ }
273
+ const entries: TranscriptEntry[] = []
274
+ for (const content of message.message.content) {
275
+ // Extended-reasoning block. Surface as its own kind so the UI renders it
276
+ // collapsed and the event log keeps reasoning distinct from output. A
277
+ // redacted block carries only a signature (empty thinking) — skip it.
278
+ if (content.type === "thinking" && typeof content.thinking === "string" && content.thinking.length > 0) {
279
+ entries.push(timestamped({
280
+ kind: "assistant_thinking",
281
+ messageId,
282
+ text: content.thinking,
283
+ signature: typeof content.signature === "string" ? content.signature : undefined,
284
+ debugRaw,
285
+ }))
286
+ }
287
+ if (content.type === "text" && typeof content.text === "string") {
288
+ entries.push(timestamped({
289
+ kind: "assistant_text",
290
+ messageId,
291
+ text: content.text,
292
+ debugRaw,
293
+ }))
294
+ }
295
+ if (content.type === "tool_use" && typeof content.name === "string" && typeof content.id === "string") {
296
+ entries.push(timestamped({
297
+ kind: "tool_call",
298
+ messageId,
299
+ tool: normalizeToolCall({
300
+ toolName: content.name,
301
+ toolId: content.id,
302
+ input: isRecord(content.input) ? content.input : {},
303
+ }),
304
+ debugRaw,
305
+ }))
306
+ }
307
+ }
308
+ return entries
309
+ }
310
+
311
+ if (message.type === "user" && Array.isArray(message.message?.content)) {
312
+ const entries: TranscriptEntry[] = []
313
+ for (const content of message.message.content) {
314
+ if (content.type === "tool_result" && typeof content.tool_use_id === "string") {
315
+ entries.push(timestamped({
316
+ kind: "tool_result",
317
+ messageId,
318
+ toolId: content.tool_use_id,
319
+ content: normalizeToolContent(content.content),
320
+ isError: Boolean(content.is_error),
321
+ debugRaw,
322
+ }))
323
+ }
324
+ if (message.message.role === "user" && typeof message.message.content === "string") {
325
+ entries.push(timestamped({
326
+ kind: "compact_summary",
327
+ messageId,
328
+ summary: message.message.content,
329
+ debugRaw,
330
+ }))
331
+ }
332
+ }
333
+ return entries
334
+ }
335
+
336
+ // No `result.subtype === "compaction"` branch by design: Kanna never relies
337
+ // on the SDK's in-loop auto-compact. The SDK `query()` driver spawns a fresh
338
+ // subprocess per turn and never enters claude-code's REPL loop, so that
339
+ // compaction stop is unreachable here (see proactive-compact.ts). Context
340
+ // compaction is instead driven by Kanna injecting a native `/compact` turn
341
+ // and surfaces purely as the `system/compact_boundary` message handled
342
+ // below — not as a result subtype.
343
+ if (message.type === "result") {
344
+ if (message.subtype === "cancelled") {
345
+ return [timestamped({ kind: "interrupted", messageId, debugRaw })]
346
+ }
347
+ return [
348
+ timestamped({
349
+ kind: "result",
350
+ messageId,
351
+ subtype: message.is_error ? "error" : "success",
352
+ isError: Boolean(message.is_error),
353
+ durationMs: typeof message.duration_ms === "number" ? message.duration_ms : 0,
354
+ result: typeof message.result === "string" ? message.result : stringFromUnknown(message.result),
355
+ costUsd: typeof message.total_cost_usd === "number" ? message.total_cost_usd : undefined,
356
+ debugRaw,
357
+ }),
358
+ ]
359
+ }
360
+
361
+ if (message.type === "system" && message.subtype === "status" && typeof message.status === "string") {
362
+ return [timestamped({ kind: "status", messageId, status: message.status, debugRaw })]
363
+ }
364
+
365
+ // The Agent SDK emits SDKTaskNotificationMessage when a
366
+ // `Bash(run_in_background)` task settles (status completed|failed|stopped).
367
+ // The model is re-driven natively by the SDK's user-origin task-notification
368
+ // message (the `canUseTool`-after-result self-resume noted in send()), so
369
+ // this branch only SURFACES the completion into the transcript/event log —
370
+ // without it the background work was invisible to Kanna. `skip_transcript`
371
+ // marks ambient/housekeeping tasks the SDK asks consumers to hide inline.
372
+ if (message.type === "system" && message.subtype === "task_notification") {
373
+ const taskStatus = typeof message.status === "string" ? message.status : "completed"
374
+ const summary = typeof message.summary === "string" && message.summary.length > 0
375
+ ? message.summary
376
+ : "(no summary)"
377
+ const taskId = typeof message.task_id === "string" ? message.task_id : undefined
378
+ return [timestamped({
379
+ kind: "status",
380
+ messageId,
381
+ status: `Background task ${taskStatus}: ${summary}`,
382
+ hidden: message.skip_transcript === true ? true : undefined,
383
+ backgroundTaskId: taskId,
384
+ debugRaw,
385
+ })]
386
+ }
387
+
388
+ // Interactive TUI claude never writes a `type: "result"` row — it writes
389
+ // `system/turn_duration` instead (per canon/shannon research). Synthesize a
390
+ // turn-end `result` so the agent loop and UI see the turn complete.
391
+ if (message.type === "system" && message.subtype === "turn_duration") {
392
+ let durationMs: number
393
+ if (typeof message.durationMs === "number") {
394
+ durationMs = message.durationMs
395
+ } else if (typeof message.duration_ms === "number") {
396
+ durationMs = message.duration_ms
397
+ } else {
398
+ durationMs = 0
399
+ }
400
+ const pendingWorkflowCount = typeof message.pendingWorkflowCount === "number"
401
+ ? message.pendingWorkflowCount
402
+ : undefined
403
+ return [
404
+ timestamped({
405
+ kind: "result",
406
+ messageId,
407
+ subtype: "success",
408
+ isError: false,
409
+ durationMs,
410
+ result: "",
411
+ costUsd: undefined,
412
+ ...(pendingWorkflowCount !== undefined ? { pendingWorkflowCount } : {}),
413
+ debugRaw,
414
+ }),
415
+ ]
416
+ }
417
+
418
+ if (message.type === "system" && message.subtype === "compact_boundary") {
419
+ return [timestamped({ kind: "compact_boundary", messageId, debugRaw })]
420
+ }
421
+
422
+ if (message.type === "system" && message.subtype === "context_cleared") {
423
+ return [timestamped({ kind: "context_cleared", messageId, debugRaw })]
424
+ }
425
+
426
+ if (
427
+ message.type === "user" &&
428
+ message.message?.role === "user" &&
429
+ typeof message.message.content === "string" &&
430
+ message.message.content.startsWith("This session is being continued")
431
+ ) {
432
+ return [timestamped({ kind: "compact_summary", messageId, summary: message.message.content, debugRaw })]
433
+ }
434
+
435
+ return []
436
+ }
@@ -0,0 +1,248 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ buildAttachmentHintText,
4
+ buildPromptText,
5
+ buildSteeredMessageContent,
6
+ isPromptTooLongMessage,
7
+ isNoConversationFoundMessage,
8
+ toSdkEffort,
9
+ backgroundTaskIdsFromToolResult,
10
+ positiveIntegerFromEnv,
11
+ } from "./claude-prompt-helpers"
12
+ import type { ChatAttachment } from "../shared/types"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Helpers
16
+ // ---------------------------------------------------------------------------
17
+
18
+ function makeAttachment(overrides: Partial<ChatAttachment> = {}): ChatAttachment {
19
+ return {
20
+ id: "att-1",
21
+ kind: "file",
22
+ mimeType: "text/plain",
23
+ absolutePath: "/home/user/notes.txt",
24
+ relativePath: "notes.txt",
25
+ contentUrl: "file:///home/user/notes.txt",
26
+ size: 42,
27
+ displayName: "notes.txt",
28
+ ...overrides,
29
+ }
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // buildAttachmentHintText
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe("buildAttachmentHintText", () => {
37
+ test("returns empty string when no attachments", () => {
38
+ expect(buildAttachmentHintText([])).toBe("")
39
+ })
40
+
41
+ test("wraps a single attachment in kanna-attachments XML", () => {
42
+ const result = buildAttachmentHintText([makeAttachment()])
43
+ expect(result).toContain("<kanna-attachments>")
44
+ expect(result).toContain("</kanna-attachments>")
45
+ expect(result).toContain('<attachment kind="file"')
46
+ expect(result).toContain('mime_type="text/plain"')
47
+ expect(result).toContain('path="/home/user/notes.txt"')
48
+ expect(result).toContain('project_path="notes.txt"')
49
+ expect(result).toContain('size_bytes="42"')
50
+ expect(result).toContain('display_name="notes.txt"')
51
+ })
52
+
53
+ test("escapes XML special chars in attribute values", () => {
54
+ const result = buildAttachmentHintText([
55
+ makeAttachment({
56
+ displayName: 'a&b"c<d>e',
57
+ absolutePath: "/tmp/test",
58
+ relativePath: "test",
59
+ }),
60
+ ])
61
+ expect(result).toContain("a&amp;b&quot;c&lt;d&gt;e")
62
+ expect(result).not.toContain('a&b"c<d>e')
63
+ })
64
+
65
+ test("produces one attachment element per attachment", () => {
66
+ const result = buildAttachmentHintText([makeAttachment(), makeAttachment({ displayName: "second.txt" })])
67
+ const matches = result.match(/<attachment /g)
68
+ expect(matches?.length).toBe(2)
69
+ })
70
+ })
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // buildPromptText
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("buildPromptText", () => {
77
+ test("returns trimmed content when no attachments", () => {
78
+ expect(buildPromptText(" hello ", [])).toBe("hello")
79
+ })
80
+
81
+ test("appends attachment hint when attachments are present", () => {
82
+ const result = buildPromptText("Look at this", [makeAttachment()])
83
+ expect(result).toContain("Look at this")
84
+ expect(result).toContain("<kanna-attachments>")
85
+ })
86
+
87
+ test("uses fallback text when content is blank but attachments present", () => {
88
+ const result = buildPromptText(" ", [makeAttachment()])
89
+ expect(result).toContain("Please inspect the attached files.")
90
+ expect(result).toContain("<kanna-attachments>")
91
+ })
92
+ })
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // buildSteeredMessageContent
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("buildSteeredMessageContent", () => {
99
+ test("prepends STEERED_MESSAGE_PREFIX to non-empty content", () => {
100
+ const result = buildSteeredMessageContent("keep going")
101
+ expect(result).toContain("<system-message>")
102
+ expect(result).toContain("keep going")
103
+ // prefix appears before user content
104
+ expect(result.indexOf("<system-message>")).toBeLessThan(result.indexOf("keep going"))
105
+ })
106
+
107
+ test("returns just the prefix for empty content", () => {
108
+ const result = buildSteeredMessageContent("")
109
+ expect(result).toContain("<system-message>")
110
+ expect(result).toContain("</system-message>")
111
+ // no trailing content after closing tag
112
+ const afterTag = result.slice(result.lastIndexOf("</system-message>") + "</system-message>".length)
113
+ expect(afterTag.trim()).toBe("")
114
+ })
115
+
116
+ test("returns just the prefix for whitespace-only content", () => {
117
+ const result = buildSteeredMessageContent(" ")
118
+ expect(result.indexOf("</system-message>")).toBeGreaterThan(0)
119
+ const afterTag = result.slice(result.lastIndexOf("</system-message>") + "</system-message>".length)
120
+ expect(afterTag.trim()).toBe("")
121
+ })
122
+ })
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // isPromptTooLongMessage
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe("isPromptTooLongMessage", () => {
129
+ test("detects 'prompt is too long'", () => {
130
+ expect(isPromptTooLongMessage("The prompt is too long for this model")).toBe(true)
131
+ })
132
+
133
+ test("detects 'prompt too large'", () => {
134
+ expect(isPromptTooLongMessage("prompt too large to process")).toBe(true)
135
+ })
136
+
137
+ test("returns false for unrelated messages", () => {
138
+ expect(isPromptTooLongMessage("Something else entirely")).toBe(false)
139
+ expect(isPromptTooLongMessage("")).toBe(false)
140
+ })
141
+ })
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // isNoConversationFoundMessage
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("isNoConversationFoundMessage", () => {
148
+ test("detects the session-id error string", () => {
149
+ expect(isNoConversationFoundMessage("No conversation found with session ID abc123")).toBe(true)
150
+ })
151
+
152
+ test("is case-insensitive", () => {
153
+ expect(isNoConversationFoundMessage("no conversation found with session id XYZ")).toBe(true)
154
+ })
155
+
156
+ test("returns false for unrelated strings", () => {
157
+ expect(isNoConversationFoundMessage("session not found")).toBe(false)
158
+ expect(isNoConversationFoundMessage("")).toBe(false)
159
+ })
160
+ })
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // toSdkEffort
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("toSdkEffort", () => {
167
+ test.each(["low", "medium", "high", "xhigh", "max"] as const)("maps '%s' to itself", (effort) => {
168
+ expect(toSdkEffort(effort)).toBe(effort)
169
+ })
170
+
171
+ test("returns undefined for undefined input", () => {
172
+ expect(toSdkEffort(undefined)).toBeUndefined()
173
+ })
174
+
175
+ test("returns undefined for unknown string", () => {
176
+ expect(toSdkEffort("ultra")).toBeUndefined()
177
+ expect(toSdkEffort("")).toBeUndefined()
178
+ })
179
+ })
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // backgroundTaskIdsFromToolResult
183
+ // ---------------------------------------------------------------------------
184
+
185
+ describe("backgroundTaskIdsFromToolResult", () => {
186
+ test("extracts id from string content", () => {
187
+ const ids = backgroundTaskIdsFromToolResult("Command running in background with ID: abc123")
188
+ expect(ids).toEqual(["abc123"])
189
+ })
190
+
191
+ test("extracts multiple ids from string content", () => {
192
+ const ids = backgroundTaskIdsFromToolResult(
193
+ "Command running in background with ID: aa1\nCommand running in background with ID: bb2",
194
+ )
195
+ expect(ids).toEqual(["aa1", "bb2"])
196
+ })
197
+
198
+ test("extracts id from content-block array", () => {
199
+ const blocks = [{ type: "text", text: "Command running in background with ID: xyz99" }]
200
+ expect(backgroundTaskIdsFromToolResult(blocks)).toEqual(["xyz99"])
201
+ })
202
+
203
+ test("returns empty array when no match", () => {
204
+ expect(backgroundTaskIdsFromToolResult("no task here")).toEqual([])
205
+ expect(backgroundTaskIdsFromToolResult([])).toEqual([])
206
+ })
207
+
208
+ test("returns empty array for non-string/non-array input", () => {
209
+ expect(backgroundTaskIdsFromToolResult(null)).toEqual([])
210
+ expect(backgroundTaskIdsFromToolResult(42)).toEqual([])
211
+ })
212
+ })
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // positiveIntegerFromEnv
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe("positiveIntegerFromEnv", () => {
219
+ test("returns fallback for undefined", () => {
220
+ expect(positiveIntegerFromEnv(undefined, 5)).toBe(5)
221
+ })
222
+
223
+ test("returns fallback for empty string", () => {
224
+ expect(positiveIntegerFromEnv("", 5)).toBe(5)
225
+ expect(positiveIntegerFromEnv(" ", 5)).toBe(5)
226
+ })
227
+
228
+ test("returns parsed value for valid positive integer", () => {
229
+ expect(positiveIntegerFromEnv("10", 5)).toBe(10)
230
+ expect(positiveIntegerFromEnv("1", 5)).toBe(1)
231
+ })
232
+
233
+ test("returns fallback for zero", () => {
234
+ expect(positiveIntegerFromEnv("0", 5)).toBe(5)
235
+ })
236
+
237
+ test("returns fallback for negative integer", () => {
238
+ expect(positiveIntegerFromEnv("-3", 5)).toBe(5)
239
+ })
240
+
241
+ test("returns fallback for non-integer float", () => {
242
+ expect(positiveIntegerFromEnv("3.14", 5)).toBe(5)
243
+ })
244
+
245
+ test("returns fallback for non-numeric string", () => {
246
+ expect(positiveIntegerFromEnv("abc", 5)).toBe(5)
247
+ })
248
+ })