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.
- package/dist/client/assets/index-C952_xJD.css +32 -0
- package/dist/client/assets/index-lgfU0zw2.js +606 -0
- package/dist/client/chat-sounds/Blow.mp3 +0 -0
- package/dist/client/chat-sounds/Bottle.mp3 +0 -0
- package/dist/client/chat-sounds/Frog.mp3 +0 -0
- package/dist/client/chat-sounds/Funk.mp3 +0 -0
- package/dist/client/chat-sounds/Glass.mp3 +0 -0
- package/dist/client/chat-sounds/Ping.mp3 +0 -0
- package/dist/client/chat-sounds/Pop.mp3 +0 -0
- package/dist/client/chat-sounds/Purr.mp3 +0 -0
- package/dist/client/chat-sounds/Tink.mp3 +0 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +89 -2
- package/src/server/agent.ts +31 -12
- package/src/server/event-store.test.ts +65 -0
- package/src/server/event-store.ts +27 -1
- package/src/server/events.ts +8 -0
- package/src/server/generate-title.ts +42 -2
- package/src/server/quick-response.test.ts +126 -3
- package/src/server/quick-response.ts +107 -14
- package/src/server/read-models.test.ts +4 -0
- package/src/server/read-models.ts +1 -0
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/ws-router.test.ts +181 -0
- package/src/server/ws-router.ts +18 -0
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.ts +1 -0
- package/dist/client/assets/index-BvhI1JYs.css +0 -32
- package/dist/client/assets/index-CLqK0AWC.js +0 -606
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/client/index.html
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
7
7
|
<title>Kanna</title>
|
|
8
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-lgfU0zw2.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C952_xJD.css">
|
|
10
10
|
</head>
|
|
11
11
|
<body>
|
|
12
12
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/server/agent.test.ts
CHANGED
|
@@ -127,6 +127,10 @@ describe("attachment prompt helpers", () => {
|
|
|
127
127
|
|
|
128
128
|
describe("AgentCoordinator codex integration", () => {
|
|
129
129
|
test("generates a chat title in the background on the first user message", async () => {
|
|
130
|
+
let releaseTitle!: () => void
|
|
131
|
+
const titleGate = new Promise<void>((resolve) => {
|
|
132
|
+
releaseTitle = resolve
|
|
133
|
+
})
|
|
130
134
|
const fakeCodexManager = {
|
|
131
135
|
async startSession() {},
|
|
132
136
|
async startTurn(): Promise<HarnessTurn> {
|
|
@@ -169,7 +173,14 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
169
173
|
store: store as never,
|
|
170
174
|
onStateChange: () => {},
|
|
171
175
|
codexManager: fakeCodexManager as never,
|
|
172
|
-
generateTitle: async () =>
|
|
176
|
+
generateTitle: async () => {
|
|
177
|
+
await titleGate
|
|
178
|
+
return {
|
|
179
|
+
title: "Generated title",
|
|
180
|
+
usedFallback: false,
|
|
181
|
+
failureMessage: null,
|
|
182
|
+
}
|
|
183
|
+
},
|
|
173
184
|
})
|
|
174
185
|
|
|
175
186
|
await coordinator.send({
|
|
@@ -180,6 +191,8 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
180
191
|
model: "gpt-5.4",
|
|
181
192
|
})
|
|
182
193
|
|
|
194
|
+
expect(store.chat.title).toBe("first message")
|
|
195
|
+
releaseTitle()
|
|
183
196
|
await waitFor(() => store.chat.title === "Generated title")
|
|
184
197
|
expect(store.messages[0]?.kind).toBe("user_prompt")
|
|
185
198
|
})
|
|
@@ -233,7 +246,11 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
233
246
|
codexManager: fakeCodexManager as never,
|
|
234
247
|
generateTitle: async () => {
|
|
235
248
|
await titleGate
|
|
236
|
-
return
|
|
249
|
+
return {
|
|
250
|
+
title: "Generated title",
|
|
251
|
+
usedFallback: false,
|
|
252
|
+
failureMessage: null,
|
|
253
|
+
}
|
|
237
254
|
},
|
|
238
255
|
})
|
|
239
256
|
|
|
@@ -252,6 +269,76 @@ describe("AgentCoordinator codex integration", () => {
|
|
|
252
269
|
expect(store.chat.title).toBe("Manual title")
|
|
253
270
|
})
|
|
254
271
|
|
|
272
|
+
test("reports provider failure without a second rename after the optimistic title", async () => {
|
|
273
|
+
const fakeCodexManager = {
|
|
274
|
+
async startSession() {},
|
|
275
|
+
async startTurn(): Promise<HarnessTurn> {
|
|
276
|
+
async function* stream() {
|
|
277
|
+
yield {
|
|
278
|
+
type: "transcript" as const,
|
|
279
|
+
entry: timestamped({
|
|
280
|
+
kind: "system_init",
|
|
281
|
+
provider: "codex",
|
|
282
|
+
model: "gpt-5.4",
|
|
283
|
+
tools: [],
|
|
284
|
+
agents: [],
|
|
285
|
+
slashCommands: [],
|
|
286
|
+
mcpServers: [],
|
|
287
|
+
}),
|
|
288
|
+
}
|
|
289
|
+
yield {
|
|
290
|
+
type: "transcript" as const,
|
|
291
|
+
entry: timestamped({
|
|
292
|
+
kind: "result",
|
|
293
|
+
subtype: "success",
|
|
294
|
+
isError: false,
|
|
295
|
+
durationMs: 0,
|
|
296
|
+
result: "",
|
|
297
|
+
}),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
provider: "codex",
|
|
303
|
+
stream: stream(),
|
|
304
|
+
interrupt: async () => {},
|
|
305
|
+
close: () => {},
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const store = createFakeStore()
|
|
311
|
+
const backgroundErrors: string[] = []
|
|
312
|
+
const coordinator = new AgentCoordinator({
|
|
313
|
+
store: store as never,
|
|
314
|
+
onStateChange: () => {},
|
|
315
|
+
codexManager: fakeCodexManager as never,
|
|
316
|
+
generateTitle: async () => ({
|
|
317
|
+
title: "first message",
|
|
318
|
+
usedFallback: true,
|
|
319
|
+
failureMessage: "claude failed conversation title generation: Not authenticated",
|
|
320
|
+
}),
|
|
321
|
+
})
|
|
322
|
+
coordinator.setBackgroundErrorReporter((message) => {
|
|
323
|
+
backgroundErrors.push(message)
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await coordinator.send({
|
|
327
|
+
type: "chat.send",
|
|
328
|
+
chatId: "chat-1",
|
|
329
|
+
provider: "codex",
|
|
330
|
+
content: "first message",
|
|
331
|
+
model: "gpt-5.4",
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
expect(store.chat.title).toBe("first message")
|
|
335
|
+
await waitFor(() => store.turnFinishedCount === 1)
|
|
336
|
+
expect(store.chat.title).toBe("first message")
|
|
337
|
+
expect(backgroundErrors).toEqual([
|
|
338
|
+
"[title-generation] chat chat-1 failed provider title generation: claude failed conversation title generation: Not authenticated",
|
|
339
|
+
])
|
|
340
|
+
})
|
|
341
|
+
|
|
255
342
|
test("binds codex provider and reuses the session token on later turns", async () => {
|
|
256
343
|
const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
|
|
257
344
|
const fakeCodexManager = {
|
package/src/server/agent.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { normalizeToolCall } from "../shared/tools"
|
|
|
11
11
|
import type { ClientCommand } from "../shared/protocol"
|
|
12
12
|
import { EventStore } from "./event-store"
|
|
13
13
|
import { CodexAppServerManager } from "./codex-app-server"
|
|
14
|
-
import {
|
|
14
|
+
import { type GenerateChatTitleResult, generateTitleForChatDetailed } from "./generate-title"
|
|
15
15
|
import type { HarnessEvent, HarnessToolRequest, HarnessTurn } from "./harness-types"
|
|
16
16
|
import {
|
|
17
17
|
codexServiceTierFromModelOptions,
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
normalizeServerModel,
|
|
22
22
|
} from "./provider-catalog"
|
|
23
23
|
import { resolveClaudeApiModelId } from "../shared/types"
|
|
24
|
+
import { fallbackTitleFromMessage } from "./generate-title"
|
|
24
25
|
|
|
25
26
|
const CLAUDE_TOOLSET = [
|
|
26
27
|
"Skill",
|
|
@@ -67,7 +68,7 @@ interface AgentCoordinatorArgs {
|
|
|
67
68
|
store: EventStore
|
|
68
69
|
onStateChange: () => void
|
|
69
70
|
codexManager?: CodexAppServerManager
|
|
70
|
-
generateTitle?: (messageContent: string, cwd: string) => Promise<
|
|
71
|
+
generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
@@ -370,14 +371,19 @@ export class AgentCoordinator {
|
|
|
370
371
|
private readonly store: EventStore
|
|
371
372
|
private readonly onStateChange: () => void
|
|
372
373
|
private readonly codexManager: CodexAppServerManager
|
|
373
|
-
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<
|
|
374
|
+
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
375
|
+
private reportBackgroundError: ((message: string) => void) | null = null
|
|
374
376
|
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
375
377
|
|
|
376
378
|
constructor(args: AgentCoordinatorArgs) {
|
|
377
379
|
this.store = args.store
|
|
378
380
|
this.onStateChange = args.onStateChange
|
|
379
381
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
380
|
-
this.generateTitle = args.generateTitle ??
|
|
382
|
+
this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
setBackgroundErrorReporter(report: ((message: string) => void) | null) {
|
|
386
|
+
this.reportBackgroundError = report
|
|
381
387
|
}
|
|
382
388
|
|
|
383
389
|
getActiveStatuses() {
|
|
@@ -444,6 +450,11 @@ export class AgentCoordinator {
|
|
|
444
450
|
|
|
445
451
|
const existingMessages = this.store.getMessages(args.chatId)
|
|
446
452
|
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
453
|
+
const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
|
|
454
|
+
|
|
455
|
+
if (optimisticTitle) {
|
|
456
|
+
await this.store.renameChat(args.chatId, optimisticTitle)
|
|
457
|
+
}
|
|
447
458
|
|
|
448
459
|
if (args.appendUserPrompt) {
|
|
449
460
|
await this.store.appendMessage(
|
|
@@ -459,7 +470,7 @@ export class AgentCoordinator {
|
|
|
459
470
|
}
|
|
460
471
|
|
|
461
472
|
if (shouldGenerateTitle) {
|
|
462
|
-
void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
|
|
473
|
+
void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
|
|
463
474
|
}
|
|
464
475
|
|
|
465
476
|
const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
|
|
@@ -570,18 +581,26 @@ export class AgentCoordinator {
|
|
|
570
581
|
return { chatId }
|
|
571
582
|
}
|
|
572
583
|
|
|
573
|
-
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string) {
|
|
584
|
+
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
|
|
574
585
|
try {
|
|
575
|
-
const
|
|
576
|
-
if (
|
|
586
|
+
const result = await this.generateTitle(messageContent, cwd)
|
|
587
|
+
if (result.failureMessage) {
|
|
588
|
+
this.reportBackgroundError?.(
|
|
589
|
+
`[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
|
|
590
|
+
)
|
|
591
|
+
}
|
|
592
|
+
if (!result.title || result.usedFallback) return
|
|
577
593
|
|
|
578
594
|
const chat = this.store.requireChat(chatId)
|
|
579
|
-
if (chat.title !==
|
|
595
|
+
if (chat.title !== expectedCurrentTitle) return
|
|
580
596
|
|
|
581
|
-
await this.store.renameChat(chatId, title)
|
|
597
|
+
await this.store.renameChat(chatId, result.title)
|
|
582
598
|
this.onStateChange()
|
|
583
|
-
} catch {
|
|
584
|
-
|
|
599
|
+
} catch (error) {
|
|
600
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
601
|
+
this.reportBackgroundError?.(
|
|
602
|
+
`[title-generation] chat ${chatId} failed background title generation: ${message}`
|
|
603
|
+
)
|
|
585
604
|
}
|
|
586
605
|
}
|
|
587
606
|
|
|
@@ -65,6 +65,7 @@ describe("EventStore", () => {
|
|
|
65
65
|
title: "Chat",
|
|
66
66
|
createdAt: 1,
|
|
67
67
|
updatedAt: 5,
|
|
68
|
+
unread: false,
|
|
68
69
|
provider: null,
|
|
69
70
|
planMode: false,
|
|
70
71
|
sessionToken: null,
|
|
@@ -130,4 +131,68 @@ describe("EventStore", () => {
|
|
|
130
131
|
expect(snapshot.messages).toBeUndefined()
|
|
131
132
|
expect(existsSync(join(dataDir, "transcripts", `${chat.id}.jsonl`))).toBe(true)
|
|
132
133
|
})
|
|
134
|
+
|
|
135
|
+
test("marks chats unread on completed turns and clears unread when marked read", async () => {
|
|
136
|
+
const dataDir = await createTempDataDir()
|
|
137
|
+
const store = new EventStore(dataDir)
|
|
138
|
+
await store.initialize()
|
|
139
|
+
|
|
140
|
+
const project = await store.openProject("/tmp/project")
|
|
141
|
+
const chat = await store.createChat(project.id)
|
|
142
|
+
|
|
143
|
+
expect(store.getChat(chat.id)?.unread).toBe(false)
|
|
144
|
+
|
|
145
|
+
await store.recordTurnFinished(chat.id)
|
|
146
|
+
expect(store.getChat(chat.id)?.unread).toBe(true)
|
|
147
|
+
|
|
148
|
+
await store.setChatReadState(chat.id, false)
|
|
149
|
+
expect(store.getChat(chat.id)?.unread).toBe(false)
|
|
150
|
+
|
|
151
|
+
await store.recordTurnFailed(chat.id, "boom")
|
|
152
|
+
expect(store.getChat(chat.id)?.unread).toBe(true)
|
|
153
|
+
|
|
154
|
+
await store.recordTurnCancelled(chat.id)
|
|
155
|
+
expect(store.getChat(chat.id)?.unread).toBe(true)
|
|
156
|
+
|
|
157
|
+
await store.compact()
|
|
158
|
+
|
|
159
|
+
const reloaded = new EventStore(dataDir)
|
|
160
|
+
await reloaded.initialize()
|
|
161
|
+
expect(reloaded.getChat(chat.id)?.unread).toBe(true)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
test("loads chats without unread from older snapshots as read", async () => {
|
|
165
|
+
const dataDir = await createTempDataDir()
|
|
166
|
+
const snapshotPath = join(dataDir, "snapshot.json")
|
|
167
|
+
|
|
168
|
+
const snapshot = {
|
|
169
|
+
v: 2,
|
|
170
|
+
generatedAt: 10,
|
|
171
|
+
projects: [{
|
|
172
|
+
id: "project-1",
|
|
173
|
+
localPath: "/tmp/project",
|
|
174
|
+
title: "Project",
|
|
175
|
+
createdAt: 1,
|
|
176
|
+
updatedAt: 5,
|
|
177
|
+
}],
|
|
178
|
+
chats: [{
|
|
179
|
+
id: "chat-1",
|
|
180
|
+
projectId: "project-1",
|
|
181
|
+
title: "Chat",
|
|
182
|
+
createdAt: 1,
|
|
183
|
+
updatedAt: 5,
|
|
184
|
+
provider: null,
|
|
185
|
+
planMode: false,
|
|
186
|
+
sessionToken: null,
|
|
187
|
+
lastTurnOutcome: null,
|
|
188
|
+
}],
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await writeFile(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8")
|
|
192
|
+
|
|
193
|
+
const store = new EventStore(dataDir)
|
|
194
|
+
await store.initialize()
|
|
195
|
+
|
|
196
|
+
expect(store.getChat("chat-1")?.unread).toBe(false)
|
|
197
|
+
})
|
|
133
198
|
})
|
|
@@ -105,7 +105,10 @@ export class EventStore {
|
|
|
105
105
|
this.state.projectIdsByPath.set(project.localPath, project.id)
|
|
106
106
|
}
|
|
107
107
|
for (const chat of parsed.chats) {
|
|
108
|
-
this.state.chatsById.set(chat.id, {
|
|
108
|
+
this.state.chatsById.set(chat.id, {
|
|
109
|
+
...chat,
|
|
110
|
+
unread: chat.unread ?? false,
|
|
111
|
+
})
|
|
109
112
|
}
|
|
110
113
|
if (parsed.messages?.length) {
|
|
111
114
|
this.snapshotHasLegacyMessages = true
|
|
@@ -210,6 +213,7 @@ export class EventStore {
|
|
|
210
213
|
title: event.title,
|
|
211
214
|
createdAt: event.timestamp,
|
|
212
215
|
updatedAt: event.timestamp,
|
|
216
|
+
unread: false,
|
|
213
217
|
provider: null,
|
|
214
218
|
planMode: false,
|
|
215
219
|
sessionToken: null,
|
|
@@ -246,6 +250,13 @@ export class EventStore {
|
|
|
246
250
|
chat.updatedAt = event.timestamp
|
|
247
251
|
break
|
|
248
252
|
}
|
|
253
|
+
case "chat_read_state_set": {
|
|
254
|
+
const chat = this.state.chatsById.get(event.chatId)
|
|
255
|
+
if (!chat) break
|
|
256
|
+
chat.unread = event.unread
|
|
257
|
+
chat.updatedAt = event.timestamp
|
|
258
|
+
break
|
|
259
|
+
}
|
|
249
260
|
case "message_appended": {
|
|
250
261
|
this.applyMessageMetadata(event.chatId, event.entry)
|
|
251
262
|
const existing = this.legacyMessagesByChatId.get(event.chatId) ?? []
|
|
@@ -263,6 +274,7 @@ export class EventStore {
|
|
|
263
274
|
const chat = this.state.chatsById.get(event.chatId)
|
|
264
275
|
if (!chat) break
|
|
265
276
|
chat.updatedAt = event.timestamp
|
|
277
|
+
chat.unread = true
|
|
266
278
|
chat.lastTurnOutcome = "success"
|
|
267
279
|
break
|
|
268
280
|
}
|
|
@@ -270,6 +282,7 @@ export class EventStore {
|
|
|
270
282
|
const chat = this.state.chatsById.get(event.chatId)
|
|
271
283
|
if (!chat) break
|
|
272
284
|
chat.updatedAt = event.timestamp
|
|
285
|
+
chat.unread = true
|
|
273
286
|
chat.lastTurnOutcome = "failed"
|
|
274
287
|
break
|
|
275
288
|
}
|
|
@@ -438,6 +451,19 @@ export class EventStore {
|
|
|
438
451
|
await this.append(this.chatsLogPath, event)
|
|
439
452
|
}
|
|
440
453
|
|
|
454
|
+
async setChatReadState(chatId: string, unread: boolean) {
|
|
455
|
+
const chat = this.requireChat(chatId)
|
|
456
|
+
if (chat.unread === unread) return
|
|
457
|
+
const event: ChatEvent = {
|
|
458
|
+
v: STORE_VERSION,
|
|
459
|
+
type: "chat_read_state_set",
|
|
460
|
+
timestamp: Date.now(),
|
|
461
|
+
chatId,
|
|
462
|
+
unread,
|
|
463
|
+
}
|
|
464
|
+
await this.append(this.chatsLogPath, event)
|
|
465
|
+
}
|
|
466
|
+
|
|
441
467
|
async appendMessage(chatId: string, entry: TranscriptEntry) {
|
|
442
468
|
this.requireChat(chatId)
|
|
443
469
|
const payload = `${JSON.stringify(entry)}\n`
|
package/src/server/events.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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).
|
|
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
|
})
|