@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.
- package/dist/client/assets/{arc-BLk1CZKB.js → arc-DXtN3EWJ.js} +1 -1
- package/dist/client/assets/{architectureDiagram-3BPJPVTR-B01ZQYte.js → architectureDiagram-3BPJPVTR-CIGqW1z-.js} +1 -1
- package/dist/client/assets/{blockDiagram-GPEHLZMM-diL6a3cq.js → blockDiagram-GPEHLZMM-BafNS_H6.js} +1 -1
- package/dist/client/assets/{c4Diagram-AAUBKEIU-BY1verL8.js → c4Diagram-AAUBKEIU-YdAgRSis.js} +1 -1
- package/dist/client/assets/channel-BEYTrUAm.js +1 -0
- package/dist/client/assets/{chunk-2J33WTMH-psdZuajw.js → chunk-2J33WTMH-eiP5y5G-.js} +1 -1
- package/dist/client/assets/{chunk-4BX2VUAB-CrwQVBc5.js → chunk-4BX2VUAB-CvLGzPMF.js} +1 -1
- package/dist/client/assets/{chunk-55IACEB6-9-aj4jKO.js → chunk-55IACEB6-Y-S9Fb8A.js} +1 -1
- package/dist/client/assets/{chunk-727SXJPM-1FNK9_nB.js → chunk-727SXJPM-6sJH1RI9.js} +1 -1
- package/dist/client/assets/{chunk-AQP2D5EJ-DFhvd_Ek.js → chunk-AQP2D5EJ-hV1r_IXO.js} +1 -1
- package/dist/client/assets/{chunk-FMBD7UC4-B7MLrDa7.js → chunk-FMBD7UC4-CC3L28S5.js} +1 -1
- package/dist/client/assets/{chunk-ND2GUHAM-D4Df05QE.js → chunk-ND2GUHAM-BZ3czhdH.js} +1 -1
- package/dist/client/assets/{chunk-QZHKN3VN-Iq17Dznm.js → chunk-QZHKN3VN-Bjyd6Yr-.js} +1 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-CvnF5Ud6.js +1 -0
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-CvnF5Ud6.js +1 -0
- package/dist/client/assets/{cose-bilkent-S5V4N54A-BmaZhtXy.js → cose-bilkent-S5V4N54A-CfxdDghE.js} +1 -1
- package/dist/client/assets/{dagre-BM42HDAG-BCmpfnfP.js → dagre-BM42HDAG-B8CddsbB.js} +1 -1
- package/dist/client/assets/{diagram-2AECGRRQ-BIEcKowS.js → diagram-2AECGRRQ-F0yS0fVB.js} +1 -1
- package/dist/client/assets/{diagram-5GNKFQAL-CRBABBfv.js → diagram-5GNKFQAL-cW0i0qlh.js} +1 -1
- package/dist/client/assets/{diagram-KO2AKTUF-BRxUk16N.js → diagram-KO2AKTUF-BzAKMeJT.js} +1 -1
- package/dist/client/assets/{diagram-LMA3HP47-DPBs4xl1.js → diagram-LMA3HP47-qdC4XqIl.js} +1 -1
- package/dist/client/assets/{diagram-OG6HWLK6-BE9rgVv8.js → diagram-OG6HWLK6-CGcpi6Dh.js} +1 -1
- package/dist/client/assets/{erDiagram-TEJ5UH35-D3vgA1xJ.js → erDiagram-TEJ5UH35-CxNTDicM.js} +1 -1
- package/dist/client/assets/{flowDiagram-I6XJVG4X-Cgu-8XXC.js → flowDiagram-I6XJVG4X-qkGa5TqY.js} +1 -1
- package/dist/client/assets/{ganttDiagram-6RSMTGT7-CgjXVyTR.js → ganttDiagram-6RSMTGT7-D9mVPoQi.js} +1 -1
- package/dist/client/assets/{gitGraphDiagram-PVQCEYII-zD5D8B-i.js → gitGraphDiagram-PVQCEYII-DA0grAXe.js} +1 -1
- package/dist/client/assets/{index-DBTdhMbJ.js → index-BqUY1Bm4.js} +1 -1
- package/dist/client/assets/index-CLsOWXff.css +32 -0
- package/dist/client/assets/{index-zQLLeAfl.js → index-TU1k6ov6.js} +253 -243
- package/dist/client/assets/{infoDiagram-5YYISTIA-BaFHqT4u.js → infoDiagram-5YYISTIA-CGljtLs3.js} +1 -1
- package/dist/client/assets/{ishikawaDiagram-YF4QCWOH-2yAC1br2.js → ishikawaDiagram-YF4QCWOH-CtC0xrmG.js} +1 -1
- package/dist/client/assets/{journeyDiagram-JHISSGLW-DacQ-ND5.js → journeyDiagram-JHISSGLW-URLnVZeN.js} +1 -1
- package/dist/client/assets/{kanban-definition-UN3LZRKU-Qlbwm3z4.js → kanban-definition-UN3LZRKU-C2Q6aqQh.js} +1 -1
- package/dist/client/assets/{linear-DeafqpgO.js → linear-ByKHtsIx.js} +1 -1
- package/dist/client/assets/{mermaid.core-KYiCPJDj.js → mermaid.core-Dl_f2KLc.js} +4 -4
- package/dist/client/assets/{mindmap-definition-RKZ34NQL-B1V-T6d3.js → mindmap-definition-RKZ34NQL-CaJ9IDLI.js} +1 -1
- package/dist/client/assets/{pieDiagram-4H26LBE5-DOF9OFRu.js → pieDiagram-4H26LBE5-mmYaf2Oa.js} +1 -1
- package/dist/client/assets/{quadrantDiagram-W4KKPZXB-lH1mB7ko.js → quadrantDiagram-W4KKPZXB-B_PO6OCC.js} +1 -1
- package/dist/client/assets/{requirementDiagram-4Y6WPE33-BaTsJ_sS.js → requirementDiagram-4Y6WPE33-M4huwJt1.js} +1 -1
- package/dist/client/assets/{sankeyDiagram-5OEKKPKP-Pfxib5iI.js → sankeyDiagram-5OEKKPKP-B0hb1sWh.js} +1 -1
- package/dist/client/assets/{sequenceDiagram-3UESZ5HK-BQMEKkBC.js → sequenceDiagram-3UESZ5HK-BAqSptnA.js} +1 -1
- package/dist/client/assets/{stateDiagram-AJRCARHV-CuldNnzp.js → stateDiagram-AJRCARHV-DTNkvPi5.js} +1 -1
- package/dist/client/assets/stateDiagram-v2-BHNVJYJU-CVN3oGTF.js +1 -0
- package/dist/client/assets/{timeline-definition-PNZ67QCA-DP7u0YAg.js → timeline-definition-PNZ67QCA-MgAjHz8Q.js} +1 -1
- package/dist/client/assets/{vennDiagram-CIIHVFJN-QlrjpNeQ.js → vennDiagram-CIIHVFJN-DmKoO51W.js} +1 -1
- package/dist/client/assets/{wardley-L42UT6IY-BqdJ_sAM.js → wardley-L42UT6IY-CWcyHnYs.js} +1 -1
- package/dist/client/assets/{wardleyDiagram-YWT4CUSO-Wi3kaQ5X.js → wardleyDiagram-YWT4CUSO-BGIGmFP9.js} +1 -1
- package/dist/client/assets/{xychartDiagram-2RQKCTM6-D-KXMyCG.js → xychartDiagram-2RQKCTM6-D20YIvEl.js} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent-coordinator-types.ts +158 -0
- package/src/server/agent-coordinator.ts +919 -0
- package/src/server/agent-deps-builders.ts +425 -0
- package/src/server/agent.oauth-rotation.test.ts +101 -0
- package/src/server/agent.test.ts +24 -8
- package/src/server/agent.ts +35 -4963
- package/src/server/auto-continue/read-model.test.ts +2 -2
- package/src/server/auto-continue/read-model.ts +4 -1
- package/src/server/claude-autocontinue-commands.test.ts +494 -0
- package/src/server/claude-autocontinue-commands.ts +256 -0
- package/src/server/claude-cancel-handler.test.ts +472 -0
- package/src/server/claude-cancel-handler.ts +241 -0
- package/src/server/claude-chat-management.test.ts +395 -0
- package/src/server/claude-chat-management.ts +251 -0
- package/src/server/claude-harness-stream.test.ts +273 -0
- package/src/server/claude-harness-stream.ts +158 -0
- package/src/server/claude-loop-orch-commands.test.ts +579 -0
- package/src/server/claude-loop-orch-commands.ts +466 -0
- package/src/server/claude-message-normalizer.test.ts +213 -0
- package/src/server/claude-message-normalizer.ts +436 -0
- package/src/server/claude-prompt-helpers.test.ts +248 -0
- package/src/server/claude-prompt-helpers.ts +139 -0
- package/src/server/claude-sdk-queue.test.ts +169 -0
- package/src/server/claude-sdk-queue.ts +82 -0
- package/src/server/claude-send-command.test.ts +414 -0
- package/src/server/claude-send-command.ts +420 -0
- package/src/server/claude-session-config-helpers.test.ts +279 -0
- package/src/server/claude-session-config-helpers.ts +151 -0
- package/src/server/claude-session-config.test.ts +250 -0
- package/src/server/claude-session-config.ts +159 -0
- package/src/server/claude-session-error-handler.test.ts +498 -0
- package/src/server/claude-session-error-handler.ts +361 -0
- package/src/server/claude-session-lifecycle.test.ts +669 -0
- package/src/server/claude-session-lifecycle.ts +283 -0
- package/src/server/claude-session-rebuild.test.ts +224 -0
- package/src/server/claude-session-rebuild.ts +101 -0
- package/src/server/claude-session-runner.test.ts +523 -0
- package/src/server/claude-session-runner.ts +521 -0
- package/src/server/claude-session-spawner.test.ts +401 -0
- package/src/server/claude-session-spawner.ts +401 -0
- package/src/server/claude-session-start.test.ts +205 -0
- package/src/server/claude-session-start.ts +277 -0
- package/src/server/claude-session-state-queries.test.ts +344 -0
- package/src/server/claude-session-state-queries.ts +160 -0
- package/src/server/claude-session-state.ts +104 -0
- package/src/server/claude-slash-commands.test.ts +277 -0
- package/src/server/claude-slash-commands.ts +234 -0
- package/src/server/claude-spawn-helpers.test.ts +221 -0
- package/src/server/claude-spawn-helpers.ts +179 -0
- package/src/server/claude-steer-log.test.ts +243 -0
- package/src/server/claude-steer-log.ts +58 -0
- package/src/server/claude-subagent-tool-response.test.ts +213 -0
- package/src/server/claude-subagent-tool-response.ts +165 -0
- package/src/server/claude-subagent-wiring.test.ts +263 -0
- package/src/server/claude-subagent-wiring.ts +324 -0
- package/src/server/claude-tool-respond.test.ts +321 -0
- package/src/server/claude-tool-respond.ts +142 -0
- package/src/server/claude-turn-runner.test.ts +310 -0
- package/src/server/claude-turn-runner.ts +228 -0
- package/src/server/claude-turn-starter.test.ts +266 -0
- package/src/server/claude-turn-starter.ts +566 -0
- package/src/server/claude-usage-math.test.ts +169 -0
- package/src/server/claude-usage-math.ts +132 -0
- package/src/server/diff-store-branch-ops.adapter.test.ts +332 -0
- package/src/server/diff-store-branch-ops.adapter.ts +602 -0
- package/src/server/diff-store-commit-ops.adapter.test.ts +358 -0
- package/src/server/diff-store-commit-ops.adapter.ts +258 -0
- package/src/server/diff-store-errors.ts +85 -0
- package/src/server/diff-store-file-ops.adapter.ts +212 -0
- package/src/server/diff-store-git-branch.adapter.ts +686 -0
- package/src/server/diff-store-io.adapter.ts +27 -0
- package/src/server/diff-store-parse.test.ts +212 -0
- package/src/server/diff-store-parse.ts +122 -0
- package/src/server/diff-store-state.ts +82 -0
- package/src/server/diff-store.ts +76 -1854
- package/src/server/event-store-apply.test.ts +280 -0
- package/src/server/event-store-apply.ts +132 -0
- package/src/server/event-store-chat-lifecycle.test.ts +408 -0
- package/src/server/event-store-chat-lifecycle.ts +441 -0
- package/src/server/event-store-entity-write.ts +356 -0
- package/src/server/event-store-helpers.test.ts +230 -0
- package/src/server/event-store-helpers.ts +235 -0
- package/src/server/event-store-init.ts +276 -0
- package/src/server/event-store-messages.adapter.test.ts +365 -0
- package/src/server/event-store-messages.adapter.ts +213 -0
- package/src/server/event-store-orch-subscription.ts +124 -0
- package/src/server/event-store-orch.test.ts +462 -0
- package/src/server/event-store-orch.ts +312 -0
- package/src/server/event-store-peripheral-events.adapter.test.ts +254 -0
- package/src/server/event-store-peripheral-events.adapter.ts +105 -0
- package/src/server/event-store-snapshot.test.ts +597 -0
- package/src/server/event-store-snapshot.ts +658 -0
- package/src/server/event-store-subagent.test.ts +408 -0
- package/src/server/event-store-subagent.ts +198 -0
- package/src/server/event-store-tool-requests.test.ts +210 -0
- package/src/server/event-store-tool-requests.ts +131 -0
- package/src/server/event-store-transcript-write.adapter.ts +285 -0
- package/src/server/event-store-write-ops.test.ts +554 -0
- package/src/server/event-store-write-ops.ts +529 -0
- package/src/server/event-store.test.ts +35 -0
- package/src/server/event-store.ts +251 -2284
- package/src/server/events.ts +3 -0
- package/src/server/harness-types.ts +15 -1
- package/src/server/oauth-errors.ts +19 -0
- package/src/server/read-models.ts +26 -1
- package/src/server/subagent-orchestrator.ts +3 -0
- package/src/server/ws-router-agent-ctrl.test.ts +187 -0
- package/src/server/ws-router-agent-ctrl.ts +137 -0
- package/src/server/ws-router-broadcast.ts +488 -0
- package/src/server/ws-router-chat.test.ts +461 -0
- package/src/server/ws-router-chat.ts +279 -0
- package/src/server/ws-router-defaults.test.ts +283 -0
- package/src/server/ws-router-defaults.ts +434 -0
- package/src/server/ws-router-diff.test.ts +167 -0
- package/src/server/ws-router-diff.ts +250 -0
- package/src/server/ws-router-envelope.ts +329 -0
- package/src/server/ws-router-misc.test.ts +422 -0
- package/src/server/ws-router-misc.ts +265 -0
- package/src/server/ws-router-orch.test.ts +190 -0
- package/src/server/ws-router-orch.ts +93 -0
- package/src/server/ws-router-project.test.ts +403 -0
- package/src/server/ws-router-project.ts +256 -0
- package/src/server/ws-router-push.test.ts +193 -0
- package/src/server/ws-router-push.ts +120 -0
- package/src/server/ws-router-settings.test.ts +173 -0
- package/src/server/ws-router-settings.ts +416 -0
- package/src/server/ws-router-skills.test.ts +194 -0
- package/src/server/ws-router-skills.ts +204 -0
- package/src/server/ws-router-utils.ts +224 -0
- package/src/server/ws-router.ts +339 -2254
- package/src/shared/app-settings-types.ts +365 -0
- package/src/shared/core-types.ts +27 -0
- package/src/shared/git-diff-types.ts +165 -0
- package/src/shared/loop-progress.test.ts +106 -0
- package/src/shared/loop-progress.ts +88 -0
- package/src/shared/mcp-types.ts +105 -0
- package/src/shared/provider-model-types.ts +437 -0
- package/src/shared/subagent-types.ts +198 -0
- package/src/shared/tool-call-types.ts +314 -0
- package/src/shared/transcript-types.ts +270 -0
- package/src/shared/types.ts +27 -1668
- package/dist/client/assets/channel-BMkhjiLI.js +0 -1
- package/dist/client/assets/classDiagram-4FO5ZUOK-C5Kg1XOD.js +0 -1
- package/dist/client/assets/classDiagram-v2-Q7XG4LA2-C5Kg1XOD.js +0 -1
- package/dist/client/assets/index-H6C-f2Vp.css +0 -32
- 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&b"c<d>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
|
+
})
|