kanna-code 0.18.0 → 0.20.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.
@@ -11,6 +11,7 @@ export interface ChatRecord {
11
11
  createdAt: number
12
12
  updatedAt: number
13
13
  deletedAt?: number
14
+ unread: boolean
14
15
  provider: AgentProvider | null
15
16
  planMode: boolean
16
17
  sessionToken: string | null
@@ -82,6 +83,13 @@ export type ChatEvent =
82
83
  chatId: string
83
84
  planMode: boolean
84
85
  }
86
+ | {
87
+ v: 2
88
+ type: "chat_read_state_set"
89
+ timestamp: number
90
+ chatId: string
91
+ unread: boolean
92
+ }
85
93
 
86
94
  export type MessageEvent = {
87
95
  v: 2
@@ -16,12 +16,39 @@ function normalizeGeneratedTitle(value: unknown): string | null {
16
16
  return normalized
17
17
  }
18
18
 
19
+ export function fallbackTitleFromMessage(messageContent: string): string | null {
20
+ const normalized = messageContent.replace(/\s+/g, " ").trim()
21
+ if (!normalized) return null
22
+ if (normalized.length <= 35) return normalized
23
+ return `${normalized.slice(0, 35)}...`
24
+ }
25
+
26
+ export interface GenerateChatTitleResult {
27
+ title: string | null
28
+ usedFallback: boolean
29
+ failureMessage: string | null
30
+ }
31
+
32
+ function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
33
+ if (failures.length === 0) return null
34
+ return failures.map((failure) => failure.reason).join("; ")
35
+ }
36
+
19
37
  export async function generateTitleForChat(
20
38
  messageContent: string,
21
39
  cwd: string,
22
40
  adapter = new QuickResponseAdapter()
23
41
  ): Promise<string | null> {
24
- const result = await adapter.generateStructured<string>({
42
+ const result = await generateTitleForChatDetailed(messageContent, cwd, adapter)
43
+ return result.title
44
+ }
45
+
46
+ export async function generateTitleForChatDetailed(
47
+ messageContent: string,
48
+ cwd: string,
49
+ adapter = new QuickResponseAdapter()
50
+ ): Promise<GenerateChatTitleResult> {
51
+ const result = await adapter.generateStructuredWithDiagnostics<string>({
25
52
  cwd,
26
53
  task: "conversation title generation",
27
54
  prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
@@ -32,5 +59,18 @@ export async function generateTitleForChat(
32
59
  },
33
60
  })
34
61
 
35
- return result
62
+ if (result.value) {
63
+ return {
64
+ title: result.value,
65
+ usedFallback: false,
66
+ failureMessage: null,
67
+ }
68
+ }
69
+
70
+ const fallbackTitle = fallbackTitleFromMessage(messageContent)
71
+ return {
72
+ title: fallbackTitle,
73
+ usedFallback: true,
74
+ failureMessage: summarizeFailures(result.failures),
75
+ }
36
76
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import { generateTitleForChat } from "./generate-title"
3
- import { QuickResponseAdapter } from "./quick-response"
2
+ import { fallbackTitleFromMessage, generateTitleForChat, generateTitleForChatDetailed } from "./generate-title"
3
+ import { getQuickResponseWorkspace, QuickResponseAdapter } from "./quick-response"
4
4
 
5
5
  describe("QuickResponseAdapter", () => {
6
6
  test("returns the Claude structured result when it validates", async () => {
@@ -85,6 +85,83 @@ describe("QuickResponseAdapter", () => {
85
85
 
86
86
  expect(result).toBe("Codex title")
87
87
  })
88
+
89
+ test("uses the Kanna app data root as the quick-response workspace", async () => {
90
+ const previousProfile = process.env.KANNA_RUNTIME_PROFILE
91
+ process.env.KANNA_RUNTIME_PROFILE = "dev"
92
+
93
+ try {
94
+ let claudeCwd = ""
95
+ const adapter = new QuickResponseAdapter({
96
+ runClaudeStructured: async (args) => {
97
+ claudeCwd = args.cwd
98
+ return { title: "Claude title" }
99
+ },
100
+ })
101
+
102
+ await adapter.generateStructured({
103
+ cwd: "/tmp/project",
104
+ task: "title generation",
105
+ prompt: "Generate a title",
106
+ schema: {
107
+ type: "object",
108
+ properties: {
109
+ title: { type: "string" },
110
+ },
111
+ required: ["title"],
112
+ additionalProperties: false,
113
+ },
114
+ parse: (value) => {
115
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
116
+ return typeof output.title === "string" ? output.title : null
117
+ },
118
+ })
119
+
120
+ expect(claudeCwd).toBe(getQuickResponseWorkspace(process.env))
121
+ expect(claudeCwd.endsWith("/.kanna-dev")).toBe(true)
122
+ } finally {
123
+ if (previousProfile === undefined) {
124
+ delete process.env.KANNA_RUNTIME_PROFILE
125
+ } else {
126
+ process.env.KANNA_RUNTIME_PROFILE = previousProfile
127
+ }
128
+ }
129
+ })
130
+
131
+ test("uses gpt-5.4-mini for Codex title generation fallback", async () => {
132
+ const requests: Array<{ cwd: string; prompt: string; model?: string }> = []
133
+ const adapter = new QuickResponseAdapter({
134
+ codexManager: {
135
+ async generateStructured(args: { cwd: string; prompt: string; model?: string }) {
136
+ requests.push(args)
137
+ return "{\"title\":\"Codex title\"}"
138
+ },
139
+ } as never,
140
+ runClaudeStructured: async () => null,
141
+ })
142
+
143
+ const result = await adapter.generateStructured({
144
+ cwd: "/tmp/project",
145
+ task: "title generation",
146
+ prompt: "Generate a title",
147
+ schema: {
148
+ type: "object",
149
+ properties: {
150
+ title: { type: "string" },
151
+ },
152
+ required: ["title"],
153
+ additionalProperties: false,
154
+ },
155
+ parse: (value) => {
156
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
157
+ return typeof output.title === "string" ? output.title : null
158
+ },
159
+ })
160
+
161
+ expect(result).toBe("Codex title")
162
+ expect(requests).toHaveLength(1)
163
+ expect(requests[0]?.model).toBe("gpt-5.4-mini")
164
+ })
88
165
  })
89
166
 
90
167
  describe("generateTitleForChat", () => {
@@ -110,6 +187,52 @@ describe("generateTitleForChat", () => {
110
187
  })
111
188
  )
112
189
 
113
- expect(title).toBeNull()
190
+ expect(title).toBe("hello")
191
+ })
192
+
193
+ test("falls back to the first 35 characters of the message with ellipsis", async () => {
194
+ const title = await generateTitleForChat(
195
+ "This message is definitely longer than thirty five characters",
196
+ "/tmp/project",
197
+ new QuickResponseAdapter({
198
+ runClaudeStructured: async () => {
199
+ throw new Error("Not authenticated")
200
+ },
201
+ runCodexStructured: async () => null,
202
+ })
203
+ )
204
+
205
+ expect(title).toBe("This message is definitely longer t...")
206
+ })
207
+
208
+ test("returns fallback metadata when providers fail", async () => {
209
+ const result = await generateTitleForChatDetailed(
210
+ "hello there",
211
+ "/tmp/project",
212
+ new QuickResponseAdapter({
213
+ runClaudeStructured: async () => {
214
+ throw new Error("Not authenticated")
215
+ },
216
+ runCodexStructured: async () => {
217
+ throw new Error("Codex unavailable")
218
+ },
219
+ })
220
+ )
221
+
222
+ expect(result).toEqual({
223
+ title: "hello there",
224
+ usedFallback: true,
225
+ failureMessage: "claude failed conversation title generation: Not authenticated; codex failed conversation title generation: Codex unavailable",
226
+ })
227
+ })
228
+ })
229
+
230
+ describe("fallbackTitleFromMessage", () => {
231
+ test("normalizes whitespace", () => {
232
+ expect(fallbackTitleFromMessage(" hello\n world ")).toBe("hello world")
233
+ })
234
+
235
+ test("returns null for blank input", () => {
236
+ expect(fallbackTitleFromMessage(" \n ")).toBeNull()
114
237
  })
115
238
  })
@@ -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,13 +47,14 @@ 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",
51
54
  lastTurnOutcome: null,
52
55
  })
