kanna-code 0.19.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-lgfU0zw2.js → index-Cdjl9mRd.js} +141 -141
- package/dist/client/assets/{index-C952_xJD.css → index-D6aACRxb.css} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/agent.test.ts +544 -0
- package/src/server/agent.ts +318 -14
- package/src/server/read-models.test.ts +1 -1
- package/src/server/read-models.ts +2 -0
- package/src/server/ws-router.test.ts +5 -5
- package/src/server/ws-router.ts +8 -1
- package/src/shared/protocol.ts +1 -0
- package/src/shared/types.ts +1 -0
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,
|
|
@@ -64,11 +64,41 @@ interface ActiveTurn {
|
|
|
64
64
|
cancelRecorded: boolean
|
|
65
65
|
}
|
|
66
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
|
+
|
|
67
89
|
interface AgentCoordinatorArgs {
|
|
68
90
|
store: EventStore
|
|
69
91
|
onStateChange: () => void
|
|
70
92
|
codexManager?: CodexAppServerManager
|
|
71
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>
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(
|
|
@@ -269,15 +299,61 @@ async function* createClaudeHarnessStream(q: Query): AsyncGenerator<HarnessEvent
|
|
|
269
299
|
}
|
|
270
300
|
}
|
|
271
301
|
|
|
272
|
-
|
|
273
|
-
|
|
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: {
|
|
274
350
|
localPath: string
|
|
275
351
|
model: string
|
|
276
352
|
effort?: string
|
|
277
353
|
planMode: boolean
|
|
278
354
|
sessionToken: string | null
|
|
279
355
|
onToolRequest: (request: HarnessToolRequest) => Promise<unknown>
|
|
280
|
-
}): Promise<
|
|
356
|
+
}): Promise<ClaudeSessionHandle> {
|
|
281
357
|
const canUseTool: CanUseTool = async (toolName, input, options) => {
|
|
282
358
|
if (toolName !== "AskUserQuestion" && toolName !== "ExitPlanMode") {
|
|
283
359
|
return {
|
|
@@ -333,8 +409,10 @@ async function startClaudeTurn(args: {
|
|
|
333
409
|
} satisfies PermissionResult
|
|
334
410
|
}
|
|
335
411
|
|
|
412
|
+
const promptQueue = new AsyncMessageQueue<SDKUserMessage>()
|
|
413
|
+
|
|
336
414
|
const q = query({
|
|
337
|
-
prompt:
|
|
415
|
+
prompt: promptQueue,
|
|
338
416
|
options: {
|
|
339
417
|
cwd: args.localPath,
|
|
340
418
|
model: args.model,
|
|
@@ -361,7 +439,25 @@ async function startClaudeTurn(args: {
|
|
|
361
439
|
interrupt: async () => {
|
|
362
440
|
await q.interrupt()
|
|
363
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
|
+
},
|
|
364
459
|
close: () => {
|
|
460
|
+
promptQueue.close()
|
|
365
461
|
q.close()
|
|
366
462
|
},
|
|
367
463
|
}
|
|
@@ -372,14 +468,18 @@ export class AgentCoordinator {
|
|
|
372
468
|
private readonly onStateChange: () => void
|
|
373
469
|
private readonly codexManager: CodexAppServerManager
|
|
374
470
|
private readonly generateTitle: (messageContent: string, cwd: string) => Promise<GenerateChatTitleResult>
|
|
471
|
+
private readonly startClaudeSessionFn: NonNullable<AgentCoordinatorArgs["startClaudeSession"]>
|
|
375
472
|
private reportBackgroundError: ((message: string) => void) | null = null
|
|
376
473
|
readonly activeTurns = new Map<string, ActiveTurn>()
|
|
474
|
+
readonly drainingStreams = new Map<string, { turn: HarnessTurn }>()
|
|
475
|
+
readonly claudeSessions = new Map<string, ClaudeSessionState>()
|
|
377
476
|
|
|
378
477
|
constructor(args: AgentCoordinatorArgs) {
|
|
379
478
|
this.store = args.store
|
|
380
479
|
this.onStateChange = args.onStateChange
|
|
381
480
|
this.codexManager = args.codexManager ?? new CodexAppServerManager()
|
|
382
481
|
this.generateTitle = args.generateTitle ?? generateTitleForChatDetailed
|
|
482
|
+
this.startClaudeSessionFn = args.startClaudeSession ?? startClaudeSession
|
|
383
483
|
}
|
|
384
484
|
|
|
385
485
|
setBackgroundErrorReporter(report: ((message: string) => void) | null) {
|
|
@@ -400,6 +500,28 @@ export class AgentCoordinator {
|
|
|
400
500
|
return { toolUseId: pending.toolUseId, toolKind: pending.tool.toolKind }
|
|
401
501
|
}
|
|
402
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
|
+
|
|
403
525
|
private resolveProvider(command: Extract<ClientCommand, { type: "chat.send" }>, currentProvider: AgentProvider | null) {
|
|
404
526
|
if (currentProvider) return currentProvider
|
|
405
527
|
return command.provider ?? "claude"
|
|
@@ -438,6 +560,13 @@ export class AgentCoordinator {
|
|
|
438
560
|
planMode: boolean
|
|
439
561
|
appendUserPrompt: boolean
|
|
440
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
|
+
|
|
441
570
|
const chat = this.store.requireChat(args.chatId)
|
|
442
571
|
if (this.activeTurns.has(args.chatId)) {
|
|
443
572
|
throw new Error("Chat is already running")
|
|
@@ -493,8 +622,8 @@ export class AgentCoordinator {
|
|
|
493
622
|
|
|
494
623
|
let turn: HarnessTurn
|
|
495
624
|
if (args.provider === "claude") {
|
|
496
|
-
turn = await startClaudeTurn({
|
|
497
|
-
|
|
625
|
+
turn = await this.startClaudeTurn({
|
|
626
|
+
chatId: args.chatId,
|
|
498
627
|
localPath: project.localPath,
|
|
499
628
|
model: args.model,
|
|
500
629
|
effort: args.effort,
|
|
@@ -529,7 +658,7 @@ export class AgentCoordinator {
|
|
|
529
658
|
effort: args.effort,
|
|
530
659
|
serviceTier: args.serviceTier,
|
|
531
660
|
planMode: args.planMode,
|
|
532
|
-
status: "starting",
|
|
661
|
+
status: args.provider === "claude" ? "running" : "starting",
|
|
533
662
|
pendingTool: null,
|
|
534
663
|
postToolFollowUp: null,
|
|
535
664
|
hasFinalResult: false,
|
|
@@ -543,15 +672,93 @@ export class AgentCoordinator {
|
|
|
543
672
|
void turn.getAccountInfo()
|
|
544
673
|
.then(async (accountInfo) => {
|
|
545
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
|
+
}
|
|
546
684
|
await this.store.appendMessage(args.chatId, timestamped({ kind: "account_info", accountInfo }))
|
|
547
685
|
this.onStateChange()
|
|
548
686
|
})
|
|
549
687
|
.catch(() => undefined)
|
|
550
688
|
}
|
|
551
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
|
+
|
|
552
699
|
void this.runTurn(active)
|
|
553
700
|
}
|
|
554
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
|
+
|
|
555
762
|
async send(command: Extract<ClientCommand, { type: "chat.send" }>) {
|
|
556
763
|
let chatId = command.chatId
|
|
557
764
|
|
|
@@ -581,6 +788,66 @@ export class AgentCoordinator {
|
|
|
581
788
|
return { chatId }
|
|
582
789
|
}
|
|
583
790
|
|
|
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
|
+
|
|
584
851
|
private async generateTitleInBackground(chatId: string, messageContent: string, cwd: string, expectedCurrentTitle: string) {
|
|
585
852
|
try {
|
|
586
853
|
const result = await this.generateTitle(messageContent, cwd)
|
|
@@ -607,6 +874,10 @@ export class AgentCoordinator {
|
|
|
607
874
|
private async runTurn(active: ActiveTurn) {
|
|
608
875
|
try {
|
|
609
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
|
+
|
|
610
881
|
if (event.type === "session_token" && event.sessionToken) {
|
|
611
882
|
await this.store.setSessionToken(active.chatId, event.sessionToken)
|
|
612
883
|
this.onStateChange()
|
|
@@ -627,6 +898,14 @@ export class AgentCoordinator {
|
|
|
627
898
|
} else if (!active.cancelRequested) {
|
|
628
899
|
await this.store.recordTurnFinished(active.chatId)
|
|
629
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 })
|
|
630
909
|
}
|
|
631
910
|
|
|
632
911
|
this.onStateChange()
|
|
@@ -651,7 +930,14 @@ export class AgentCoordinator {
|
|
|
651
930
|
await this.store.recordTurnCancelled(active.chatId)
|
|
652
931
|
}
|
|
653
932
|
active.turn.close()
|
|
654
|
-
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)
|
|
655
941
|
this.onStateChange()
|
|
656
942
|
|
|
657
943
|
if (active.postToolFollowUp && !active.cancelRequested) {
|
|
@@ -687,9 +973,18 @@ export class AgentCoordinator {
|
|
|
687
973
|
}
|
|
688
974
|
|
|
689
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
|
+
|
|
690
983
|
const active = this.activeTurns.get(chatId)
|
|
691
984
|
if (!active) return
|
|
692
985
|
|
|
986
|
+
// Guard against concurrent cancel() calls — only the first one does work.
|
|
987
|
+
if (active.cancelRequested) return
|
|
693
988
|
active.cancelRequested = true
|
|
694
989
|
|
|
695
990
|
const pendingTool = active.pendingTool
|
|
@@ -715,14 +1010,23 @@ export class AgentCoordinator {
|
|
|
715
1010
|
active.cancelRecorded = true
|
|
716
1011
|
active.hasFinalResult = true
|
|
717
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().
|
|
718
1021
|
try {
|
|
719
|
-
await
|
|
1022
|
+
await Promise.race([
|
|
1023
|
+
active.turn.interrupt(),
|
|
1024
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
1025
|
+
])
|
|
720
1026
|
} catch {
|
|
721
|
-
|
|
1027
|
+
// interrupt() failed — force close
|
|
722
1028
|
}
|
|
723
|
-
|
|
724
|
-
this.activeTurns.delete(chatId)
|
|
725
|
-
this.onStateChange()
|
|
1029
|
+
active.turn.close()
|
|
726
1030
|
}
|
|
727
1031
|
|
|
728
1032
|
async respondTool(command: Extract<ClientCommand, { type: "chat.respondTool" }>) {
|
|
@@ -54,7 +54,7 @@ describe("read models", () => {
|
|
|
54
54
|
lastTurnOutcome: null,
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
const chat = deriveChatSnapshot(state, new Map(), "chat-1", () => [])
|
|
57
|
+
const chat = deriveChatSnapshot(state, new Map(), new Set(), "chat-1", () => [])
|
|
58
58
|
expect(chat?.runtime.provider).toBe("claude")
|
|
59
59
|
expect(chat?.availableProviders.length).toBeGreaterThan(1)
|
|
60
60
|
expect(chat?.availableProviders.find((provider) => provider.id === "codex")?.models.map((model) => model.id)).toEqual([
|
|
@@ -98,6 +98,7 @@ export function deriveLocalProjectsSnapshot(
|
|
|
98
98
|
export function deriveChatSnapshot(
|
|
99
99
|
state: StoreState,
|
|
100
100
|
activeStatuses: Map<string, KannaStatus>,
|
|
101
|
+
drainingChatIds: Set<string>,
|
|
101
102
|
chatId: string,
|
|
102
103
|
getMessages: (chatId: string) => ChatSnapshot["messages"]
|
|
103
104
|
): ChatSnapshot | null {
|
|
@@ -112,6 +113,7 @@ export function deriveChatSnapshot(
|
|
|
112
113
|
localPath: project.localPath,
|
|
113
114
|
title: chat.title,
|
|
114
115
|
status: deriveStatus(chat, activeStatuses.get(chat.id)),
|
|
116
|
+
isDraining: drainingChatIds.has(chat.id),
|
|
115
117
|
provider: chat.provider,
|
|
116
118
|
planMode: chat.planMode,
|
|
117
119
|
sessionToken: chat.sessionToken,
|
|
@@ -41,7 +41,7 @@ describe("ws-router", () => {
|
|
|
41
41
|
test("acks system.ping without broadcasting snapshots", () => {
|
|
42
42
|
const router = createWsRouter({
|
|
43
43
|
store: { state: createEmptyState() } as never,
|
|
44
|
-
agent: { getActiveStatuses: () => new Map() } as never,
|
|
44
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
45
45
|
terminals: {
|
|
46
46
|
getSnapshot: () => null,
|
|
47
47
|
onEvent: () => () => {},
|
|
@@ -80,7 +80,7 @@ describe("ws-router", () => {
|
|
|
80
80
|
test("acks terminal.input without rebroadcasting terminal snapshots", () => {
|
|
81
81
|
const router = createWsRouter({
|
|
82
82
|
store: { state: createEmptyState() } as never,
|
|
83
|
-
agent: { getActiveStatuses: () => new Map() } as never,
|
|
83
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
84
84
|
terminals: {
|
|
85
85
|
getSnapshot: () => null,
|
|
86
86
|
onEvent: () => () => {},
|
|
@@ -124,7 +124,7 @@ describe("ws-router", () => {
|
|
|
124
124
|
test("subscribes and unsubscribes chat topics", () => {
|
|
125
125
|
const router = createWsRouter({
|
|
126
126
|
store: { state: createEmptyState() } as never,
|
|
127
|
-
agent: { getActiveStatuses: () => new Map() } as never,
|
|
127
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
128
128
|
terminals: {
|
|
129
129
|
getSnapshot: () => null,
|
|
130
130
|
onEvent: () => () => {},
|
|
@@ -373,7 +373,7 @@ describe("ws-router", () => {
|
|
|
373
373
|
|
|
374
374
|
const router = createWsRouter({
|
|
375
375
|
store: { state: createEmptyState() } as never,
|
|
376
|
-
agent: { getActiveStatuses: () => new Map() } as never,
|
|
376
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
377
377
|
terminals: {
|
|
378
378
|
getSnapshot: () => null,
|
|
379
379
|
onEvent: () => () => {},
|
|
@@ -474,7 +474,7 @@ describe("ws-router", () => {
|
|
|
474
474
|
|
|
475
475
|
const router = createWsRouter({
|
|
476
476
|
store: { state: createEmptyState() } as never,
|
|
477
|
-
agent: { getActiveStatuses: () => new Map() } as never,
|
|
477
|
+
agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
|
|
478
478
|
terminals: {
|
|
479
479
|
getSnapshot: () => null,
|
|
480
480
|
onEvent: () => () => {},
|
package/src/server/ws-router.ts
CHANGED
|
@@ -121,7 +121,7 @@ export function createWsRouter({
|
|
|
121
121
|
id,
|
|
122
122
|
snapshot: {
|
|
123
123
|
type: "chat",
|
|
124
|
-
data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), topic.chatId, (chatId) => store.getMessages(chatId)),
|
|
124
|
+
data: deriveChatSnapshot(store.state, agent.getActiveStatuses(), agent.getDrainingChatIds(), topic.chatId, (chatId) => store.getMessages(chatId)),
|
|
125
125
|
},
|
|
126
126
|
}
|
|
127
127
|
}
|
|
@@ -258,6 +258,7 @@ export function createWsRouter({
|
|
|
258
258
|
const project = store.getProject(command.projectId)
|
|
259
259
|
for (const chat of store.listChatsByProject(command.projectId)) {
|
|
260
260
|
await agent.cancel(chat.id)
|
|
261
|
+
await agent.closeChat(chat.id)
|
|
261
262
|
}
|
|
262
263
|
if (project) {
|
|
263
264
|
terminals.closeByCwd(project.localPath)
|
|
@@ -283,6 +284,7 @@ export function createWsRouter({
|
|
|
283
284
|
}
|
|
284
285
|
case "chat.delete": {
|
|
285
286
|
await agent.cancel(command.chatId)
|
|
287
|
+
await agent.closeChat(command.chatId)
|
|
286
288
|
await store.deleteChat(command.chatId)
|
|
287
289
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
288
290
|
break
|
|
@@ -302,6 +304,11 @@ export function createWsRouter({
|
|
|
302
304
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
303
305
|
break
|
|
304
306
|
}
|
|
307
|
+
case "chat.stopDraining": {
|
|
308
|
+
await agent.stopDraining(command.chatId)
|
|
309
|
+
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
|
310
|
+
break
|
|
311
|
+
}
|
|
305
312
|
case "chat.respondTool": {
|
|
306
313
|
await agent.respondTool(command)
|
|
307
314
|
send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
|
package/src/shared/protocol.ts
CHANGED
|
@@ -76,6 +76,7 @@ export type ClientCommand =
|
|
|
76
76
|
planMode?: boolean
|
|
77
77
|
}
|
|
78
78
|
| { type: "chat.cancel"; chatId: string }
|
|
79
|
+
| { type: "chat.stopDraining"; chatId: string }
|
|
79
80
|
| { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
|
|
80
81
|
| { type: "terminal.create"; projectId: string; terminalId: string; cols: number; rows: number; scrollback: number }
|
|
81
82
|
| { type: "terminal.input"; terminalId: string; data: string }
|