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.
@@ -0,0 +1,541 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { AgentCoordinator, normalizeClaudeStreamMessage } from "./agent"
3
+ import type { HarnessTurn } from "./harness-types"
4
+ import type { TranscriptEntry } from "../shared/types"
5
+
6
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
7
+ return {
8
+ _id: crypto.randomUUID(),
9
+ createdAt: Date.now(),
10
+ ...entry,
11
+ } as TranscriptEntry
12
+ }
13
+
14
+ async function waitFor(condition: () => boolean, timeoutMs = 2000) {
15
+ const start = Date.now()
16
+ while (!condition()) {
17
+ if (Date.now() - start > timeoutMs) {
18
+ throw new Error("Timed out waiting for condition")
19
+ }
20
+ await new Promise((resolve) => setTimeout(resolve, 10))
21
+ }
22
+ }
23
+
24
+ describe("normalizeClaudeStreamMessage", () => {
25
+ test("normalizes assistant tool calls", () => {
26
+ const entries = normalizeClaudeStreamMessage({
27
+ type: "assistant",
28
+ uuid: "msg-1",
29
+ message: {
30
+ content: [
31
+ {
32
+ type: "tool_use",
33
+ id: "tool-1",
34
+ name: "Bash",
35
+ input: {
36
+ command: "pwd",
37
+ timeout: 1000,
38
+ },
39
+ },
40
+ ],
41
+ },
42
+ })
43
+
44
+ expect(entries).toHaveLength(1)
45
+ expect(entries[0]?.kind).toBe("tool_call")
46
+ if (entries[0]?.kind !== "tool_call") throw new Error("unexpected entry")
47
+ expect(entries[0].tool.toolKind).toBe("bash")
48
+ })
49
+
50
+ test("normalizes result messages", () => {
51
+ const entries = normalizeClaudeStreamMessage({
52
+ type: "result",
53
+ subtype: "success",
54
+ is_error: false,
55
+ duration_ms: 3210,
56
+ result: "done",
57
+ })
58
+
59
+ expect(entries).toHaveLength(1)
60
+ expect(entries[0]?.kind).toBe("result")
61
+ if (entries[0]?.kind !== "result") throw new Error("unexpected entry")
62
+ expect(entries[0].durationMs).toBe(3210)
63
+ })
64
+ })
65
+
66
+ describe("AgentCoordinator codex integration", () => {
67
+ test("generates a chat title in the background on the first user message", async () => {
68
+ const fakeCodexManager = {
69
+ async startSession() {},
70
+ async startTurn(): Promise<HarnessTurn> {
71
+ async function* stream() {
72
+ yield {
73
+ type: "transcript" as const,
74
+ entry: timestamped({
75
+ kind: "system_init",
76
+ provider: "codex",
77
+ model: "gpt-5.4",
78
+ tools: [],
79
+ agents: [],
80
+ slashCommands: [],
81
+ mcpServers: [],
82
+ }),
83
+ }
84
+ yield {
85
+ type: "transcript" as const,
86
+ entry: timestamped({
87
+ kind: "result",
88
+ subtype: "success",
89
+ isError: false,
90
+ durationMs: 0,
91
+ result: "",
92
+ }),
93
+ }
94
+ }
95
+
96
+ return {
97
+ provider: "codex",
98
+ stream: stream(),
99
+ interrupt: async () => {},
100
+ close: () => {},
101
+ }
102
+ },
103
+ }
104
+
105
+ const store = createFakeStore()
106
+ const coordinator = new AgentCoordinator({
107
+ store: store as never,
108
+ onStateChange: () => {},
109
+ codexManager: fakeCodexManager as never,
110
+ generateTitle: async () => "Generated title",
111
+ })
112
+
113
+ await coordinator.send({
114
+ type: "chat.send",
115
+ chatId: "chat-1",
116
+ provider: "codex",
117
+ content: "first message",
118
+ model: "gpt-5.4",
119
+ })
120
+
121
+ await waitFor(() => store.chat.title === "Generated title")
122
+ expect(store.messages[0]?.kind).toBe("user_prompt")
123
+ })
124
+
125
+ test("does not overwrite a manual rename when background title generation finishes later", async () => {
126
+ let releaseTitle!: () => void
127
+ const titleGate = new Promise<void>((resolve) => {
128
+ releaseTitle = resolve
129
+ })
130
+ const fakeCodexManager = {
131
+ async startSession() {},
132
+ async startTurn(): Promise<HarnessTurn> {
133
+ async function* stream() {
134
+ yield {
135
+ type: "transcript" as const,
136
+ entry: timestamped({
137
+ kind: "system_init",
138
+ provider: "codex",
139
+ model: "gpt-5.4",
140
+ tools: [],
141
+ agents: [],
142
+ slashCommands: [],
143
+ mcpServers: [],
144
+ }),
145
+ }
146
+ yield {
147
+ type: "transcript" as const,
148
+ entry: timestamped({
149
+ kind: "result",
150
+ subtype: "success",
151
+ isError: false,
152
+ durationMs: 0,
153
+ result: "",
154
+ }),
155
+ }
156
+ }
157
+
158
+ return {
159
+ provider: "codex",
160
+ stream: stream(),
161
+ interrupt: async () => {},
162
+ close: () => {},
163
+ }
164
+ },
165
+ }
166
+
167
+ const store = createFakeStore()
168
+ const coordinator = new AgentCoordinator({
169
+ store: store as never,
170
+ onStateChange: () => {},
171
+ codexManager: fakeCodexManager as never,
172
+ generateTitle: async () => {
173
+ await titleGate
174
+ return "Generated title"
175
+ },
176
+ })
177
+
178
+ await coordinator.send({
179
+ type: "chat.send",
180
+ chatId: "chat-1",
181
+ provider: "codex",
182
+ content: "first message",
183
+ model: "gpt-5.4",
184
+ })
185
+
186
+ await store.renameChat("chat-1", "Manual title")
187
+ releaseTitle()
188
+ await waitFor(() => store.turnFinishedCount === 1)
189
+
190
+ expect(store.chat.title).toBe("Manual title")
191
+ })
192
+
193
+ test("binds codex provider and reuses the session token on later turns", async () => {
194
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
195
+ const fakeCodexManager = {
196
+ async startSession(args: { chatId: string; sessionToken: string | null }) {
197
+ sessionCalls.push({ chatId: args.chatId, sessionToken: args.sessionToken })
198
+ },
199
+ async startTurn(): Promise<HarnessTurn> {
200
+ async function* stream() {
201
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
202
+ yield {
203
+ type: "transcript" as const,
204
+ entry: timestamped({
205
+ kind: "system_init",
206
+ provider: "codex",
207
+ model: "gpt-5.4",
208
+ tools: [],
209
+ agents: [],
210
+ slashCommands: [],
211
+ mcpServers: [],
212
+ }),
213
+ }
214
+ yield {
215
+ type: "transcript" as const,
216
+ entry: timestamped({
217
+ kind: "result",
218
+ subtype: "success",
219
+ isError: false,
220
+ durationMs: 0,
221
+ result: "",
222
+ }),
223
+ }
224
+ }
225
+
226
+ return {
227
+ provider: "codex",
228
+ stream: stream(),
229
+ interrupt: async () => {},
230
+ close: () => {},
231
+ }
232
+ },
233
+ }
234
+
235
+ const store = createFakeStore()
236
+ const coordinator = new AgentCoordinator({
237
+ store: store as never,
238
+ onStateChange: () => {},
239
+ codexManager: fakeCodexManager as never,
240
+ })
241
+
242
+ await coordinator.send({
243
+ type: "chat.send",
244
+ chatId: "chat-1",
245
+ provider: "codex",
246
+ content: "first",
247
+ })
248
+
249
+ await waitFor(() => store.turnFinishedCount === 1)
250
+ expect(store.chat.provider).toBe("codex")
251
+ expect(store.chat.sessionToken).toBe("thread-1")
252
+ expect(sessionCalls).toEqual([{ chatId: "chat-1", sessionToken: null }])
253
+
254
+ await coordinator.send({
255
+ type: "chat.send",
256
+ chatId: "chat-1",
257
+ content: "second",
258
+ })
259
+
260
+ await waitFor(() => store.turnFinishedCount === 2)
261
+ expect(sessionCalls).toEqual([
262
+ { chatId: "chat-1", sessionToken: null },
263
+ { chatId: "chat-1", sessionToken: "thread-1" },
264
+ ])
265
+ })
266
+
267
+ test("maps codex model options into session and turn settings", async () => {
268
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null; serviceTier?: string }> = []
269
+ const turnCalls: Array<{ effort?: string; serviceTier?: string }> = []
270
+
271
+ const fakeCodexManager = {
272
+ async startSession(args: { chatId: string; sessionToken: string | null; serviceTier?: string }) {
273
+ sessionCalls.push({
274
+ chatId: args.chatId,
275
+ sessionToken: args.sessionToken,
276
+ serviceTier: args.serviceTier,
277
+ })
278
+ },
279
+ async startTurn(args: { effort?: string; serviceTier?: string }): Promise<HarnessTurn> {
280
+ turnCalls.push({
281
+ effort: args.effort,
282
+ serviceTier: args.serviceTier,
283
+ })
284
+
285
+ async function* stream() {
286
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
287
+ yield {
288
+ type: "transcript" as const,
289
+ entry: timestamped({
290
+ kind: "system_init",
291
+ provider: "codex",
292
+ model: "gpt-5.4",
293
+ tools: [],
294
+ agents: [],
295
+ slashCommands: [],
296
+ mcpServers: [],
297
+ }),
298
+ }
299
+ yield {
300
+ type: "transcript" as const,
301
+ entry: timestamped({
302
+ kind: "result",
303
+ subtype: "success",
304
+ isError: false,
305
+ durationMs: 0,
306
+ result: "",
307
+ }),
308
+ }
309
+ }
310
+
311
+ return {
312
+ provider: "codex",
313
+ stream: stream(),
314
+ interrupt: async () => {},
315
+ close: () => {},
316
+ }
317
+ },
318
+ }
319
+
320
+ const store = createFakeStore()
321
+ const coordinator = new AgentCoordinator({
322
+ store: store as never,
323
+ onStateChange: () => {},
324
+ codexManager: fakeCodexManager as never,
325
+ })
326
+
327
+ await coordinator.send({
328
+ type: "chat.send",
329
+ chatId: "chat-1",
330
+ provider: "codex",
331
+ content: "opt in",
332
+ modelOptions: {
333
+ codex: {
334
+ reasoningEffort: "xhigh",
335
+ fastMode: true,
336
+ },
337
+ },
338
+ })
339
+
340
+ await waitFor(() => store.turnFinishedCount === 1)
341
+
342
+ expect(sessionCalls).toEqual([{ chatId: "chat-1", sessionToken: null, serviceTier: "fast" }])
343
+ expect(turnCalls).toEqual([{ effort: "xhigh", serviceTier: "fast" }])
344
+ })
345
+
346
+ test("approving synthetic codex ExitPlanMode starts a hidden follow-up turn and can clear context", async () => {
347
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
348
+ const startTurnCalls: Array<{ content: string; planMode: boolean }> = []
349
+ let turnCount = 0
350
+
351
+ const fakeCodexManager = {
352
+ async startSession(args: { chatId: string; sessionToken: string | null }) {
353
+ sessionCalls.push({ chatId: args.chatId, sessionToken: args.sessionToken })
354
+ },
355
+ async startTurn(args: {
356
+ content: string
357
+ planMode: boolean
358
+ onToolRequest: (request: any) => Promise<unknown>
359
+ }): Promise<HarnessTurn> {
360
+ startTurnCalls.push({ content: args.content, planMode: args.planMode })
361
+ turnCount += 1
362
+
363
+ async function* firstStream() {
364
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
365
+ yield {
366
+ type: "transcript" as const,
367
+ entry: timestamped({
368
+ kind: "system_init",
369
+ provider: "codex",
370
+ model: "gpt-5.4",
371
+ tools: [],
372
+ agents: [],
373
+ slashCommands: [],
374
+ mcpServers: [],
375
+ }),
376
+ }
377
+ yield {
378
+ type: "transcript" as const,
379
+ entry: timestamped({
380
+ kind: "tool_call",
381
+ tool: {
382
+ kind: "tool",
383
+ toolKind: "exit_plan_mode",
384
+ toolName: "ExitPlanMode",
385
+ toolId: "exit-1",
386
+ input: {
387
+ plan: "## Plan\n\n- [ ] Ship it",
388
+ summary: "Plan summary",
389
+ },
390
+ },
391
+ }),
392
+ }
393
+ await args.onToolRequest({
394
+ tool: {
395
+ kind: "tool",
396
+ toolKind: "exit_plan_mode",
397
+ toolName: "ExitPlanMode",
398
+ toolId: "exit-1",
399
+ input: {
400
+ plan: "## Plan\n\n- [ ] Ship it",
401
+ summary: "Plan summary",
402
+ },
403
+ },
404
+ })
405
+ }
406
+
407
+ async function* secondStream() {
408
+ yield { type: "session_token" as const, sessionToken: "thread-2" }
409
+ yield {
410
+ type: "transcript" as const,
411
+ entry: timestamped({
412
+ kind: "system_init",
413
+ provider: "codex",
414
+ model: "gpt-5.4",
415
+ tools: [],
416
+ agents: [],
417
+ slashCommands: [],
418
+ mcpServers: [],
419
+ }),
420
+ }
421
+ yield {
422
+ type: "transcript" as const,
423
+ entry: timestamped({
424
+ kind: "result",
425
+ subtype: "success",
426
+ isError: false,
427
+ durationMs: 0,
428
+ result: "",
429
+ }),
430
+ }
431
+ }
432
+
433
+ return {
434
+ provider: "codex",
435
+ stream: turnCount === 1 ? firstStream() : secondStream(),
436
+ interrupt: async () => {},
437
+ close: () => {},
438
+ }
439
+ },
440
+ }
441
+
442
+ const store = createFakeStore()
443
+ const coordinator = new AgentCoordinator({
444
+ store: store as never,
445
+ onStateChange: () => {},
446
+ codexManager: fakeCodexManager as never,
447
+ })
448
+
449
+ await coordinator.send({
450
+ type: "chat.send",
451
+ chatId: "chat-1",
452
+ provider: "codex",
453
+ content: "plan this",
454
+ planMode: true,
455
+ })
456
+
457
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "exit_plan_mode")
458
+
459
+ await coordinator.respondTool({
460
+ type: "chat.respondTool",
461
+ chatId: "chat-1",
462
+ toolUseId: "exit-1",
463
+ result: {
464
+ confirmed: true,
465
+ clearContext: true,
466
+ message: "Use the fast path",
467
+ },
468
+ })
469
+
470
+ await waitFor(() => store.turnFinishedCount === 1)
471
+
472
+ expect(startTurnCalls).toEqual([
473
+ { content: "plan this", planMode: true },
474
+ { content: "Proceed with the approved plan. Additional guidance: Use the fast path", planMode: false },
475
+ ])
476
+ expect(sessionCalls).toEqual([
477
+ { chatId: "chat-1", sessionToken: null },
478
+ { chatId: "chat-1", sessionToken: null },
479
+ ])
480
+ expect(store.messages.filter((entry) => entry.kind === "user_prompt")).toHaveLength(1)
481
+ expect(store.messages.some((entry) => entry.kind === "context_cleared")).toBe(true)
482
+ expect(store.chat.sessionToken).toBe("thread-2")
483
+ })
484
+ })
485
+
486
+ function createFakeStore() {
487
+ const chat = {
488
+ id: "chat-1",
489
+ projectId: "project-1",
490
+ title: "New Chat",
491
+ provider: null as "claude" | "codex" | null,
492
+ planMode: false,
493
+ sessionToken: null as string | null,
494
+ }
495
+ const project = {
496
+ id: "project-1",
497
+ localPath: "/tmp/project",
498
+ }
499
+ return {
500
+ chat,
501
+ turnFinishedCount: 0,
502
+ messages: [] as TranscriptEntry[],
503
+ requireChat(chatId: string) {
504
+ expect(chatId).toBe("chat-1")
505
+ return chat
506
+ },
507
+ getProject(projectId: string) {
508
+ expect(projectId).toBe("project-1")
509
+ return project
510
+ },
511
+ getMessages() {
512
+ return this.messages
513
+ },
514
+ async setChatProvider(_chatId: string, provider: "claude" | "codex") {
515
+ chat.provider = provider
516
+ },
517
+ async setPlanMode(_chatId: string, planMode: boolean) {
518
+ chat.planMode = planMode
519
+ },
520
+ async renameChat(_chatId: string, title: string) {
521
+ chat.title = title
522
+ },
523
+ async appendMessage(_chatId: string, entry: TranscriptEntry) {
524
+ this.messages.push(entry)
525
+ },
526
+ async recordTurnStarted() {},
527
+ async recordTurnFinished() {
528
+ this.turnFinishedCount += 1
529
+ },
530
+ async recordTurnFailed() {
531
+ throw new Error("Did not expect turn failure")
532
+ },
533
+ async recordTurnCancelled() {},
534
+ async setSessionToken(_chatId: string, sessionToken: string | null) {
535
+ chat.sessionToken = sessionToken
536
+ },
537
+ async createChat() {
538
+ return chat
539
+ },
540
+ }
541
+ }