53
56
 
54
- const chat = deriveChatSnapshot(state, new Map(), "chat-1", () => [])
57
+ const chat = deriveChatSnapshot(state, new Map(), new Set(), "chat-1", () => [])
55
58
  expect(chat?.runtime.provider).toBe("claude")
56
59
  expect(chat?.availableProviders.length).toBeGreaterThan(1)
57
60
  expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
@@ -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,
@@ -97,6 +98,7 @@ export function deriveLocalProjectsSnapshot(
97
98
  export function deriveChatSnapshot(
98
99
  state: StoreState,
99
100
  activeStatuses: Map<string, KannaStatus>,
101
+ drainingChatIds: Set<string>,
100
102
  chatId: string,
101
103
  getMessages: (chatId: string) => ChatSnapshot["messages"]
102
104
  ): ChatSnapshot | null {
@@ -111,6 +113,7 @@ export function deriveChatSnapshot(
111
113
  localPath: project.localPath,
112
114
  title: chat.title,
113
115
  status: deriveStatus(chat, activeStatuses.get(chat.id)),
116
+ isDraining: drainingChatIds.has(chat.id),
114
117
  provider: chat.provider,
115
118
  planMode: chat.planMode,
116
119
  sessionToken: chat.sessionToken,
@@ -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
+ }