kanna-code 0.1.4 → 0.3.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.
@@ -2,7 +2,7 @@ import { appendFile, mkdir } from "node:fs/promises"
2
2
  import { homedir } from "node:os"
3
3
  import path from "node:path"
4
4
  import { getDataDir, LOG_PREFIX } from "../shared/branding"
5
- import type { TranscriptEntry } from "../shared/types"
5
+ import type { AgentProvider, TranscriptEntry } from "../shared/types"
6
6
  import { STORE_VERSION } from "../shared/types"
7
7
  import {
8
8
  type ChatEvent,
@@ -29,6 +29,7 @@ export class EventStore {
29
29
  readonly dataDir = DATA_DIR
30
30
  readonly state: StoreState = createEmptyState()
31
31
  private writeChain = Promise.resolve()
32
+ private storageReset = false
32
33
 
33
34
  async initialize() {
34
35
  await mkdir(DATA_DIR, { recursive: true })
@@ -50,14 +51,31 @@ export class EventStore {
50
51
  }
51
52
  }
52
53
 
54
+ private async clearStorage() {
55
+ if (this.storageReset) return
56
+ this.storageReset = true
57
+ this.resetState()
58
+ await Promise.all([
59
+ Bun.write(SNAPSHOT_PATH, ""),
60
+ Bun.write(PROJECTS_LOG, ""),
61
+ Bun.write(CHATS_LOG, ""),
62
+ Bun.write(MESSAGES_LOG, ""),
63
+ Bun.write(TURNS_LOG, ""),
64
+ ])
65
+ }
66
+
53
67
  private async loadSnapshot() {
54
68
  const file = Bun.file(SNAPSHOT_PATH)
55
69
  if (!(await file.exists())) return
56
70
 
57
71
  try {
58
- const parsed = (await file.json()) as SnapshotFile
72
+ const text = await file.text()
73
+ if (!text.trim()) return
74
+ const parsed = JSON.parse(text) as SnapshotFile
59
75
  if (parsed.v !== STORE_VERSION) {
60
- throw new Error(`Unsupported snapshot version ${String(parsed.v)}`)
76
+ console.warn(`${LOG_PREFIX} Resetting local chat history for store version ${STORE_VERSION}`)
77
+ await this.clearStorage()
78
+ return
61
79
  }
62
80
  for (const project of parsed.projects) {
63
81
  this.state.projectsById.set(project.id, { ...project })
@@ -70,8 +88,8 @@ export class EventStore {
70
88
  this.state.messagesByChatId.set(messageSet.chatId, cloneTranscriptEntries(messageSet.entries))
71
89
  }
72
90
  } catch (error) {
73
- console.warn(`${LOG_PREFIX} Failed to load snapshot, rebuilding from logs:`, error)
74
- this.resetState()
91
+ console.warn(`${LOG_PREFIX} Failed to load snapshot, resetting local history:`, error)
92
+ await this.clearStorage()
75
93
  }
76
94
  }
77
95
 
@@ -83,9 +101,13 @@ export class EventStore {
83
101
  }
84
102
 
85
103
  private async replayLogs() {
104
+ if (this.storageReset) return
86
105
  await this.replayLog<ProjectEvent>(PROJECTS_LOG)
106
+ if (this.storageReset) return
87
107
  await this.replayLog<ChatEvent>(CHATS_LOG)
108
+ if (this.storageReset) return
88
109
  await this.replayLog<MessageEvent>(MESSAGES_LOG)
110
+ if (this.storageReset) return
89
111
  await this.replayLog<TurnEvent>(TURNS_LOG)
90
112
  }
91
113
 
@@ -108,14 +130,21 @@ export class EventStore {
108
130
  const line = lines[index].trim()
109
131
  if (!line) continue
110
132
  try {
111
- const event = JSON.parse(line) as TEvent
112
- this.applyEvent(event)
133
+ const event = JSON.parse(line) as Partial<StoreEvent>
134
+ if (event.v !== STORE_VERSION) {
135
+ console.warn(`${LOG_PREFIX} Resetting local history from incompatible event log`)
136
+ await this.clearStorage()
137
+ return
138
+ }
139
+ this.applyEvent(event as StoreEvent)
113
140
  } catch (error) {
114
141
  if (index === lastNonEmpty) {
115
142
  console.warn(`${LOG_PREFIX} Ignoring corrupt trailing line in ${path.basename(filePath)}`)
116
143
  return
117
144
  }
118
- throw error
145
+ console.warn(`${LOG_PREFIX} Failed to replay ${path.basename(filePath)}, resetting local history:`, error)
146
+ await this.clearStorage()
147
+ return
119
148
  }
120
149
  }
121
150
  }
@@ -150,8 +179,9 @@ export class EventStore {
150
179
  title: event.title,
151
180
  createdAt: event.timestamp,
152
181
  updatedAt: event.timestamp,
182
+ provider: null,
153
183
  planMode: false,
154
- resumeSessionId: null,
184
+ sessionToken: null,
155
185
  lastTurnOutcome: null,
156
186
  }
157
187
  this.state.chatsById.set(chat.id, chat)
@@ -171,6 +201,13 @@ export class EventStore {
171
201
  chat.updatedAt = event.timestamp
172
202
  break
173
203
  }
204
+ case "chat_provider_set": {
205
+ const chat = this.state.chatsById.get(event.chatId)
206
+ if (!chat) break
207
+ chat.provider = event.provider
208
+ chat.updatedAt = event.timestamp
209
+ break
210
+ }
174
211
  case "chat_plan_mode_set": {
175
212
  const chat = this.state.chatsById.get(event.chatId)
176
213
  if (!chat) break
@@ -181,15 +218,8 @@ export class EventStore {
181
218
  case "message_appended": {
182
219
  const chat = this.state.chatsById.get(event.chatId)
183
220
  if (chat) {
184
- // Only update lastMessageAt for user-sent messages so the sidebar
185
- // sorts by "last sent" rather than "last received".
186
- try {
187
- const parsed = JSON.parse(event.entry.message)
188
- if (parsed.type === "user_prompt") {
189
- chat.lastMessageAt = event.entry.createdAt
190
- }
191
- } catch {
192
- // non-JSON entry, skip
221
+ if (event.entry.kind === "user_prompt") {
222
+ chat.lastMessageAt = event.entry.createdAt
193
223
  }
194
224
  chat.updatedAt = Math.max(chat.updatedAt, event.entry.createdAt)
195
225
  }
@@ -225,10 +255,10 @@ export class EventStore {
225
255
  chat.lastTurnOutcome = "cancelled"
226
256
  break
227
257
  }
228
- case "resume_session_set": {
258
+ case "session_token_set": {
229
259
  const chat = this.state.chatsById.get(event.chatId)
230
260
  if (!chat) break
231
- chat.resumeSessionId = event.sessionId
261
+ chat.sessionToken = event.sessionToken
232
262
  chat.updatedAt = event.timestamp
233
263
  break
234
264
  }
@@ -326,6 +356,19 @@ export class EventStore {
326
356
  await this.append(CHATS_LOG, event)
327
357
  }
328
358
 
359
+ async setChatProvider(chatId: string, provider: AgentProvider) {
360
+ const chat = this.requireChat(chatId)
361
+ if (chat.provider === provider) return
362
+ const event: ChatEvent = {
363
+ v: STORE_VERSION,
364
+ type: "chat_provider_set",
365
+ timestamp: Date.now(),
366
+ chatId,
367
+ provider,
368
+ }
369
+ await this.append(CHATS_LOG, event)
370
+ }
371
+
329
372
  async setPlanMode(chatId: string, planMode: boolean) {
330
373
  const chat = this.requireChat(chatId)
331
374
  if (chat.planMode === planMode) return
@@ -396,15 +439,15 @@ export class EventStore {
396
439
  await this.append(TURNS_LOG, event)
397
440
  }
398
441
 
399
- async setResumeSession(chatId: string, sessionId: string | null) {
442
+ async setSessionToken(chatId: string, sessionToken: string | null) {
400
443
  const chat = this.requireChat(chatId)
401
- if (chat.resumeSessionId === sessionId) return
444
+ if (chat.sessionToken === sessionToken) return
402
445
  const event: TurnEvent = {
403
446
  v: STORE_VERSION,
404
- type: "resume_session_set",
447
+ type: "session_token_set",
405
448
  timestamp: Date.now(),
406
449
  chatId,
407
- sessionId,
450
+ sessionToken,
408
451
  }
409
452
  await this.append(TURNS_LOG, event)
410
453
  }
@@ -460,19 +503,23 @@ export class EventStore {
460
503
  entries: cloneTranscriptEntries(entries),
461
504
  })),
462
505
  }
506
+
463
507
  await Bun.write(SNAPSHOT_PATH, JSON.stringify(snapshot, null, 2))
464
- await Bun.write(PROJECTS_LOG, "")
465
- await Bun.write(CHATS_LOG, "")
466
- await Bun.write(MESSAGES_LOG, "")
467
- await Bun.write(TURNS_LOG, "")
508
+ await Promise.all([
509
+ Bun.write(PROJECTS_LOG, ""),
510
+ Bun.write(CHATS_LOG, ""),
511
+ Bun.write(MESSAGES_LOG, ""),
512
+ Bun.write(TURNS_LOG, ""),
513
+ ])
468
514
  }
469
515
 
470
516
  private async shouldCompact() {
471
- const files = [PROJECTS_LOG, CHATS_LOG, MESSAGES_LOG, TURNS_LOG]
472
- let total = 0
473
- for (const filePath of files) {
474
- total += Bun.file(filePath).size
475
- }
476
- return total >= COMPACTION_THRESHOLD_BYTES
517
+ const sizes = await Promise.all([
518
+ Bun.file(PROJECTS_LOG).size,
519
+ Bun.file(CHATS_LOG).size,
520
+ Bun.file(MESSAGES_LOG).size,
521
+ Bun.file(TURNS_LOG).size,
522
+ ])
523
+ return sizes.reduce((total, size) => total + size, 0) >= COMPACTION_THRESHOLD_BYTES
477
524
  }
478
525
  }
@@ -1,4 +1,4 @@
1
- import type { ProjectSummary, TranscriptEntry } from "../shared/types"
1
+ import type { AgentProvider, ProjectSummary, TranscriptEntry } from "../shared/types"
2
2
 
3
3
  export interface ProjectRecord extends ProjectSummary {
4
4
  deletedAt?: number
@@ -11,8 +11,9 @@ export interface ChatRecord {
11
11
  createdAt: number
12
12
  updatedAt: number
13
13
  deletedAt?: number
14
+ provider: AgentProvider | null
14
15
  planMode: boolean
15
- resumeSessionId: string | null
16
+ sessionToken: string | null
16
17
  lastMessageAt?: number
17
18
  lastTurnOutcome: "success" | "failed" | "cancelled" | null
18
19
  }
@@ -25,7 +26,7 @@ export interface StoreState {
25
26
  }
26
27
 
27
28
  export interface SnapshotFile {
28
- v: 1
29
+ v: 2
29
30
  generatedAt: number
30
31
  projects: ProjectRecord[]
31
32
  chats: ChatRecord[]
@@ -33,14 +34,14 @@ export interface SnapshotFile {
33
34
  }
34
35
 
35
36
  export type ProjectEvent = {
36
- v: 1
37
+ v: 2
37
38
  type: "project_opened"
38
39
  timestamp: number
39
40
  projectId: string
40
41
  localPath: string
41
42
  title: string
42
43
  } | {
43
- v: 1
44
+ v: 2
44
45
  type: "project_removed"
45
46
  timestamp: number
46
47
  projectId: string
@@ -48,7 +49,7 @@ export type ProjectEvent = {
48
49
 
49
50
  export type ChatEvent =
50
51
  | {
51
- v: 1
52
+ v: 2
52
53
  type: "chat_created"
53
54
  timestamp: number
54
55
  chatId: string
@@ -56,20 +57,27 @@ export type ChatEvent =
56
57
  title: string
57
58
  }
58
59
  | {
59
- v: 1
60
+ v: 2
60
61
  type: "chat_renamed"
61
62
  timestamp: number
62
63
  chatId: string
63
64
  title: string
64
65
  }
65
66
  | {
66
- v: 1
67
+ v: 2
67
68
  type: "chat_deleted"
68
69
  timestamp: number
69
70
  chatId: string
70
71
  }
71
72
  | {
72
- v: 1
73
+ v: 2
74
+ type: "chat_provider_set"
75
+ timestamp: number
76
+ chatId: string
77
+ provider: AgentProvider
78
+ }
79
+ | {
80
+ v: 2
73
81
  type: "chat_plan_mode_set"
74
82
  timestamp: number
75
83
  chatId: string
@@ -77,7 +85,7 @@ export type ChatEvent =
77
85
  }
78
86
 
79
87
  export type MessageEvent = {
80
- v: 1
88
+ v: 2
81
89
  type: "message_appended"
82
90
  timestamp: number
83
91
  chatId: string
@@ -86,36 +94,36 @@ export type MessageEvent = {
86
94
 
87
95
  export type TurnEvent =
88
96
  | {
89
- v: 1
97
+ v: 2
90
98
  type: "turn_started"
91
99
  timestamp: number
92
100
  chatId: string
93
101
  }
94
102
  | {
95
- v: 1
103
+ v: 2
96
104
  type: "turn_finished"
97
105
  timestamp: number
98
106
  chatId: string
99
107
  }
100
108
  | {
101
- v: 1
109
+ v: 2
102
110
  type: "turn_failed"
103
111
  timestamp: number
104
112
  chatId: string
105
113
  error: string
106
114
  }
107
115
  | {
108
- v: 1
116
+ v: 2
109
117
  type: "turn_cancelled"
110
118
  timestamp: number
111
119
  chatId: string
112
120
  }
113
121
  | {
114
- v: 1
115
- type: "resume_session_set"
122
+ v: 2
123
+ type: "session_token_set"
116
124
  timestamp: number
117
125
  chatId: string
118
- sessionId: string | null
126
+ sessionToken: string | null
119
127
  }
120
128
 
121
129
  export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | TurnEvent
@@ -1,43 +1,36 @@
1
- import { query } from "@anthropic-ai/claude-agent-sdk"
1
+ import { QuickResponseAdapter } from "./quick-response"
2
2
 
3
- export async function generateTitleForChat(messageContent: string): Promise<string | null> {
4
- try {
5
- const q = query({
6
- prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message. Return JSON matching the schema.\n\n${messageContent}`,
7
- options: {
8
- model: "haiku",
9
- tools: [],
10
- systemPrompt: "",
11
- effort: "low",
12
- permissionMode: "bypassPermissions",
13
- outputFormat: {
14
- type: "json_schema",
15
- schema: {
16
- type: "object",
17
- properties: {
18
- title: { type: "string" },
19
- },
20
- required: ["title"],
21
- additionalProperties: false,
22
- },
23
- },
24
- env: { ...process.env },
25
- },
26
- })
3
+ const TITLE_SCHEMA = {
4
+ type: "object",
5
+ properties: {
6
+ title: { type: "string" },
7
+ },
8
+ required: ["title"],
9
+ additionalProperties: false,
10
+ } as const
27
11
 
28
- try {
29
- for await (const message of q) {
30
- if ("result" in message) {
31
- const output = (message as Record<string, unknown>).structured_output as { title?: string } | undefined
32
- return typeof output?.title === "string" ? output.title.slice(0, 80) : null
33
- }
34
- }
35
- } finally {
36
- q.close()
37
- }
12
+ function normalizeGeneratedTitle(value: unknown): string | null {
13
+ if (typeof value !== "string") return null
14
+ const normalized = value.replace(/\s+/g, " ").trim().slice(0, 80)
15
+ if (!normalized || normalized === "New Chat") return null
16
+ return normalized
17
+ }
18
+
19
+ export async function generateTitleForChat(
20
+ messageContent: string,
21
+ cwd: string,
22
+ adapter = new QuickResponseAdapter()
23
+ ): Promise<string | null> {
24
+ const result = await adapter.generateStructured<string>({
25
+ cwd,
26
+ task: "conversation title generation",
27
+ prompt: `Generate a short, descriptive title (under 30 chars) for a conversation that starts with this message.\n\n${messageContent}`,
28
+ schema: TITLE_SCHEMA,
29
+ parse: (value) => {
30
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
31
+ return normalizeGeneratedTitle(output.title)
32
+ },
33
+ })
38
34
 
39
- return null
40
- } catch {
41
- return null
42
- }
35
+ return result
43
36
  }
@@ -0,0 +1,19 @@
1
+ import type { AccountInfo, AgentProvider, NormalizedToolCall, TranscriptEntry } from "../shared/types"
2
+
3
+ export interface HarnessEvent {
4
+ type: "transcript" | "session_token"
5
+ entry?: TranscriptEntry
6
+ sessionToken?: string
7
+ }
8
+
9
+ export interface HarnessToolRequest {
10
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
11
+ }
12
+
13
+ export interface HarnessTurn {
14
+ provider: AgentProvider
15
+ stream: AsyncIterable<HarnessEvent>
16
+ getAccountInfo?: () => Promise<AccountInfo | null>
17
+ interrupt: () => Promise<void>
18
+ close: () => void
19
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ codexServiceTierFromModelOptions,
4
+ normalizeClaudeModelOptions,
5
+ normalizeCodexModelOptions,
6
+ } from "./provider-catalog"
7
+
8
+ describe("provider catalog normalization", () => {
9
+ test("maps legacy Claude effort into shared model options", () => {
10
+ expect(normalizeClaudeModelOptions(undefined, "max")).toEqual({
11
+ reasoningEffort: "max",
12
+ })
13
+ })
14
+
15
+ test("normalizes Codex model options and fast mode defaults", () => {
16
+ expect(normalizeCodexModelOptions(undefined)).toEqual({
17
+ reasoningEffort: "high",
18
+ fastMode: false,
19
+ })
20
+
21
+ const normalized = normalizeCodexModelOptions({
22
+ codex: {
23
+ reasoningEffort: "xhigh",
24
+ fastMode: true,
25
+ },
26
+ })
27
+
28
+ expect(normalized).toEqual({
29
+ reasoningEffort: "xhigh",
30
+ fastMode: true,
31
+ })
32
+ expect(codexServiceTierFromModelOptions(normalized)).toBe("fast")
33
+ })
34
+ })
@@ -0,0 +1,77 @@
1
+ import type {
2
+ AgentProvider,
3
+ ClaudeModelOptions,
4
+ CodexModelOptions,
5
+ ModelOptions,
6
+ ProviderCatalogEntry,
7
+ ProviderModelOption,
8
+ ServiceTier,
9
+ } from "../shared/types"
10
+ import {
11
+ DEFAULT_CLAUDE_MODEL_OPTIONS,
12
+ DEFAULT_CODEX_MODEL_OPTIONS,
13
+ PROVIDERS,
14
+ isClaudeReasoningEffort,
15
+ isCodexReasoningEffort,
16
+ } from "../shared/types"
17
+
18
+ const HARD_CODED_CODEX_MODELS: ProviderModelOption[] = [
19
+ { id: "gpt-5.4", label: "GPT-5.4", supportsEffort: false },
20
+ { id: "gpt-5.3-codex", label: "GPT-5.3 Codex", supportsEffort: false },
21
+ { id: "gpt-5.3-codex-spark", label: "GPT-5.3 Codex Spark", supportsEffort: false },
22
+ ]
23
+
24
+ export const SERVER_PROVIDERS: ProviderCatalogEntry[] = PROVIDERS.map((provider) =>
25
+ provider.id === "codex"
26
+ ? {
27
+ ...provider,
28
+ defaultModel: "gpt-5.4",
29
+ models: HARD_CODED_CODEX_MODELS,
30
+ }
31
+ : provider
32
+ )
33
+
34
+ export function getServerProviderCatalog(provider: AgentProvider): ProviderCatalogEntry {
35
+ const entry = SERVER_PROVIDERS.find((candidate) => candidate.id === provider)
36
+ if (!entry) {
37
+ throw new Error(`Unknown provider: ${provider}`)
38
+ }
39
+ return entry
40
+ }
41
+
42
+ export function normalizeServerModel(provider: AgentProvider, model?: string): string {
43
+ const catalog = getServerProviderCatalog(provider)
44
+ if (model && catalog.models.some((candidate) => candidate.id === model)) {
45
+ return model
46
+ }
47
+ return catalog.defaultModel
48
+ }
49
+
50
+ export function normalizeClaudeModelOptions(modelOptions?: ModelOptions, legacyEffort?: string): ClaudeModelOptions {
51
+ const reasoningEffort = modelOptions?.claude?.reasoningEffort
52
+ return {
53
+ reasoningEffort: isClaudeReasoningEffort(reasoningEffort)
54
+ ? reasoningEffort
55
+ : isClaudeReasoningEffort(legacyEffort)
56
+ ? legacyEffort
57
+ : DEFAULT_CLAUDE_MODEL_OPTIONS.reasoningEffort,
58
+ }
59
+ }
60
+
61
+ export function normalizeCodexModelOptions(modelOptions?: ModelOptions, legacyEffort?: string): CodexModelOptions {
62
+ const reasoningEffort = modelOptions?.codex?.reasoningEffort
63
+ return {
64
+ reasoningEffort: isCodexReasoningEffort(reasoningEffort)
65
+ ? reasoningEffort
66
+ : isCodexReasoningEffort(legacyEffort)
67
+ ? legacyEffort
68
+ : DEFAULT_CODEX_MODEL_OPTIONS.reasoningEffort,
69
+ fastMode: typeof modelOptions?.codex?.fastMode === "boolean"
70
+ ? modelOptions.codex.fastMode
71
+ : DEFAULT_CODEX_MODEL_OPTIONS.fastMode,
72
+ }
73
+ }
74
+
75
+ export function codexServiceTierFromModelOptions(modelOptions: CodexModelOptions): ServiceTier | undefined {
76
+ return modelOptions.fastMode ? "fast" : undefined
77
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { generateTitleForChat } from "./generate-title"
3
+ import { QuickResponseAdapter } from "./quick-response"
4
+
5
+ describe("QuickResponseAdapter", () => {
6
+ test("returns the Claude structured result when it validates", async () => {
7
+ const adapter = new QuickResponseAdapter({
8
+ runClaudeStructured: async () => ({ title: "Claude title" }),
9
+ runCodexStructured: async () => ({ title: "Codex title" }),
10
+ })
11
+
12
+ const result = await adapter.generateStructured({
13
+ cwd: "/tmp/project",
14
+ task: "title generation",
15
+ prompt: "Generate a title",
16
+ schema: {
17
+ type: "object",
18
+ properties: {
19
+ title: { type: "string" },
20
+ },
21
+ required: ["title"],
22
+ additionalProperties: false,
23
+ },
24
+ parse: (value) => {
25
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
26
+ return typeof output.title === "string" ? output.title : null
27
+ },
28
+ })
29
+
30
+ expect(result).toBe("Claude title")
31
+ })
32
+
33
+ test("falls back to Codex when Claude fails validation", async () => {
34
+ const adapter = new QuickResponseAdapter({
35
+ runClaudeStructured: async () => ({ bad: true }),
36
+ runCodexStructured: async () => ({ title: "Codex title" }),
37
+ })
38
+
39
+ const result = await adapter.generateStructured({
40
+ cwd: "/tmp/project",
41
+ task: "title generation",
42
+ prompt: "Generate a title",
43
+ schema: {
44
+ type: "object",
45
+ properties: {
46
+ title: { type: "string" },
47
+ },
48
+ required: ["title"],
49
+ additionalProperties: false,
50
+ },
51
+ parse: (value) => {
52
+ const output = value && typeof value === "object" ? value as { title?: unknown } : {}
53
+ return typeof output.title === "string" ? output.title : null
54
+ },
55
+ })
56
+
57
+ expect(result).toBe("Codex title")
58
+ })
59
+ })
60
+
61
+ describe("generateTitleForChat", () => {
62
+ test("sanitizes generated titles", async () => {
63
+ const title = await generateTitleForChat(
64
+ "hello",
65
+ "/tmp/project",
66
+ new QuickResponseAdapter({
67
+ runClaudeStructured: async () => ({ title: " Example\nTitle " }),
68
+ })
69
+ )
70
+
71
+ expect(title).toBe("Example Title")
72
+ })
73
+
74
+ test("rejects invalid generated titles", async () => {
75
+ const title = await generateTitleForChat(
76
+ "hello",
77
+ "/tmp/project",
78
+ new QuickResponseAdapter({
79
+ runClaudeStructured: async () => ({ title: " " }),
80
+ runCodexStructured: async () => ({ title: "New Chat" }),
81
+ })
82
+ )
83
+
84
+ expect(title).toBeNull()
85
+ })
86
+ })