kanna-code 0.18.0 → 0.19.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.
@@ -1,4 +1,6 @@
1
1
  import { query } from "@anthropic-ai/claude-agent-sdk"
2
+ import { homedir } from "node:os"
3
+ import { getDataRootDir } from "../shared/branding"
2
4
  import { CodexAppServerManager } from "./codex-app-server"
3
5
 
4
6
  const CLAUDE_STRUCTURED_TIMEOUT_MS = 5_000
@@ -24,6 +26,20 @@ interface QuickResponseAdapterArgs {
24
26
  runCodexStructured?: (args: Omit<StructuredQuickResponseArgs<unknown>, "parse">) => Promise<unknown | null>
25
27
  }
26
28
 
29
+ export interface StructuredQuickResponseFailure {
30
+ provider: "claude" | "codex"
31
+ reason: string
32
+ }
33
+
34
+ export interface StructuredQuickResponseResult<T> {
35
+ value: T | null
36
+ failures: StructuredQuickResponseFailure[]
37
+ }
38
+
39
+ export function getQuickResponseWorkspace(env: Record<string, string | undefined> = process.env) {
40
+ return getDataRootDir(homedir(), env)
41
+ }
42
+
27
43
  function parseJsonText(value: string): unknown | null {
28
44
  const trimmed = value.trim()
29
45
  if (!trimmed) return null
@@ -45,10 +61,35 @@ function parseJsonText(value: string): unknown | null {
45
61
  return null
46
62
  }
47
63
 
48
- async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
64
+ function structuredOutputFromSdkMessage(message: unknown): unknown | null {
65
+ if (!message || typeof message !== "object") return null
66
+
67
+ const record = message as Record<string, unknown>
68
+ if (record.type === "result") {
69
+ return record.structured_output ?? null
70
+ }
71
+
72
+ const assistantMessage = record.message
73
+ if (!assistantMessage || typeof assistantMessage !== "object") return null
74
+ const content = (assistantMessage as { content?: unknown }).content
75
+ if (!Array.isArray(content)) return null
76
+
77
+ for (const item of content) {
78
+ if (!item || typeof item !== "object") continue
79
+ const toolUse = item as Record<string, unknown>
80
+ if (toolUse.type === "tool_use" && toolUse.name === "StructuredOutput") {
81
+ return toolUse.input ?? null
82
+ }
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknown>, "parse">): Promise<unknown | null> {
49
89
  const q = query({
50
90
  prompt: args.prompt,
51
91
  options: {
92
+ cwd: args.cwd,
52
93
  model: "haiku",
53
94
  tools: [],
54
95
  systemPrompt: "",
@@ -66,8 +107,9 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
66
107
  const result = await Promise.race<unknown | null>([
67
108
  (async () => {
68
109
  for await (const message of q) {
69
- if ("result" in message) {
70
- return (message as Record<string, unknown>).structured_output ?? null
110
+ const structuredOutput = structuredOutputFromSdkMessage(message)
111
+ if (structuredOutput !== null) {
112
+ return structuredOutput
71
113
  }
72
114
  }
73
115
  return null
@@ -91,12 +133,13 @@ async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs<unknow
91
133
  }
92
134
  }
93
135
 
94
- async function runCodexStructured(
136
+ export async function runCodexStructured(
95
137
  codexManager: CodexAppServerManager,
96
138
  args: Omit<StructuredQuickResponseArgs<unknown>, "parse">
97
139
  ): Promise<unknown | null> {
98
140
  const response = await codexManager.generateStructured({
99
141
  cwd: args.cwd,
142
+ model: "gpt-5.4-mini",
100
143
  prompt: `${args.prompt}\n\nReturn JSON only that matches this schema:\n${JSON.stringify(args.schema, null, 2)}`,
101
144
  })
102
145
  if (typeof response !== "string") return null
@@ -115,17 +158,45 @@ export class QuickResponseAdapter {
115
158
  runCodexStructured(this.codexManager, structuredArgs))
116
159
  }
117
160
  async generateStructured<T>(args: StructuredQuickResponseArgs<T>): Promise<T | null> {
161
+ const result = await this.generateStructuredWithDiagnostics(args)
162
+ return result.value
163
+ }
164
+
165
+ async generateStructuredWithDiagnostics<T>(args: StructuredQuickResponseArgs<T>): Promise<StructuredQuickResponseResult<T>> {
118
166
  const request = {
119
- cwd: args.cwd,
167
+ cwd: getQuickResponseWorkspace(),
120
168
  task: args.task,
121
169
  prompt: args.prompt,
122
170
  schema: args.schema,
123
171
  }
124
172
 
173
+ const failures: StructuredQuickResponseFailure[] = []
125
174
  const claudeResult = await this.tryProvider("claude", args.task, args.parse, () => this.runClaudeStructured(request))
126
- if (claudeResult !== null) return claudeResult
175
+ if (claudeResult.value !== null) {
176
+ return {
177
+ value: claudeResult.value,
178
+ failures,
179
+ }
180
+ }
181
+ if (claudeResult.failure) {
182
+ failures.push(claudeResult.failure)
183
+ }
184
+
185
+ const codexResult = await this.tryProvider("codex", args.task, args.parse, () => this.runCodexStructured(request))
186
+ if (codexResult.value !== null) {
187
+ return {
188
+ value: codexResult.value,
189
+ failures,
190
+ }
191
+ }
192
+ if (codexResult.failure) {
193
+ failures.push(codexResult.failure)
194
+ }
127
195
 
128
- return await this.tryProvider("codex", args.task, args.parse, () => this.runCodexStructured(request))
196
+ return {
197
+ value: null,
198
+ failures,
199
+ }
129
200
  }
130
201
 
131
202
  private async tryProvider<T>(
@@ -133,21 +204,43 @@ export class QuickResponseAdapter {
133
204
  task: string,
134
205
  parse: (value: unknown) => T | null,
135
206
  run: () => Promise<unknown | null>
136
- ): Promise<T | null> {
207
+ ): Promise<{ value: T | null; failure: StructuredQuickResponseFailure | null }> {
137
208
  try {
138
209
  const result = await run()
139
210
  if (result === null) {
140
- return null
211
+ return {
212
+ value: null,
213
+ failure: {
214
+ provider,
215
+ reason: `${provider} returned no result for ${task}`,
216
+ },
217
+ }
141
218
  }
142
-
219
+
143
220
  const parsed = parse(result)
144
221
  if (parsed === null) {
145
- return null
222
+ return {
223
+ value: null,
224
+ failure: {
225
+ provider,
226
+ reason: `${provider} returned invalid structured output for ${task}`,
227
+ },
228
+ }
229
+ }
230
+
231
+ return {
232
+ value: parsed,
233
+ failure: null,
146
234
  }
147
-
148
- return parsed
149
235
  } catch (error) {
150
- return null
236
+ const message = error instanceof Error ? error.message : String(error)
237
+ return {
238
+ value: null,
239
+ failure: {
240
+ provider,
241
+ reason: `${provider} failed ${task}: ${message}`,
242
+ },
243
+ }
151
244
  }
152
245
  }
153
246
  }
@@ -19,6 +19,7 @@ describe("read models", () => {
19
19
  title: "Chat",
20
20
  createdAt: 1,
21
21
  updatedAt: 1,
22
+ unread: true,
22
23
  provider: "codex",
23
24
  planMode: false,
24
25
  sessionToken: "thread-1",
@@ -27,6 +28,7 @@ describe("read models", () => {
27
28
 
28
29
  const sidebar = deriveSidebarData(state, new Map())
29
30
  expect(sidebar.projectGroups[0]?.chats[0]?.provider).toBe("codex")
31
+ expect(sidebar.projectGroups[0]?.chats[0]?.unread).toBe(true)
30
32
  })
31
33
 
32
34
  test("includes available providers in chat snapshots", () => {
@@ -45,6 +47,7 @@ describe("read models", () => {
45
47
  title: "Chat",
46
48
  createdAt: 1,
47
49
  updatedAt: 1,
50
+ unread: false,
48
51
  provider: "claude",
49
52
  planMode: true,
50
53
  sessionToken: "session-1",
@@ -77,6 +80,7 @@ describe("read models", () => {
77
80
  title: "Chat",
78
81
  createdAt: 1,
79
82
  updatedAt: 75,
83
+ unread: false,
80
84
  provider: "codex",
81
85
  planMode: false,
82
86
  sessionToken: null,
@@ -35,6 +35,7 @@ export function deriveSidebarData(
35
35
  chatId: chat.id,
36
36
  title: chat.title,
37
37
  status: deriveStatus(chat, activeStatuses.get(chat.id)),
38
+ unread: chat.unread,
38
39
  localPath: project.localPath,
39
40
  provider: chat.provider,
40
41
  lastMessageAt: chat.lastMessageAt,
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { CodexAppServerManager } from "./codex-app-server"
3
+ import { fallbackTitleFromMessage, generateTitleForChatDetailed } from "./generate-title"
4
+ import { QuickResponseAdapter, runClaudeStructured, runCodexStructured } from "./quick-response"
5
+
6
+ const shouldRunLiveTests = process.env.KANNA_RUN_LIVE_TITLE_TESTS === "1"
7
+ const LIVE_MESSAGE = "Please help me debug a websocket reconnection issue in a Bun server app"
8
+
9
+ if (shouldRunLiveTests) {
10
+ describe("live title generation", () => {
11
+ test("generates a title with Claude", async () => {
12
+ const adapter = new QuickResponseAdapter({
13
+ runClaudeStructured,
14
+ runCodexStructured: async () => {
15
+ throw new Error("Codex fallback should not be used in the Claude live title test")
16
+ },
17
+ })
18
+
19
+ const result = await generateTitleForChatDetailed(LIVE_MESSAGE, process.cwd(), adapter)
20
+
21
+ expect(result.usedFallback).toBe(false)
22
+ expect(result.failureMessage).toBeNull()
23
+ expect(typeof result.title).toBe("string")
24
+ expect(result.title).not.toBe(fallbackTitleFromMessage(LIVE_MESSAGE))
25
+ }, 15_000)
26
+
27
+ test("generates a title with Codex", async () => {
28
+ const codexManager = new CodexAppServerManager()
29
+ const adapter = new QuickResponseAdapter({
30
+ runClaudeStructured: async () => {
31
+ throw new Error("Claude should not be used in the Codex live title test")
32
+ },
33
+ runCodexStructured: async (args) => runCodexStructured(codexManager, args),
34
+ })
35
+
36
+ const result = await generateTitleForChatDetailed(LIVE_MESSAGE, process.cwd(), adapter)
37
+
38
+ expect(result.usedFallback).toBe(false)
39
+ expect(result.failureMessage).toBeNull()
40
+ expect(typeof result.title).toBe("string")
41
+ expect(result.title).not.toBe(fallbackTitleFromMessage(LIVE_MESSAGE))
42
+ }, 15_000)
43
+ })
44
+ }
@@ -176,6 +176,187 @@ describe("ws-router", () => {
176
176
  })
177
177
  })
178
178
 
179
+ test("marks chats read and rebroadcasts sidebar snapshots", async () => {
180
+ const state = createEmptyState()
181
+ state.projectsById.set("project-1", {
182
+ id: "project-1",
183
+ localPath: "/tmp/project",
184
+ title: "Project",
185
+ createdAt: 1,
186
+ updatedAt: 1,
187
+ })
188
+ state.projectIdsByPath.set("/tmp/project", "project-1")
189
+ state.chatsById.set("chat-1", {
190
+ id: "chat-1",
191
+ projectId: "project-1",
192
+ title: "Chat",
193
+ createdAt: 1,
194
+ updatedAt: 1,
195
+ unread: true,
196
+ provider: null,
197
+ planMode: false,
198
+ sessionToken: null,
199
+ lastTurnOutcome: null,
200
+ })
201
+
202
+ const store = {
203
+ state,
204
+ async setChatReadState(chatId: string, unread: boolean) {
205
+ const chat = state.chatsById.get(chatId)
206
+ if (!chat) throw new Error("Chat not found")
207
+ chat.unread = unread
208
+ },
209
+ }
210
+
211
+ const router = createWsRouter({
212
+ store: store as never,
213
+ agent: { getActiveStatuses: () => new Map() } as never,
214
+ terminals: {
215
+ getSnapshot: () => null,
216
+ onEvent: () => () => {},
217
+ } as never,
218
+ keybindings: {
219
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
220
+ onChange: () => () => {},
221
+ } as never,
222
+ refreshDiscovery: async () => [],
223
+ getDiscoveredProjects: () => [],
224
+ machineDisplayName: "Local Machine",
225
+ updateManager: null,
226
+ })
227
+ const wsA = new FakeWebSocket()
228
+ const wsB = new FakeWebSocket()
229
+
230
+ router.handleOpen(wsA as never)
231
+ router.handleOpen(wsB as never)
232
+
233
+ router.handleMessage(
234
+ wsA as never,
235
+ JSON.stringify({
236
+ v: 1,
237
+ type: "subscribe",
238
+ id: "sidebar-a",
239
+ topic: { type: "sidebar" },
240
+ })
241
+ )
242
+ router.handleMessage(
243
+ wsB as never,
244
+ JSON.stringify({
245
+ v: 1,
246
+ type: "subscribe",
247
+ id: "sidebar-b",
248
+ topic: { type: "sidebar" },
249
+ })
250
+ )
251
+
252
+ router.handleMessage(
253
+ wsA as never,
254
+ JSON.stringify({
255
+ v: 1,
256
+ type: "command",
257
+ id: "mark-read-1",
258
+ command: { type: "chat.markRead", chatId: "chat-1" },
259
+ })
260
+ )
261
+
262
+ await Promise.resolve()
263
+
264
+ expect(wsA.sent.at(-2)).toEqual({
265
+ v: PROTOCOL_VERSION,
266
+ type: "ack",
267
+ id: "mark-read-1",
268
+ })
269
+ expect(wsA.sent.at(-1)).toEqual({
270
+ v: PROTOCOL_VERSION,
271
+ type: "snapshot",
272
+ id: "sidebar-a",
273
+ snapshot: {
274
+ type: "sidebar",
275
+ data: {
276
+ projectGroups: [{
277
+ groupKey: "project-1",
278
+ localPath: "/tmp/project",
279
+ chats: [{
280
+ _id: "chat-1",
281
+ _creationTime: 1,
282
+ chatId: "chat-1",
283
+ title: "Chat",
284
+ status: "idle",
285
+ unread: false,
286
+ localPath: "/tmp/project",
287
+ provider: null,
288
+ lastMessageAt: undefined,
289
+ hasAutomation: false,
290
+ }],
291
+ }],
292
+ },
293
+ },
294
+ })
295
+ expect(wsB.sent.at(-1)).toEqual({
296
+ v: PROTOCOL_VERSION,
297
+ type: "snapshot",
298
+ id: "sidebar-b",
299
+ snapshot: {
300
+ type: "sidebar",
301
+ data: {
302
+ projectGroups: [{
303
+ groupKey: "project-1",
304
+ localPath: "/tmp/project",
305
+ chats: [{
306
+ _id: "chat-1",
307
+ _creationTime: 1,
308
+ chatId: "chat-1",
309
+ title: "Chat",
310
+ status: "idle",
311
+ unread: false,
312
+ localPath: "/tmp/project",
313
+ provider: null,
314
+ lastMessageAt: undefined,
315
+ hasAutomation: false,
316
+ }],
317
+ }],
318
+ },
319
+ },
320
+ })
321
+ })
322
+
323
+ test("broadcasts background title-generation errors to connected clients", () => {
324
+ let reportBackgroundError: ((message: string) => void) | null | undefined
325
+ const router = createWsRouter({
326
+ store: { state: createEmptyState() } as never,
327
+ agent: {
328
+ getActiveStatuses: () => new Map(),
329
+ setBackgroundErrorReporter: (reporter: ((message: string) => void) | null) => {
330
+ reportBackgroundError = reporter
331
+ },
332
+ } as never,
333
+ terminals: {
334
+ getSnapshot: () => null,
335
+ onEvent: () => () => {},
336
+ } as never,
337
+ keybindings: {
338
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
339
+ onChange: () => () => {},
340
+ } as never,
341
+ refreshDiscovery: async () => [],
342
+ getDiscoveredProjects: () => [],
343
+ machineDisplayName: "Local Machine",
344
+ updateManager: null,
345
+ })
346
+ const ws = new FakeWebSocket()
347
+ router.handleOpen(ws as never)
348
+
349
+ reportBackgroundError?.("[title-generation] chat chat-1 failed")
350
+
351
+ expect(ws.sent).toEqual([
352
+ {
353
+ v: PROTOCOL_VERSION,
354
+ type: "error",
355
+ message: "[title-generation] chat chat-1 failed",
356
+ },
357
+ ])
358
+ })
359
+
179
360
  test("subscribes to keybindings snapshots and writes keybindings through the router", async () => {
180
361
  const initialSnapshot: KeybindingsSnapshot = DEFAULT_KEYBINDINGS_SNAPSHOT
181
362
  const keybindings = {
@@ -138,6 +138,16 @@ export function createWsRouter({
138
138
  }
139
139
  }
140
140
 
141
+ function broadcastError(message: string) {
142
+ for (const ws of sockets) {
143
+ send(ws, {
144
+ v: PROTOCOL_VERSION,
145
+ type: "error",
146
+ message,
147
+ })
148
+ }
149
+ }
150
+
141
151
  function pushTerminalSnapshot(terminalId: string) {
142
152
  for (const ws of sockets) {
143
153
  for (const [id, topic] of ws.data.subscriptions.entries()) {
@@ -183,6 +193,8 @@ export function createWsRouter({
183
193
  }
184
194
  }) ?? (() => {})
185
195
 
196
+ agent.setBackgroundErrorReporter?.(broadcastError)
197
+
186
198
  async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
187
199
  const { command, id } = message
188
200
  try {
@@ -275,6 +287,11 @@ export function createWsRouter({
275
287
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
276
288
  break
277
289
  }
290
+ case "chat.markRead": {
291
+ await store.setChatReadState(command.chatId, false)
292
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
293
+ break
294
+ }
278
295
  case "chat.send": {
279
296
  const result = await agent.send(command)
280
297
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
@@ -374,6 +391,7 @@ export function createWsRouter({
374
391
  void handleCommand(ws, parsed)
375
392
  },
376
393
  dispose() {
394
+ agent.setBackgroundErrorReporter?.(null)
377
395
  disposeTerminalEvents()
378
396
  disposeKeybindingEvents()
379
397
  disposeUpdateEvents()
@@ -62,6 +62,7 @@ export type ClientCommand =
62
62
  | { type: "chat.create"; projectId: string }
63
63
  | { type: "chat.rename"; chatId: string; title: string }
64
64
  | { type: "chat.delete"; chatId: string }
65
+ | { type: "chat.markRead"; chatId: string }
65
66
  | {
66
67
  type: "chat.send"
67
68
  chatId?: string
@@ -192,6 +192,7 @@ export interface SidebarChatRow {
192
192
  chatId: string
193
193
  title: string
194
194
  status: KannaStatus
195
+ unread: boolean
195
196
  localPath: string
196
197
  provider: AgentProvider | null
197
198
  lastMessageAt?: number