kanna-code 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { hydrateToolResult, normalizeToolCall } from "./tools"
3
+
4
+ describe("normalizeToolCall", () => {
5
+ test("maps AskUserQuestion input to typed questions", () => {
6
+ const tool = normalizeToolCall({
7
+ toolName: "AskUserQuestion",
8
+ toolId: "tool-1",
9
+ input: {
10
+ questions: [
11
+ {
12
+ question: "Which runtime?",
13
+ header: "Runtime",
14
+ options: [{ label: "Codex", description: "Use Codex" }],
15
+ },
16
+ ],
17
+ },
18
+ })
19
+
20
+ expect(tool.toolKind).toBe("ask_user_question")
21
+ if (tool.toolKind !== "ask_user_question") throw new Error("unexpected tool kind")
22
+ expect(tool.input.questions[0]?.question).toBe("Which runtime?")
23
+ })
24
+
25
+ test("maps Bash snake_case input to camelCase", () => {
26
+ const tool = normalizeToolCall({
27
+ toolName: "Bash",
28
+ toolId: "tool-2",
29
+ input: {
30
+ command: "pwd",
31
+ timeout: 5000,
32
+ run_in_background: true,
33
+ },
34
+ })
35
+
36
+ expect(tool.toolKind).toBe("bash")
37
+ if (tool.toolKind !== "bash") throw new Error("unexpected tool kind")
38
+ expect(tool.input.timeoutMs).toBe(5000)
39
+ expect(tool.input.runInBackground).toBe(true)
40
+ })
41
+
42
+ test("maps unknown MCP tools to mcp_generic", () => {
43
+ const tool = normalizeToolCall({
44
+ toolName: "mcp__sentry__search_issues",
45
+ toolId: "tool-3",
46
+ input: { query: "regression" },
47
+ })
48
+
49
+ expect(tool.toolKind).toBe("mcp_generic")
50
+ if (tool.toolKind !== "mcp_generic") throw new Error("unexpected tool kind")
51
+ expect(tool.input.server).toBe("sentry")
52
+ expect(tool.input.tool).toBe("search_issues")
53
+ })
54
+ })
55
+
56
+ describe("hydrateToolResult", () => {
57
+ test("hydrates AskUserQuestion answers", () => {
58
+ const tool = normalizeToolCall({
59
+ toolName: "AskUserQuestion",
60
+ toolId: "tool-1",
61
+ input: { questions: [] },
62
+ })
63
+
64
+ const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: "codex" } }))
65
+ expect(result).toEqual({ answers: { runtime: "codex" } })
66
+ })
67
+
68
+ test("hydrates ExitPlanMode decisions", () => {
69
+ const tool = normalizeToolCall({
70
+ toolName: "ExitPlanMode",
71
+ toolId: "tool-2",
72
+ input: { plan: "Do the thing" },
73
+ })
74
+
75
+ const result = hydrateToolResult(tool, { confirmed: true, clearContext: true })
76
+ expect(result).toEqual({ confirmed: true, clearContext: true, message: undefined })
77
+ })
78
+
79
+ test("hydrates Read file text results", () => {
80
+ const tool = normalizeToolCall({
81
+ toolName: "Read",
82
+ toolId: "tool-3",
83
+ input: { file_path: "/tmp/example.ts" },
84
+ })
85
+
86
+ expect(hydrateToolResult(tool, "line 1\nline 2")).toBe("line 1\nline 2")
87
+ })
88
+ })
@@ -0,0 +1,233 @@
1
+ import type {
2
+ AskUserQuestionItem,
3
+ AskUserQuestionToolResult,
4
+ ExitPlanModeToolResult,
5
+ HydratedToolCall,
6
+ NormalizedToolCall,
7
+ ReadFileToolResult,
8
+ TodoItem,
9
+ } from "./types"
10
+
11
+ function asRecord(value: unknown): Record<string, unknown> | null {
12
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null
13
+ return value as Record<string, unknown>
14
+ }
15
+
16
+ export function normalizeToolCall(args: {
17
+ toolName: string
18
+ toolId: string
19
+ input: Record<string, unknown>
20
+ }): NormalizedToolCall {
21
+ const { toolName, toolId, input } = args
22
+
23
+ switch (toolName) {
24
+ case "AskUserQuestion":
25
+ return {
26
+ kind: "tool",
27
+ toolKind: "ask_user_question",
28
+ toolName,
29
+ toolId,
30
+ input: {
31
+ questions: Array.isArray(input.questions) ? (input.questions as AskUserQuestionItem[]) : [],
32
+ },
33
+ rawInput: input,
34
+ }
35
+ case "ExitPlanMode":
36
+ return {
37
+ kind: "tool",
38
+ toolKind: "exit_plan_mode",
39
+ toolName,
40
+ toolId,
41
+ input: {
42
+ plan: typeof input.plan === "string" ? input.plan : undefined,
43
+ summary: typeof input.summary === "string" ? input.summary : undefined,
44
+ },
45
+ rawInput: input,
46
+ }
47
+ case "TodoWrite":
48
+ return {
49
+ kind: "tool",
50
+ toolKind: "todo_write",
51
+ toolName,
52
+ toolId,
53
+ input: {
54
+ todos: Array.isArray(input.todos) ? (input.todos as TodoItem[]) : [],
55
+ },
56
+ rawInput: input,
57
+ }
58
+ case "Skill":
59
+ return {
60
+ kind: "tool",
61
+ toolKind: "skill",
62
+ toolName,
63
+ toolId,
64
+ input: {
65
+ skill: typeof input.skill === "string" ? input.skill : "",
66
+ },
67
+ rawInput: input,
68
+ }
69
+ case "Glob":
70
+ return {
71
+ kind: "tool",
72
+ toolKind: "glob",
73
+ toolName,
74
+ toolId,
75
+ input: {
76
+ pattern: typeof input.pattern === "string" ? input.pattern : "",
77
+ },
78
+ rawInput: input,
79
+ }
80
+ case "Grep":
81
+ return {
82
+ kind: "tool",
83
+ toolKind: "grep",
84
+ toolName,
85
+ toolId,
86
+ input: {
87
+ pattern: typeof input.pattern === "string" ? input.pattern : "",
88
+ outputMode: typeof input.output_mode === "string" ? input.output_mode : undefined,
89
+ },
90
+ rawInput: input,
91
+ }
92
+ case "Bash":
93
+ return {
94
+ kind: "tool",
95
+ toolKind: "bash",
96
+ toolName,
97
+ toolId,
98
+ input: {
99
+ command: typeof input.command === "string" ? input.command : "",
100
+ description: typeof input.description === "string" ? input.description : undefined,
101
+ timeoutMs: typeof input.timeout === "number" ? input.timeout : undefined,
102
+ runInBackground: Boolean(input.run_in_background),
103
+ },
104
+ rawInput: input,
105
+ }
106
+ case "WebSearch":
107
+ return {
108
+ kind: "tool",
109
+ toolKind: "web_search",
110
+ toolName,
111
+ toolId,
112
+ input: {
113
+ query: typeof input.query === "string" ? input.query : "",
114
+ },
115
+ rawInput: input,
116
+ }
117
+ case "Read":
118
+ return {
119
+ kind: "tool",
120
+ toolKind: "read_file",
121
+ toolName,
122
+ toolId,
123
+ input: {
124
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
125
+ },
126
+ rawInput: input,
127
+ }
128
+ case "Write":
129
+ return {
130
+ kind: "tool",
131
+ toolKind: "write_file",
132
+ toolName,
133
+ toolId,
134
+ input: {
135
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
136
+ content: typeof input.content === "string" ? input.content : "",
137
+ },
138
+ rawInput: input,
139
+ }
140
+ case "Edit":
141
+ return {
142
+ kind: "tool",
143
+ toolKind: "edit_file",
144
+ toolName,
145
+ toolId,
146
+ input: {
147
+ filePath: typeof input.file_path === "string" ? input.file_path : "",
148
+ oldString: typeof input.old_string === "string" ? input.old_string : "",
149
+ newString: typeof input.new_string === "string" ? input.new_string : "",
150
+ },
151
+ rawInput: input,
152
+ }
153
+ }
154
+
155
+ const mcpMatch = toolName.match(/^mcp__(.+?)__(.+)$/)
156
+ if (mcpMatch) {
157
+ return {
158
+ kind: "tool",
159
+ toolKind: "mcp_generic",
160
+ toolName,
161
+ toolId,
162
+ input: {
163
+ server: mcpMatch[1],
164
+ tool: mcpMatch[2],
165
+ payload: input,
166
+ },
167
+ rawInput: input,
168
+ }
169
+ }
170
+
171
+ if (typeof input.subagent_type === "string") {
172
+ return {
173
+ kind: "tool",
174
+ toolKind: "subagent_task",
175
+ toolName,
176
+ toolId,
177
+ input: {
178
+ subagentType: input.subagent_type,
179
+ },
180
+ rawInput: input,
181
+ }
182
+ }
183
+
184
+ return {
185
+ kind: "tool",
186
+ toolKind: "unknown_tool",
187
+ toolName,
188
+ toolId,
189
+ input: {
190
+ payload: input,
191
+ },
192
+ rawInput: input,
193
+ }
194
+ }
195
+
196
+ function parseJsonValue(value: unknown): unknown {
197
+ if (typeof value !== "string") return value
198
+ try {
199
+ return JSON.parse(value)
200
+ } catch {
201
+ return value
202
+ }
203
+ }
204
+
205
+ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): HydratedToolCall["result"] {
206
+ const parsed = parseJsonValue(raw)
207
+
208
+ switch (tool.toolKind) {
209
+ case "ask_user_question": {
210
+ const record = asRecord(parsed)
211
+ const answers = asRecord(record?.answers) ?? (record ? record : {})
212
+ return { answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, String(value)])) } satisfies AskUserQuestionToolResult
213
+ }
214
+ case "exit_plan_mode": {
215
+ const record = asRecord(parsed)
216
+ return {
217
+ confirmed: typeof record?.confirmed === "boolean" ? record.confirmed : undefined,
218
+ clearContext: typeof record?.clearContext === "boolean" ? record.clearContext : undefined,
219
+ message: typeof record?.message === "string" ? record.message : undefined,
220
+ } satisfies ExitPlanModeToolResult
221
+ }
222
+ case "read_file":
223
+ if (typeof parsed === "string") {
224
+ return parsed
225
+ }
226
+ const record = asRecord(parsed)
227
+ return {
228
+ content: typeof record?.content === "string" ? record.content : JSON.stringify(parsed, null, 2),
229
+ } satisfies ReadFileToolResult
230
+ default:
231
+ return parsed
232
+ }
233
+ }