kanna-code 0.17.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.
Files changed (36) hide show
  1. package/dist/client/assets/index-C952_xJD.css +32 -0
  2. package/dist/client/assets/index-lgfU0zw2.js +606 -0
  3. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  4. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  5. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  6. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  7. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  8. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  9. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  10. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  11. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  12. package/dist/client/index.html +2 -2
  13. package/package.json +2 -1
  14. package/src/server/agent.test.ts +153 -4
  15. package/src/server/agent.ts +76 -15
  16. package/src/server/event-store.test.ts +65 -0
  17. package/src/server/event-store.ts +27 -1
  18. package/src/server/events.ts +8 -0
  19. package/src/server/generate-title.ts +42 -2
  20. package/src/server/paths.ts +4 -0
  21. package/src/server/quick-response.test.ts +126 -3
  22. package/src/server/quick-response.ts +107 -14
  23. package/src/server/read-models.test.ts +4 -0
  24. package/src/server/read-models.ts +1 -0
  25. package/src/server/server.ts +178 -1
  26. package/src/server/title-generation.live.test.ts +44 -0
  27. package/src/server/uploads.test.ts +282 -0
  28. package/src/server/uploads.ts +127 -0
  29. package/src/server/ws-router.test.ts +181 -0
  30. package/src/server/ws-router.ts +18 -0
  31. package/src/shared/protocol.ts +3 -0
  32. package/src/shared/tools.test.ts +65 -0
  33. package/src/shared/tools.ts +76 -0
  34. package/src/shared/types.ts +34 -1
  35. package/dist/client/assets/index-CtKZIZdD.js +0 -533
  36. package/dist/client/assets/index-Cwex1U8S.css +0 -32
@@ -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,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,
@@ -1,5 +1,7 @@
1
1
  import path from "node:path"
2
+ import { stat } from "node:fs/promises"
2
3
  import { APP_NAME, getRuntimeProfile } from "../shared/branding"
4
+ import type { ChatAttachment } from "../shared/types"
3
5
  import { EventStore } from "./event-store"
4
6
  import { AgentCoordinator } from "./agent"
5
7
  import { discoverProjects, type DiscoveredProject } from "./discovery"
@@ -9,6 +11,45 @@ import { TerminalManager } from "./terminal-manager"
9
11
  import { UpdateManager } from "./update-manager"
10
12
  import type { UpdateInstallAttemptResult } from "./cli-runtime"
11
13
  import { createWsRouter, type ClientState } from "./ws-router"
14
+ import { deleteProjectUpload, inferAttachmentContentType, persistProjectUpload } from "./uploads"
15
+ import { getProjectUploadDir } from "./paths"
16
+
17
+ const MAX_UPLOAD_FILES = 10
18
+ const MAX_UPLOAD_SIZE_BYTES = 25 * 1024 * 1024
19
+
20
+ export async function persistUploadedFiles(args: {
21
+ projectId: string
22
+ localPath: string
23
+ files: File[]
24
+ persistUpload?: typeof persistProjectUpload
25
+ }): Promise<ChatAttachment[]> {
26
+ const persistUpload = args.persistUpload ?? persistProjectUpload
27
+ const attachments: ChatAttachment[] = []
28
+
29
+ try {
30
+ for (const file of args.files) {
31
+ const bytes = new Uint8Array(await file.arrayBuffer())
32
+ const attachment = await persistUpload({
33
+ projectId: args.projectId,
34
+ localPath: args.localPath,
35
+ fileName: file.name,
36
+ bytes,
37
+ fallbackMimeType: file.type || undefined,
38
+ })
39
+ attachments.push(attachment)
40
+ }
41
+ } catch (error) {
42
+ await Promise.allSettled(
43
+ attachments.map((attachment) => deleteProjectUpload({
44
+ localPath: args.localPath,
45
+ storedName: path.basename(attachment.absolutePath),
46
+ }))
47
+ )
48
+ throw error
49
+ }
50
+
51
+ return attachments
52
+ }
12
53
 
