kanna-code 0.2.0 → 0.4.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,47 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { PROTOCOL_VERSION } from "../shared/types"
3
+ import { createEmptyState } from "./events"
4
+ import { createWsRouter } from "./ws-router"
5
+
6
+ class FakeWebSocket {
7
+ readonly sent: unknown[] = []
8
+ readonly data = {
9
+ subscriptions: new Map(),
10
+ }
11
+
12
+ send(message: string) {
13
+ this.sent.push(JSON.parse(message))
14
+ }
15
+ }
16
+
17
+ describe("ws-router", () => {
18
+ test("acks system.ping without broadcasting snapshots", () => {
19
+ const router = createWsRouter({
20
+ store: { state: createEmptyState() } as never,
21
+ agent: { getActiveStatuses: () => new Map() } as never,
22
+ refreshDiscovery: async () => [],
23
+ getDiscoveredProjects: () => [],
24
+ machineDisplayName: "Local Machine",
25
+ })
26
+ const ws = new FakeWebSocket()
27
+
28
+ ws.data.subscriptions.set("sub-1", { type: "sidebar" })
29
+ router.handleMessage(
30
+ ws as never,
31
+ JSON.stringify({
32
+ v: 1,
33
+ type: "command",
34
+ id: "ping-1",
35
+ command: { type: "system.ping" },
36
+ })
37
+ )
38
+
39
+ expect(ws.sent).toEqual([
40
+ {
41
+ v: PROTOCOL_VERSION,
42
+ type: "ack",
43
+ id: "ping-1",
44
+ },
45
+ ])
46
+ })
47
+ })
@@ -89,6 +89,10 @@ export function createWsRouter({
89
89
  const { command, id } = message
90
90
  try {
91
91
  switch (command.type) {
92
+ case "system.ping": {
93
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
94
+ return
95
+ }
92
96
  case "project.open": {
93
97
  await ensureProjectDirectory(command.localPath)
94
98
  const project = await store.openProject(command.localPath)
@@ -9,6 +9,7 @@ export type ClientCommand =
9
9
  | { type: "project.open"; localPath: string }
10
10
  | { type: "project.create"; localPath: string; title: string }
11
11
  | { type: "project.remove"; projectId: string }
12
+ | { type: "system.ping" }
12
13
  | { type: "system.openExternal"; localPath: string; action: "open_finder" | "open_terminal" | "open_editor" }
13
14
  | { type: "chat.create"; projectId: string }
14
15
  | { type: "chat.rename"; chatId: string; title: string }
@@ -62,7 +62,18 @@ describe("hydrateToolResult", () => {
62
62
  })
63
63
 
64
64
  const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: "codex" } }))
65
- expect(result).toEqual({ answers: { runtime: "codex" } })
65
+ expect(result).toEqual({ answers: { runtime: ["codex"] } })
66
+ })
67
+
68
+ test("hydrates AskUserQuestion multi-select answers", () => {
69
+ const tool = normalizeToolCall({
70
+ toolName: "AskUserQuestion",
71
+ toolId: "tool-1",
72
+ input: { questions: [] },
73
+ })
74
+
75
+ const result = hydrateToolResult(tool, JSON.stringify({ answers: { runtime: ["bun", "node"] } }))
76
+ expect(result).toEqual({ answers: { runtime: ["bun", "node"] } })
66
77
  })
67
78
 
68
79
  test("hydrates ExitPlanMode decisions", () => {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AskUserQuestionItem,
3
+ AskUserQuestionAnswerMap,
3
4
  AskUserQuestionToolResult,
4
5
  ExitPlanModeToolResult,
5
6
  HydratedToolCall,
@@ -209,7 +210,23 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
209
210
  case "ask_user_question": {
210
211
  const record = asRecord(parsed)
211
212
  const answers = asRecord(record?.answers) ?? (record ? record : {})
212
- return { answers: Object.fromEntries(Object.entries(answers).map(([key, value]) => [key, String(value)])) } satisfies AskUserQuestionToolResult
213
+ return {
214
+ answers: Object.fromEntries(
215
+ Object.entries(answers).map(([key, value]) => {
216
+ if (Array.isArray(value)) {
217
+ return [key, value.map((entry) => String(entry))]
218
+ }
219
+ if (value && typeof value === "object" && Array.isArray((value as { answers?: unknown }).answers)) {
220
+ return [key, (value as { answers: unknown[] }).answers.map((entry) => String(entry))]
221
+ }
222
+ if (value == null || value === "") {
223
+ return [key, []]
224
+ }
225
+ return [key, [String(value)]]
226
+ })
227
+ ) as AskUserQuestionAnswerMap,
228
+ ...(record?.discarded === true ? { discarded: true } : {}),
229
+ } satisfies AskUserQuestionToolResult
213
230
  }
214
231
  case "exit_plan_mode": {
215
232
  const record = asRecord(parsed)
@@ -217,6 +234,7 @@ export function hydrateToolResult(tool: NormalizedToolCall, raw: unknown): Hydra
217
234
  confirmed: typeof record?.confirmed === "boolean" ? record.confirmed : undefined,
218
235
  clearContext: typeof record?.clearContext === "boolean" ? record.clearContext : undefined,
219
236
  message: typeof record?.message === "string" ? record.message : undefined,
237
+ ...(record?.discarded === true ? { discarded: true } : {}),
220
238
  } satisfies ExitPlanModeToolResult
221
239
  }
222
240
  case "read_file":
@@ -194,6 +194,8 @@ export interface AskUserQuestionItem {
194
194
  multiSelect?: boolean
195
195
  }
196
196
 
197
+ export type AskUserQuestionAnswerMap = Record<string, string[]>
198
+
197
199
  export interface TodoItem {
198
200
  content: string
199
201
  status: "pending" | "in_progress" | "completed"
@@ -373,13 +375,15 @@ export interface HydratedToolCallBase<TKind extends string, TInput, TResult> {
373
375
  }
374
376
 
375
377
  export interface AskUserQuestionToolResult {
376
- answers: Record<string, string>
378
+ answers: AskUserQuestionAnswerMap
379
+ discarded?: boolean
377
380
  }
378
381
 
379
382
  export interface ExitPlanModeToolResult {
380
383
  confirmed?: boolean
381
384
  clearContext?: boolean
382
385
  message?: string
386
+ discarded?: boolean
383
387
  }
384
388
 
385
389
  export type HydratedAskUserQuestionToolCall =