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.
@@ -21,6 +21,44 @@ async function waitFor(condition: () => boolean, timeoutMs = 2000) {
21
21
  }
22
22
  }
23
23
 
24
+ class AsyncEventQueue<T> implements AsyncIterable<T> {
25
+ private readonly values: T[] = []
26
+ private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
27
+ private closed = false
28
+
29
+ push(value: T) {
30
+ const waiter = this.waiters.shift()
31
+ if (waiter) {
32
+ waiter({ done: false, value })
33
+ return
34
+ }
35
+ this.values.push(value)
36
+ }
37
+
38
+ close() {
39
+ this.closed = true
40
+ while (this.waiters.length > 0) {
41
+ this.waiters.shift()?.({ done: true, value: undefined as never })
42
+ }
43
+ }
44
+
45
+ [Symbol.asyncIterator](): AsyncIterator<T> {
46
+ return {
47
+ next: async () => {
48
+ if (this.values.length > 0) {
49
+ return { done: false, value: this.values.shift() as T }
50
+ }
51
+ if (this.closed) {
52
+ return { done: true, value: undefined as never }
53
+ }
54
+ return await new Promise<IteratorResult<T>>((resolve) => {
55
+ this.waiters.push(resolve)
56
+ })
57
+ },
58
+ }
59
+ }
60
+ }
61
+
24
62
  describe("normalizeClaudeStreamMessage", () => {
25
63
  test("normalizes assistant tool calls", () => {
26
64
  const entries = normalizeClaudeStreamMessage({
@@ -706,6 +744,370 @@ describe("AgentCoordinator codex integration", () => {
706
744
  expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
707
745
  })
708
746
 
747
+ test("UI unblocks immediately when result arrives even if stream stays open", async () => {
748
+ let resolveStream!: () => void
749
+
750
+ const fakeCodexManager = {
751
+ async startSession() {},
752
+ async startTurn(): Promise<HarnessTurn> {
753
+ async function* stream() {
754
+ yield {
755
+ type: "transcript" as const,
756
+ entry: timestamped({
757
+ kind: "system_init",
758
+ provider: "codex",
759
+ model: "gpt-5.4",
760
+ tools: [],
761
+ agents: [],
762
+ slashCommands: [],
763
+ mcpServers: [],
764
+ }),
765
+ }
766
+ // Produce the result event
767
+ yield {
768
+ type: "transcript" as const,
769
+ entry: timestamped({
770
+ kind: "result",
771
+ subtype: "success",
772
+ isError: false,
773
+ durationMs: 120_000,
774
+ result: "done",
775
+ }),
776
+ }
777
+ // Stream stays open (simulates background tasks still running)
778
+ await new Promise<void>((resolve) => {
779
+ resolveStream = resolve
780
+ })
781
+ }
782
+
783
+ return {
784
+ provider: "codex",
785
+ stream: stream(),
786
+ interrupt: async () => {},
787
+ close: () => {
788
+ resolveStream?.()
789
+ },
790
+ }
791
+ },
792
+ }
793
+
794
+ const store = createFakeStore()
795
+ const coordinator = new AgentCoordinator({
796
+ store: store as never,
797
+ onStateChange: () => {},
798
+ codexManager: fakeCodexManager as never,
799
+ })
800
+
801
+ await coordinator.send({
802
+ type: "chat.send",
803
+ chatId: "chat-1",
804
+ provider: "codex",
805
+ content: "run something with a background task",
806
+ })
807
+
808
+ // Wait for the result message to be persisted
809
+ await waitFor(() => store.messages.some((entry) => entry.kind === "result"))
810
+
811
+ // The active turn should be removed even though the stream is still open.
812
+ // This is the key assertion: the UI should show idle (not "Running...")
813
+ // so the user can send new messages without hitting stop.
814
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
815
+ expect(store.turnFinishedCount).toBe(1)
816
+
817
+ // The stream is still open, so it should be draining
818
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(true)
819
+
820
+ // Clean up the hanging stream
821
+ resolveStream()
822
+
823
+ // After the stream closes, draining should stop
824
+ await waitFor(() => !coordinator.getDrainingChatIds().has("chat-1"))
825
+ })
826
+
827
+ test("stopDraining closes the stream and removes from draining set", async () => {
828
+ let resolveStream!: () => void
829
+ let streamClosed = false
830
+
831
+ const fakeCodexManager = {
832
+ async startSession() {},
833
+ async startTurn(): Promise<HarnessTurn> {
834
+ async function* stream() {
835
+ yield {
836
+ type: "transcript" as const,
837
+ entry: timestamped({
838
+ kind: "system_init",
839
+ provider: "codex",
840
+ model: "gpt-5.4",
841
+ tools: [],
842
+ agents: [],
843
+ slashCommands: [],
844
+ mcpServers: [],
845
+ }),
846
+ }
847
+ yield {
848
+ type: "transcript" as const,
849
+ entry: timestamped({
850
+ kind: "result",
851
+ subtype: "success",
852
+ isError: false,
853
+ durationMs: 0,
854
+ result: "done",
855
+ }),
856
+ }
857
+ await new Promise<void>((resolve) => {
858
+ resolveStream = resolve
859
+ })
860
+ }
861
+
862
+ return {
863
+ provider: "codex",
864
+ stream: stream(),
865
+ interrupt: async () => {},
866
+ close: () => {
867
+ streamClosed = true
868
+ resolveStream?.()
869
+ },
870
+ }
871
+ },
872
+ }
873
+
874
+ const store = createFakeStore()
875
+ const coordinator = new AgentCoordinator({
876
+ store: store as never,
877
+ onStateChange: () => {},
878
+ codexManager: fakeCodexManager as never,
879
+ })
880
+
881
+ await coordinator.send({
882
+ type: "chat.send",
883
+ chatId: "chat-1",
884
+ provider: "codex",
885
+ content: "work",
886
+ })
887
+
888
+ await waitFor(() => coordinator.getDrainingChatIds().has("chat-1"))
889
+
890
+ await coordinator.stopDraining("chat-1")
891
+
892
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
893
+ expect(streamClosed).toBe(true)
894
+ })
895
+
896
+ test("cancel immediately removes active turn so UI shows idle", async () => {
897
+ let resolveInterrupt!: () => void
898
+ const interruptCalled = new Promise<void>((resolve) => {
899
+ resolveInterrupt = resolve
900
+ })
901
+ // interrupt() that hangs until we resolve it — simulating a slow SDK
902
+ let interruptDone = false
903
+
904
+ const fakeCodexManager = {
905
+ async startSession() {},
906
+ async startTurn(): Promise<HarnessTurn> {
907
+ async function* stream() {
908
+ yield {
909
+ type: "transcript" as const,
910
+ entry: timestamped({
911
+ kind: "system_init",
912
+ provider: "codex",
913
+ model: "gpt-5.4",
914
+ tools: [],
915
+ agents: [],
916
+ slashCommands: [],
917
+ mcpServers: [],
918
+ }),
919
+ }
920
+ // Stream that never ends (simulates the SDK hanging)
921
+ await new Promise(() => {})
922
+ }
923
+
924
+ return {
925
+ provider: "codex",
926
+ stream: stream(),
927
+ interrupt: async () => {
928
+ resolveInterrupt()
929
+ // Hang to simulate a slow interrupt
930
+ await new Promise<void>((resolve) => {
931
+ setTimeout(() => {
932
+ interruptDone = true
933
+ resolve()
934
+ }, 100)
935
+ })
936
+ },
937
+ close: () => {},
938
+ }
939
+ },
940
+ }
941
+
942
+ const stateChanges: number[] = []
943
+ const store = createFakeStore()
944
+ const coordinator = new AgentCoordinator({
945
+ store: store as never,
946
+ onStateChange: () => {
947
+ stateChanges.push(Date.now())
948
+ },
949
+ codexManager: fakeCodexManager as never,
950
+ })
951
+
952
+ await coordinator.send({
953
+ type: "chat.send",
954
+ chatId: "chat-1",
955
+ provider: "codex",
956
+ content: "do something",
957
+ })
958
+
959
+ // Wait for the turn to be running
960
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
961
+
962
+ // Cancel — this should immediately remove from active turns
963
+ const cancelPromise = coordinator.cancel("chat-1")
964
+
965
+ // The turn should be removed from activeTurns immediately,
966
+ // BEFORE interrupt() resolves
967
+ await interruptCalled
968
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
969
+ expect(interruptDone).toBe(false) // interrupt is still in progress
970
+
971
+ await cancelPromise
972
+
973
+ // Verify only one "interrupted" message was appended
974
+ const interruptedMessages = store.messages.filter((entry) => entry.kind === "interrupted")
975
+ expect(interruptedMessages).toHaveLength(1)
976
+ })
977
+
978
+ test("concurrent cancel calls only produce a single interrupted message", async () => {
979
+ let resolveStream!: () => void
980
+
981
+ const fakeCodexManager = {
982
+ async startSession() {},
983
+ async startTurn(): Promise<HarnessTurn> {
984
+ async function* stream() {
985
+ yield {
986
+ type: "transcript" as const,
987
+ entry: timestamped({
988
+ kind: "system_init",
989
+ provider: "codex",
990
+ model: "gpt-5.4",
991
+ tools: [],
992
+ agents: [],
993
+ slashCommands: [],
994
+ mcpServers: [],
995
+ }),
996
+ }
997
+ await new Promise<void>((resolve) => {
998
+ resolveStream = resolve
999
+ })
1000
+ }
1001
+
1002
+ return {
1003
+ provider: "codex",
1004
+ stream: stream(),
1005
+ interrupt: async () => {
1006
+ resolveStream()
1007
+ },
1008
+ close: () => {},
1009
+ }
1010
+ },
1011
+ }
1012
+
1013
+ const store = createFakeStore()
1014
+ const coordinator = new AgentCoordinator({
1015
+ store: store as never,
1016
+ onStateChange: () => {},
1017
+ codexManager: fakeCodexManager as never,
1018
+ })
1019
+
1020
+ await coordinator.send({
1021
+ type: "chat.send",
1022
+ chatId: "chat-1",
1023
+ provider: "codex",
1024
+ content: "work",
1025
+ })
1026
+
1027
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
1028
+
1029
+ // Fire multiple cancel calls concurrently (simulating repeated stop button clicks)
1030
+ await Promise.all([
1031
+ coordinator.cancel("chat-1"),
1032
+ coordinator.cancel("chat-1"),
1033
+ coordinator.cancel("chat-1"),
1034
+ ])
1035
+
1036
+ // Only one "interrupted" message should exist
1037
+ const interruptedMessages = store.messages.filter((entry) => entry.kind === "interrupted")
1038
+ expect(interruptedMessages).toHaveLength(1)
1039
+ })
1040
+
1041
+ test("runTurn stops processing events after cancel", async () => {
1042
+ let resolveStream!: () => void
1043
+
1044
+ const fakeCodexManager = {
1045
+ async startSession() {},
1046
+ async startTurn(): Promise<HarnessTurn> {
1047
+ async function* stream() {
1048
+ yield {
1049
+ type: "transcript" as const,
1050
+ entry: timestamped({
1051
+ kind: "system_init",
1052
+ provider: "codex",
1053
+ model: "gpt-5.4",
1054
+ tools: [],
1055
+ agents: [],
1056
+ slashCommands: [],
1057
+ mcpServers: [],
1058
+ }),
1059
+ }
1060
+ // Wait for cancel, then yield another event that should be ignored
1061
+ await new Promise<void>((resolve) => {
1062
+ resolveStream = resolve
1063
+ })
1064
+ // This event arrives after cancel — should not be processed
1065
+ yield {
1066
+ type: "transcript" as const,
1067
+ entry: timestamped({
1068
+ kind: "assistant_text",
1069
+ text: "this should be ignored after cancel",
1070
+ }),
1071
+ }
1072
+ }
1073
+
1074
+ return {
1075
+ provider: "codex",
1076
+ stream: stream(),
1077
+ interrupt: async () => {
1078
+ resolveStream()
1079
+ },
1080
+ close: () => {},
1081
+ }
1082
+ },
1083
+ }
1084
+
1085
+ const store = createFakeStore()
1086
+ const coordinator = new AgentCoordinator({
1087
+ store: store as never,
1088
+ onStateChange: () => {},
1089
+ codexManager: fakeCodexManager as never,
1090
+ })
1091
+
1092
+ await coordinator.send({
1093
+ type: "chat.send",
1094
+ chatId: "chat-1",
1095
+ provider: "codex",
1096
+ content: "work",
1097
+ })
1098
+
1099
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
1100
+
1101
+ const messageCountBefore = store.messages.filter((entry) => entry.kind === "assistant_text").length
1102
+ await coordinator.cancel("chat-1")
1103
+
1104
+ // Give the stream time to yield the extra event
1105
+ await new Promise((resolve) => setTimeout(resolve, 50))
1106
+
1107
+ const postCancelTextMessages = store.messages.filter((entry) => entry.kind === "assistant_text")
1108
+ expect(postCancelTextMessages.length).toBe(messageCountBefore)
1109
+ })
1110
+
709
1111
  test("cancelling a waiting codex exit-plan prompt discards it without starting a follow-up turn", async () => {
710
1112
  let releaseInterrupt!: () => void
711
1113
  const interrupted = new Promise<void>((resolve) => {
@@ -802,6 +1204,148 @@ describe("AgentCoordinator codex integration", () => {
802
1204
  })
803
1205
  })
804
1206
 
1207
+ describe("AgentCoordinator claude integration", () => {
1208
+ test("reuses a persistent Claude session across turns", async () => {
1209
+ const events = new AsyncEventQueue<any>()
1210
+ const startSessionCalls: Array<{ model: string; planMode: boolean; sessionToken: string | null }> = []
1211
+ const prompts: string[] = []
1212
+
1213
+ const store = createFakeStore()
1214
+ const coordinator = new AgentCoordinator({
1215
+ store: store as never,
1216
+ onStateChange: () => {},
1217
+ startClaudeSession: async (args) => {
1218
+ startSessionCalls.push({
1219
+ model: args.model,
1220
+ planMode: args.planMode,
1221
+ sessionToken: args.sessionToken,
1222
+ })
1223
+
1224
+ return {
1225
+ provider: "claude",
1226
+ stream: events,
1227
+ getAccountInfo: async () => null,
1228
+ interrupt: async () => {},
1229
+ close: () => {},
1230
+ setModel: async () => {},
1231
+ setPermissionMode: async () => {},
1232
+ sendPrompt: async (content: string) => {
1233
+ prompts.push(content)
1234
+ if (prompts.length === 1) {
1235
+ events.push({ type: "session_token" as const, sessionToken: "claude-session-1" })
1236
+ events.push({
1237
+ type: "transcript" as const,
1238
+ entry: timestamped({
1239
+ kind: "system_init",
1240
+ provider: "claude",
1241
+ model: "claude-opus-4-1",
1242
+ tools: [],
1243
+ agents: [],
1244
+ slashCommands: [],
1245
+ mcpServers: [],
1246
+ }),
1247
+ })
1248
+ }
1249
+ events.push({
1250
+ type: "transcript" as const,
1251
+ entry: timestamped({
1252
+ kind: "result",
1253
+ subtype: "success",
1254
+ isError: false,
1255
+ durationMs: 0,
1256
+ result: "done",
1257
+ }),
1258
+ })
1259
+ },
1260
+ }
1261
+ },
1262
+ })
1263
+
1264
+ await coordinator.send({
1265
+ type: "chat.send",
1266
+ chatId: "chat-1",
1267
+ provider: "claude",
1268
+ content: "start background task",
1269
+ model: "claude-opus-4-1",
1270
+ })
1271
+ await waitFor(() => store.turnFinishedCount === 1)
1272
+
1273
+ await coordinator.send({
1274
+ type: "chat.send",
1275
+ chatId: "chat-1",
1276
+ provider: "claude",
1277
+ content: "check task output",
1278
+ model: "claude-opus-4-1",
1279
+ })
1280
+ await waitFor(() => store.turnFinishedCount === 2)
1281
+
1282
+ expect(startSessionCalls).toHaveLength(1)
1283
+ expect(startSessionCalls[0]?.planMode).toBe(false)
1284
+ expect(startSessionCalls[0]?.sessionToken).toBeNull()
1285
+ expect(prompts).toEqual(["start background task", "check task output"])
1286
+ expect(store.chat.sessionToken).toBe("claude-session-1")
1287
+
1288
+ events.close()
1289
+ })
1290
+
1291
+ test("Claude final results clear running state without using draining mode", async () => {
1292
+ const events = new AsyncEventQueue<any>()
1293
+
1294
+ const store = createFakeStore()
1295
+ const coordinator = new AgentCoordinator({
1296
+ store: store as never,
1297
+ onStateChange: () => {},
1298
+ startClaudeSession: async () => ({
1299
+ provider: "claude",
1300
+ stream: events,
1301
+ getAccountInfo: async () => null,
1302
+ interrupt: async () => {},
1303
+ close: () => {},
1304
+ setModel: async () => {},
1305
+ setPermissionMode: async () => {},
1306
+ sendPrompt: async () => {
1307
+ events.push({
1308
+ type: "transcript" as const,
1309
+ entry: timestamped({
1310
+ kind: "system_init",
1311
+ provider: "claude",
1312
+ model: "claude-opus-4-1",
1313
+ tools: [],
1314
+ agents: [],
1315
+ slashCommands: [],
1316
+ mcpServers: [],
1317
+ }),
1318
+ })
1319
+ events.push({
1320
+ type: "transcript" as const,
1321
+ entry: timestamped({
1322
+ kind: "result",
1323
+ subtype: "success",
1324
+ isError: false,
1325
+ durationMs: 0,
1326
+ result: "done",
1327
+ }),
1328
+ })
1329
+ },
1330
+ }),
1331
+ })
1332
+
1333
+ await coordinator.send({
1334
+ type: "chat.send",
1335
+ chatId: "chat-1",
1336
+ provider: "claude",
1337
+ content: "run something",
1338
+ model: "claude-opus-4-1",
1339
+ })
1340
+
1341
+ await waitFor(() => store.turnFinishedCount === 1)
1342
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
1343
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
1344
+
1345
+ events.close()
1346
+ })
1347
+ })
1348
+
805
1349
  function createFakeStore() {
806
1350
  const chat = {
807
1351
  id: "chat-1",