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/agent.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { query, type CanUseTool, type PermissionResult, type Query } from "@anthropic-ai/claude-agent-sdk"
|
|
1
|
+
import { query, type CanUseTool, type PermissionResult, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"
|
|
2
2
|
import type {
|
|
3
3
|
AgentProvider,
|
|
4
4
|
ChatAttachment,
|
|
@@ -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",
|
|
@@ -63,11 +64,41 @@ interface ActiveTurn {
|
|
|
63
64
|
cancelRecorded: boolean
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
interface ClaudeSessionHandle {
|
|
68
|
+
provider: "claude"
|
|
69
|
+
stream: AsyncIterable<HarnessEvent>
|
|
70
|
+
getAccountInfo?: () => Promise<any>
|
|
71
|
+
interrupt: () => Promise<void>
|
|
72
|
+
close: () => void
|
|
73
|
+
sendPrompt: (content: string) => Promise<void>
|
|
74
|
+
setModel: (model: string) => Promise<void>
|
|
75
|
+
setPermissionMode: (planMode: boolean) => Promise<void>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ClaudeSessionState {
|
|
79
|
+
chatId: string
|
|
80
|
+
session: ClaudeSessionHandle
|
|
81
|
+
localPath: string
|
|
82
|
+
model: string
|
|
83
|
+
effort?: string
|
|
84
|
+
planMode: boolean
|
|
85
|
+
sessionToken: string | null
|
|
86
|
+
accountInfoLoaded: boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
interface AgentCoordinatorArgs {
|
|
67
90
|
store: EventStore
|
|
68
91
|
onStateChange: () => void
|
|
69
92
|
codexManager?: CodexAppServerManager
|
|
70
|
-
generateTitle?: (messageContent: string, cwd: string) => Promise<
|
|
93
|
+
generateTitle?: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
94
|
+
startClaudeSession?: (args: {
|
|
95
|
+
localPath: string
|
|
96
|
+
model: string
|
|
97
|
+
effort?: string
|
|
98
|
+
planMode: boolean
|
|
99
|
+
sessionToken: string | null
|
|
100
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
101
|
+
}) => Promise<ClaudeSessionHandle>
|
|
71
102
|
}
|
|
72
103
|
|
|
73
104
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
@@ -268,15 +299,61 @@ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent
|
|
|
268
299
|
}
|
|
269
300
|
}
|
|
270
301
|
|
|
271
|
-
|
|
272
|
-
|
|
302
|
+
class AsyncMessageQueue<T> implements AsyncIterable<T> {
|
|
303
|
+
private readonly values: T[] = []
|
|
304
|
+
private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
|
|
305
|
+
private closed = false
|
|
306
|
+
|
|
307
|
+
push(value: T) {
|
|
308
|
+
if (this.closed) {
|
|
309
|
+
throw new Error("Cannot push to a closed queue")
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const waiter = this.waiters.shift()
|
|
313
|
+
if (waiter) {
|
|
314
|
+
waiter({ done: false, value })
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
this.values.push(value)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
close() {
|
|
322
|
+
if (this.closed) return
|
|
323
|
+
this.closed = true
|
|
324
|
+
while (this.waiters.length > 0) {
|
|
325
|
+
const waiter = this.waiters.shift()
|
|
326
|
+
waiter?.({ done: true, value: undefined as never })
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
331
|
+
return {
|
|
332
|
+
next: async () => {
|
|
333
|
+
if (this.values.length > 0) {
|
|
334
|
+
return { done: false, value: this.values.shift() as T }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (this.closed) {
|
|
338
|
+
return { done: true, value: undefined as never }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return await new Promise<IteratorResult<T>>((resolve) => {
|
|
342
|
+
this.waiters.push(resolve)
|
|
343
|
+
})
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function startClaudeSession(args: {
|
|
273
350
|
localPath: string
|
|
274
351
|
model: string
|
|
275
352
|
effort?: string
|
|
276
353
|
planMode: boolean
|
|
277
354
|
sessionToken: string | null
|
|
278
355
|
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
279
|
-
}): Promise<
|
|
356
|
+
}): Promise<ClaudeSessionHandle> {
|
|
280
357
|
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
|
281
358
|
if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
|
|
282
359
|
return {
|
|
@@ -332,8 +409,10 @@ async function startClaudeTurn(args: {
|
|
|
332
409
|
} satisfies PermissionResult
|
|
333
410
|
}
|
|
334
411
|
|
|
412
|
+
const promptQueue = new AsyncMessageQueue<SDKUserMessage>()
|
|
413
|
+
|
|
335
414
|
const q = query({
|
|
336
|
-
prompt:
|
|
415
|
+
prompt: promptQueue,
|
|
337
416
|
options: {
|
|
338
417
|
cwd: args.localPath,
|
|
339
418
|
model: args.model,
|
|
@@ -360,7 +439,25 @@ async function startClaudeTurn(args: {
|
|
|
360
439
|
interrupt: async () => {
|
|
361
440
|
await q.interrupt()
|
|
362
441
|
},
|
|
442
|
+
sendPrompt: async (content: string) => {
|
|
443
|
+
promptQueue.push({
|
|
444
|
+
type: "user",
|
|
445
|
+
message: {
|
|
446
|
+
role: "user",
|
|
447
|
+
content,
|
|
448
|
+
},
|
|
449
|
+
parent_tool_use_id: null,
|
|
450
|
+
session_id: args.sessionToken ?? "",
|
|
451
|
+
})
|
|
452
|
+
},
|
|
453
|
+
setModel: async (model: string) => {
|
|
454
|
+
await q.setModel(model)
|
|
455
|
+
},
|
|
456
|
+
setPermissionMode: async (planMode: boolean) => {
|
|
457
|
+
await q.setPermissionMode(planMode ? "plan" : "acceptEdits")
|
|
458
|
+
},
|
|
363
459
|
close: () => {
|
|
460
|
+
promptQueue.close()
|
|
364
461
|
q.close()
|
|
365
462
|
},
|
|
366
463
|
}
|
|
@@ -370,14 +467,23 @@ export class AgentCoordinator {
|
|
|
370
467
|
private readonly store: EventStore
|
|
371
468
|
private readonly onStateChange: () => void
|
|
372
469
|
private readonly codexManager: CodexAppServerManager
|
|
373
|
-
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<
|
|
470
|
+
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
471
|
+
private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
|
|
472
|
+
private reportBackgroundError: ((message: string) => void) | null = null
|
|
374
473
|
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
474
|
+
readonly drainingStreams = new Map<string, { turn: HarnessTurn }>()
|
|
475
|
+
readonly claudeSessions = new Map<string, ClaudeSessionState>()
|
|
375
476
|
|
|
376
477
|
constructor(args: AgentCoordinatorArgs) {
|
|
377
478
|
this.store = args.store
|
|
378
479
|
this.onStateChange = args.onStateChange
|
|
379
480
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
380
|
-
this.generateTitle = args.generateTitle ??
|
|
481
|
+
this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
|
|
482
|
+
this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
setBackgroundErrorReporter(report: ((message: string) => void) | null) {
|
|
486
|
+
this.reportBackgroundError = report
|
|
381
487
|
}
|
|
382
488
|
|
|
383
489
|
getActiveStatuses() {
|
|
@@ -394,6 +500,28 @@ export class AgentCoordinator {
|
|
|
394
500
|
return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
|
|
395
501
|
}
|
|
396
502
|
|
|
503
|
+
getDrainingChatIds(): Set<string> {
|
|
504
|
+
return new Set(this.drainingStreams.keys())
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async stopDraining(chatId: string) {
|
|
508
|
+
const draining = this.drainingStreams.get(chatId)
|
|
509
|
+
if (!draining) return
|
|
510
|
+
draining.turn.close()
|
|
511
|
+
this.drainingStreams.delete(chatId)
|
|
512
|
+
this.onStateChange()
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async closeChat(chatId: string) {
|
|
516
|
+
await this.stopDraining(chatId)
|
|
517
|
+
const claudeSession = this.claudeSessions.get(chatId)
|
|
518
|
+
if (claudeSession) {
|
|
519
|
+
claudeSession.session.close()
|
|
520
|
+
this.claudeSessions.delete(chatId)
|
|
521
|
+
}
|
|
522
|
+
this.onStateChange()
|
|
523
|
+
}
|
|
524
|
+
|
|
397
525
|
private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
|
|
398
526
|
if (currentProvider) return currentProvider
|
|
399
527
|
return command.provider ?? "claude"
|
|
@@ -432,6 +560,13 @@ export class AgentCoordinator {
|
|
|
432
560
|
planMode: boolean
|
|
433
561
|
appendUserPrompt: boolean
|
|
434
562
|
}) {
|
|
563
|
+
// Close any lingering draining stream before starting a new turn.
|
|
564
|
+
const draining = this.drainingStreams.get(args.chatId)
|
|
565
|
+
if (draining) {
|
|
566
|
+
draining.turn.close()
|
|
567
|
+
this.drainingStreams.delete(args.chatId)
|
|
568
|
+
}
|
|
569
|
+
|
|
435
570
|
const chat = this.store.requireChat(args.chatId)
|
|
436
571
|
if (this.activeTurns.has(args.chatId)) {
|
|
437
572
|
throw new Error("Chat is already running")
|
|
@@ -444,6 +579,11 @@ export class AgentCoordinator {
|
|
|
444
579
|
|
|
445
580
|
const existingMessages = this.store.getMessages(args.chatId)
|
|
446
581
|
const shouldGenerateTitle = args.appendUserPrompt && chat.title === "New Chat" && existingMessages.length === 0
|
|
582
|
+
const optimisticTitle = shouldGenerateTitle ? fallbackTitleFromMessage(args.content) : null
|
|
583
|
+
|
|
584
|
+
if (optimisticTitle) {
|
|
585
|
+
await this.store.renameChat(args.chatId, optimisticTitle)
|
|
586
|
+
}
|
|
447
587
|
|
|
448
588
|
if (args.appendUserPrompt) {
|
|
449
589
|
await this.store.appendMessage(
|
|
@@ -459,7 +599,7 @@ export class AgentCoordinator {
|
|
|
459
599
|
}
|
|
460
600
|
|
|
461
601
|
if (shouldGenerateTitle) {
|
|
462
|
-
void this.generateTitleInBackground(args.chatId, args.content, project.localPath)
|
|
602
|
+
void this.generateTitleInBackground(args.chatId, args.content, project.localPath, optimisticTitle ?? "New Chat")
|
|
463
603
|
}
|
|
464
604
|
|
|
465
605
|
const onToolRequest = async (request: HarnessToolRequest): Promise<unknown> => {
|
|
@@ -482,8 +622,8 @@ export class AgentCoordinator {
|
|
|
482
622
|
|
|
483
623
|
let turn: HarnessTurn
|
|
484
624
|
if (args.provider === "claude") {
|
|
485
|
-
turn = await startClaudeTurn({
|
|
486
|
-
|
|
625
|
+
turn = await this.startClaudeTurn({
|
|
626
|
+
chatId: args.chatId,
|
|
487
627
|
localPath: project.localPath,
|
|
488
628
|
model: args.model,
|
|
489
629
|
effort: args.effort,
|
|
@@ -518,7 +658,7 @@ export class AgentCoordinator {
|
|
|
518
658
|
effort: args.effort,
|
|
519
659
|
serviceTier: args.serviceTier,
|
|
520
660
|
planMode: args.planMode,
|
|
521
|
-
status: "starting",
|
|
661
|
+
status: args.provider === "claude" ? "running" : "starting",
|
|
522
662
|
pendingTool: null,
|
|
523
663
|
postToolFollowUp: null,
|
|
524
664
|
hasFinalResult: false,
|
|
@@ -532,15 +672,93 @@ export class AgentCoordinator {
|
|
|
532
672
|
void turn.getAccountInfo()
|
|
533
673
|
.then(async (accountInfo) => {
|
|
534
674
|
if (!accountInfo) return
|
|
675
|
+
if (args.provider === "claude") {
|
|
676
|
+
const session = this.claudeSessions.get(args.chatId)
|
|
677
|
+
if (session) {
|
|
678
|
+
if (session.accountInfoLoaded) return
|
|
679
|
+
session.accountInfoLoaded = true
|
|
680
|
+
} else {
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
}
|
|
535
684
|
await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
|
|
536
685
|
this.onStateChange()
|
|
537
686
|
})
|
|
538
687
|
.catch(() => undefined)
|
|
539
688
|
}
|
|
540
689
|
|
|
690
|
+
if (args.provider === "claude") {
|
|
691
|
+
const session = this.claudeSessions.get(args.chatId)
|
|
692
|
+
if (!session) {
|
|
693
|
+
throw new Error("Claude session was not initialized")
|
|
694
|
+
}
|
|
695
|
+
await session.session.sendPrompt(buildPromptText(args.content, args.attachments))
|
|
696
|
+
return
|
|
697
|
+
}
|
|
698
|
+
|
|
541
699
|
void this.runTurn(active)
|
|
542
700
|
}
|
|
543
701
|
|
|
702
|
+
private async startClaudeTurn(args: {
|
|
703
|
+
chatId: string
|
|
704
|
+
localPath: string
|
|
705
|
+
model: string
|
|
706
|
+
effort?: string
|
|
707
|
+
planMode: boolean
|
|
708
|
+
sessionToken: string | null
|
|
709
|
+
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
710
|
+
}): Promise<HarnessTurn> {
|
|
711
|
+
let session = this.claudeSessions.get(args.chatId)
|
|
712
|
+
|
|
713
|
+
if (!session || session.localPath !== args.localPath || session.effort !== args.effort) {
|
|
714
|
+
if (session) {
|
|
715
|
+
session.session.close()
|
|
716
|
+
this.claudeSessions.delete(args.chatId)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const started = await this.startClaudeSessionFn({
|
|
720
|
+
localPath: args.localPath,
|
|
721
|
+
model: args.model,
|
|
722
|
+
effort: args.effort,
|
|
723
|
+
planMode: args.planMode,
|
|
724
|
+
sessionToken: args.sessionToken,
|
|
725
|
+
onToolRequest: args.onToolRequest,
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
session = {
|
|
729
|
+
chatId: args.chatId,
|
|
730
|
+
session: started,
|
|
731
|
+
localPath: args.localPath,
|
|
732
|
+
model: args.model,
|
|
733
|
+
effort: args.effort,
|
|
734
|
+
planMode: args.planMode,
|
|
735
|
+
sessionToken: args.sessionToken,
|
|
736
|
+
accountInfoLoaded: false,
|
|
737
|
+
}
|
|
738
|
+
this.claudeSessions.set(args.chatId, session)
|
|
739
|
+
void this.runClaudeSession(session)
|
|
740
|
+
} else {
|
|
741
|
+
if (session.model !== args.model) {
|
|
742
|
+
await session.session.setModel(args.model)
|
|
743
|
+
session.model = args.model
|
|
744
|
+
}
|
|
745
|
+
if (session.planMode !== args.planMode) {
|
|
746
|
+
await session.session.setPermissionMode(args.planMode)
|
|
747
|
+
session.planMode = args.planMode
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
provider: "claude",
|
|
753
|
+
stream: {
|
|
754
|
+
async *[Symbol.asyncIterator]() {},
|
|
755
|
+
},
|
|
756
|
+
getAccountInfo: session.session.getAccountInfo,
|
|
757
|
+
interrupt: session.session.interrupt,
|
|
758
|
+
close: () => {},
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
544
762
|
async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
|
|
545
763
|
let chatId = command.chatId
|
|
546
764
|
|
|
@@ -570,24 +788,96 @@ export class AgentCoordinator {
|
|
|
570
788
|
return { chatId }
|
|
571
789
|
}
|
|
572
790
|
|
|
573
|
-
private async
|
|
791
|
+
private async runClaudeSession(session: ClaudeSessionState) {
|
|
792
|
+
try {
|
|
793
|
+
for await (const event of session.session.stream) {
|
|
794
|
+
if (event.type === "session_token" && event.sessionToken) {
|
|
795
|
+
session.sessionToken = event.sessionToken
|
|
796
|
+
await this.store.setSessionToken(session.chatId, event.sessionToken)
|
|
797
|
+
this.onStateChange()
|
|
798
|
+
continue
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (!event.entry) continue
|
|
802
|
+
await this.store.appendMessage(session.chatId, event.entry)
|
|
803
|
+
|
|
804
|
+
const active = this.activeTurns.get(session.chatId)
|
|
805
|
+
if (event.entry.kind === "system_init" && active) {
|
|
806
|
+
active.status = "running"
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (event.entry.kind === "result" && active) {
|
|
810
|
+
active.hasFinalResult = true
|
|
811
|
+
if (event.entry.isError) {
|
|
812
|
+
await this.store.recordTurnFailed(session.chatId, event.entry.result || "Turn failed")
|
|
813
|
+
} else if (!active.cancelRequested) {
|
|
814
|
+
await this.store.recordTurnFinished(session.chatId)
|
|
815
|
+
}
|
|
816
|
+
this.activeTurns.delete(session.chatId)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
this.onStateChange()
|
|
820
|
+
}
|
|
821
|
+
} catch (error) {
|
|
822
|
+
const active = this.activeTurns.get(session.chatId)
|
|
823
|
+
if (active && !active.cancelRequested) {
|
|
824
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
825
|
+
await this.store.appendMessage(
|
|
826
|
+
session.chatId,
|
|
827
|
+
timestamped({
|
|
828
|
+
kind: "result",
|
|
829
|
+
subtype: "error",
|
|
830
|
+
isError: true,
|
|
831
|
+
durationMs: 0,
|
|
832
|
+
result: message,
|
|
833
|
+
})
|
|
834
|
+
)
|
|
835
|
+
await this.store.recordTurnFailed(session.chatId, message)
|
|
836
|
+
}
|
|
837
|
+
} finally {
|
|
838
|
+
this.claudeSessions.delete(session.chatId)
|
|
839
|
+
const active = this.activeTurns.get(session.chatId)
|
|
840
|
+
if (active?.provider === "claude") {
|
|
841
|
+
if (active.cancelRequested && !active.cancelRecorded) {
|
|
842
|
+
await this.store.recordTurnCancelled(session.chatId)
|
|
843
|
+
}
|
|
844
|
+
this.activeTurns.delete(session.chatId)
|
|
845
|
+
}
|
|
846
|
+
session.session.close()
|
|
847
|
+
this.onStateChange()
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
|
|
574
852
|
try {
|
|
575
|
-
const
|
|
576
|
-
if (
|
|
853
|
+
const result = await this.generateTitle(messageContent, cwd)
|
|
854
|
+
if (result.failureMessage) {
|
|
855
|
+
this.reportBackgroundError?.(
|
|
856
|
+
`[title-generation] chat ${chatId} failed provider title generation: ${result.failureMessage}`
|
|
857
|
+
)
|
|
858
|
+
}
|
|
859
|
+
if (!result.title || result.usedFallback) return
|
|
577
860
|
|
|
578
861
|
const chat = this.store.requireChat(chatId)
|
|
579
|
-
if (chat.title !==
|
|
862
|
+
if (chat.title !== expectedCurrentTitle) return
|
|
580
863
|
|
|
581
|
-
await this.store.renameChat(chatId, title)
|
|
864
|
+
await this.store.renameChat(chatId, result.title)
|
|
582
865
|
this.onStateChange()
|
|
583
|
-
} catch {
|
|
584
|
-
|
|
866
|
+
} catch (error) {
|
|
867
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
868
|
+
this.reportBackgroundError?.(
|
|
869
|
+
`[title-generation] chat ${chatId} failed background title generation: ${message}`
|
|
870
|
+
)
|
|
585
871
|
}
|
|
586
872
|
}
|
|
587
873
|
|
|
588
874
|
private async runTurn(active: ActiveTurn) {
|
|
589
875
|
try {
|
|
590
876
|
for await (const event of active.turn.stream) {
|
|
877
|
+
// Once cancelled, stop processing further stream events.
|
|
878
|
+
// cancel() already removed us from activeTurns and notified the UI.
|
|
879
|
+
if (active.cancelRequested) break
|
|
880
|
+
|
|
591
881
|
if (event.type === "session_token" && event.sessionToken) {
|
|
592
882
|
await this.store.setSessionToken(active.chatId, event.sessionToken)
|
|
593
883
|
this.onStateChange()
|
|
@@ -608,6 +898,14 @@ export class AgentCoordinator {
|
|
|
608
898
|
} else if (!active.cancelRequested) {
|
|
609
899
|
await this.store.recordTurnFinished(active.chatId)
|
|
610
900
|
}
|
|
901
|
+
// Remove from activeTurns as soon as the result arrives so the UI
|
|
902
|
+
// transitions to idle immediately. The stream may still be open
|
|
903
|
+
// (e.g. background tasks), but the user should be able to send
|
|
904
|
+
// new messages without having to hit stop first.
|
|
905
|
+
this.activeTurns.delete(active.chatId)
|
|
906
|
+
// Track the still-open stream so the UI can show a draining
|
|
907
|
+
// indicator and the user can stop background tasks.
|
|
908
|
+
this.drainingStreams.set(active.chatId, { turn: active.turn })
|
|
611
909
|
}
|
|
612
910
|
|
|
613
911
|
this.onStateChange()
|
|
@@ -632,7 +930,14 @@ export class AgentCoordinator {
|
|
|
632
930
|
await this.store.recordTurnCancelled(active.chatId)
|
|
633
931
|
}
|
|
634
932
|
active.turn.close()
|
|
635
|
-
this.
|
|
933
|
+
// Only remove if we're still the active turn for this chat.
|
|
934
|
+
// We may have already been removed by result handling or cancel(),
|
|
935
|
+
// and a new turn may have started for the same chatId.
|
|
936
|
+
if (this.activeTurns.get(active.chatId) === active) {
|
|
937
|
+
this.activeTurns.delete(active.chatId)
|
|
938
|
+
}
|
|
939
|
+
// Stream has fully ended — no longer draining.
|
|
940
|
+
this.drainingStreams.delete(active.chatId)
|
|
636
941
|
this.onStateChange()
|
|
637
942
|
|
|
638
943
|
if (active.postToolFollowUp && !active.cancelRequested) {
|
|
@@ -668,9 +973,18 @@ export class AgentCoordinator {
|
|
|
668
973
|
}
|
|
669
974
|
|
|
670
975
|
async cancel(chatId: string) {
|
|
976
|
+
// Also clean up any draining stream for this chat.
|
|
977
|
+
const draining = this.drainingStreams.get(chatId)
|
|
978
|
+
if (draining) {
|
|
979
|
+
draining.turn.close()
|
|
980
|
+
this.drainingStreams.delete(chatId)
|
|
981
|
+
}
|
|
982
|
+
|
|
671
983
|
const active = this.activeTurns.get(chatId)
|
|
672
984
|
if (!active) return
|
|
673
985
|
|
|
986
|
+
// Guard against concurrent cancel() calls — only the first one does work.
|
|
987
|
+
if (active.cancelRequested) return
|
|
674
988
|
active.cancelRequested = true
|
|
675
989
|
|
|
676
990
|
const pendingTool = active.pendingTool
|
|
@@ -696,14 +1010,23 @@ export class AgentCoordinator {
|
|
|
696
1010
|
active.cancelRecorded = true
|
|
697
1011
|
active.hasFinalResult = true
|
|
698
1012
|
|
|
1013
|
+
// Remove from activeTurns immediately so the UI reflects the cancellation
|
|
1014
|
+
// right away, rather than waiting for interrupt() which may hang.
|
|
1015
|
+
this.activeTurns.delete(chatId)
|
|
1016
|
+
this.onStateChange()
|
|
1017
|
+
|
|
1018
|
+
// Now attempt to interrupt/close the underlying stream in the background.
|
|
1019
|
+
// This is best-effort — the turn is already removed from active state above,
|
|
1020
|
+
// and runTurn()'s finally block will also call close().
|
|
699
1021
|
try {
|
|
700
|
-
await
|
|
1022
|
+
await Promise.race([
|
|
1023
|
+
active.turn.interrupt(),
|
|
1024
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
1025
|
+
])
|
|
701
1026
|
} catch {
|
|
702
|
-
|
|
1027
|
+
// interrupt() failed — force close
|
|
703
1028
|
}
|
|
704
|
-
|
|
705
|
-
this.activeTurns.delete(chatId)
|
|
706
|
-
this.onStateChange()
|
|
1029
|
+
active.turn.close()
|
|
707
1030
|
}
|
|
708
1031
|
|
|
709
1032
|
async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
|
|
@@ -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`
|