13
54
  export interface StartKannaServerOptions {
14
55
  port?: number
@@ -79,7 +120,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
79
120
  server = Bun.serve<ClientState>({
80
121
  port: actualPort,
81
122
  hostname,
82
- fetch(req, serverInstance) {
123
+ async fetch(req, serverInstance) {
83
124
  const url = new URL(req.url)
84
125
 
85
126
  if (url.pathname === "/ws") {
@@ -95,6 +136,21 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
95
136
  return Response.json({ ok: true, port: actualPort })
96
137
  }
97
138
 
139
+ const uploadResponse = await handleProjectUpload(req, url, store)
140
+ if (uploadResponse) {
141
+ return uploadResponse
142
+ }
143
+
144
+ const deleteUploadResponse = await handleProjectUploadDelete(req, url, store)
145
+ if (deleteUploadResponse) {
146
+ return deleteUploadResponse
147
+ }
148
+
149
+ const attachmentContentResponse = await handleAttachmentContent(req, url, store)
150
+ if (attachmentContentResponse) {
151
+ return attachmentContentResponse
152
+ }
153
+
98
154
  return serveStatic(distDir, url.pathname)
99
155
  },
100
156
  websocket: {
@@ -140,6 +196,127 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
140
196
  }
141
197
  }
142
198
 
199
+ async function handleProjectUpload(req: Request, url: URL, store: EventStore) {
200
+ if (req.method !== "POST") {
201
+ return null
202
+ }
203
+
204
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads$/)
205
+ if (!match) {
206
+ return null
207
+ }
208
+
209
+ const project = store.getProject(match[1])
210
+ if (!project) {
211
+ return Response.json({ error: "Project not found" }, { status: 404 })
212
+ }
213
+
214
+ const formData = await req.formData()
215
+ const files = formData
216
+ .getAll("files")
217
+ .filter((value): value is File => value instanceof File)
218
+
219
+ if (files.length === 0) {
220
+ return Response.json({ error: "No files uploaded" }, { status: 400 })
221
+ }
222
+
223
+ if (files.length > MAX_UPLOAD_FILES) {
224
+ return Response.json({ error: `You can upload up to ${MAX_UPLOAD_FILES} files at a time.` }, { status: 400 })
225
+ }
226
+
227
+ for (const file of files) {
228
+ if (file.size > MAX_UPLOAD_SIZE_BYTES) {
229
+ return Response.json(
230
+ { error: `File "${file.name}" exceeds the ${Math.floor(MAX_UPLOAD_SIZE_BYTES / (1024 * 1024))} MB limit.` },
231
+ { status: 413 }
232
+ )
233
+ }
234
+ }
235
+
236
+ try {
237
+ const attachments = await persistUploadedFiles({
238
+ projectId: project.id,
239
+ localPath: project.localPath,
240
+ files,
241
+ })
242
+ return Response.json({ attachments })
243
+ } catch (error) {
244
+ console.error("[uploads] Upload failed:", error)
245
+ return Response.json({ error: "Upload failed" }, { status: 500 })
246
+ }
247
+ }
248
+
249
+ async function handleAttachmentContent(req: Request, url: URL, store: EventStore) {
250
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)\/content$/)
251
+ if (!match) {
252
+ return null
253
+ }
254
+
255
+ if (req.method !== "GET") {
256
+ return new Response(null, {
257
+ status: 405,
258
+ headers: {
259
+ Allow: "GET",
260
+ },
261
+ })
262
+ }
263
+
264
+ const project = store.getProject(match[1])
265
+ if (!project) {
266
+ return Response.json({ error: "Project not found" }, { status: 404 })
267
+ }
268
+
269
+ const storedName = decodeURIComponent(match[2])
270
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
271
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
272
+ }
273
+
274
+ const filePath = path.join(getProjectUploadDir(project.localPath), storedName)
275
+ const file = Bun.file(filePath)
276
+ try {
277
+ const info = await stat(filePath)
278
+ if (!info.isFile()) {
279
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
280
+ }
281
+ } catch {
282
+ return Response.json({ error: "Attachment not found" }, { status: 404 })
283
+ }
284
+
285
+ return new Response(file, {
286
+ headers: {
287
+ "Content-Type": inferAttachmentContentType(storedName, file.type),
288
+ },
289
+ })
290
+ }
291
+
292
+ async function handleProjectUploadDelete(req: Request, url: URL, store: EventStore) {
293
+ if (req.method !== "DELETE") {
294
+ return null
295
+ }
296
+
297
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/uploads\/([^/]+)$/)
298
+ if (!match) {
299
+ return null
300
+ }
301
+
302
+ const project = store.getProject(match[1])
303
+ if (!project) {
304
+ return Response.json({ error: "Project not found" }, { status: 404 })
305
+ }
306
+
307
+ const storedName = decodeURIComponent(match[2])
308
+ if (!storedName || storedName.includes("/") || storedName.includes("\\") || storedName === "." || storedName === "..") {
309
+ return Response.json({ error: "Invalid attachment path" }, { status: 400 })
310
+ }
311
+
312
+ const deleted = await deleteProjectUpload({
313
+ localPath: project.localPath,
314
+ storedName,
315
+ })
316
+
317
+ return Response.json({ ok: deleted })
318
+ }
319
+
143
320
  async function serveStatic(distDir: string, pathname: string) {
144
321
  const requestedPath = pathname === "/" ? "/index.html" : pathname
145
322
  const filePath = path.join(distDir, requestedPath)
@@ -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
+ }