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.
- package/dist/client/assets/index-Cdjl9mRd.js +606 -0
- package/dist/client/assets/index-D6aACRxb.css +32 -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 +633 -2
- package/src/server/agent.ts +349 -26
- 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 +5 -1
- package/src/server/read-models.ts +3 -0
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/ws-router.test.ts +186 -5
- package/src/server/ws-router.ts +26 -1
- package/src/shared/protocol.ts +2 -0
- package/src/shared/types.ts +2 -0
- package/dist/client/assets/index-BvhI1JYs.css +0 -32
- package/dist/client/assets/index-CLqK0AWC.js +0 -606
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
|
})
|
|
@@ -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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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:
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|