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.
- package/LICENSE +21 -0
- package/README.md +46 -19
- package/dist/client/assets/index-C-sGbl7X.js +409 -0
- package/dist/client/assets/index-gld9RxCU.css +1 -0
- package/dist/client/index.html +2 -2
- package/package.json +18 -2
- package/src/server/agent.test.ts +415 -0
- package/src/server/agent.ts +483 -194
- package/src/server/codex-app-server-protocol.ts +440 -0
- package/src/server/codex-app-server.test.ts +1303 -0
- package/src/server/codex-app-server.ts +1277 -0
- package/src/server/event-store.ts +81 -34
- package/src/server/events.ts +25 -17
- package/src/server/harness-types.ts +19 -0
- package/src/server/provider-catalog.test.ts +34 -0
- package/src/server/provider-catalog.ts +77 -0
- package/src/server/read-models.test.ts +63 -0
- package/src/server/read-models.ts +5 -1
- package/src/shared/protocol.ts +12 -2
- package/src/shared/tools.test.ts +88 -0
- package/src/shared/tools.ts +233 -0
- package/src/shared/types.ts +404 -5
- package/dist/client/assets/index-BRiM6Nxc.css +0 -1
- package/dist/client/assets/index-DelZ0MyD.js +0 -418
|
@@ -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
|
+
}
|