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.
@@ -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({
@@ -127,6 +165,10 @@ describe("attachment prompt helpers", () => {
127
165
 
128
166
  describe("AgentCoordinator codex integration", () => {
129
167
  test("generates a chat title in the background on the first user message", async () => {
168
+ let releaseTitle!: () => void
169
+ const titleGate = new Promise<void>((resolve) => {
170
+ releaseTitle = resolve
171
+ })
130
172
  const fakeCodexManager = {
131
173
  async startSession() {},
132
174
  async startTurn(): Promise<HarnessTurn> {
@@ -169,7 +211,14 @@ describe("AgentCoordinator codex integration", () => {
169
211
  store: store as never,
170
212
  onStateChange: () => {},
171
213
  codexManager: fakeCodexManager as never,
172
- generateTitle: async () => "Generated title",
214
+ generateTitle: async () => {
215
+ await titleGate
216
+ return {
217
+ title: "Generated title",
218
+ usedFallback: false,
219
+ failureMessage: null,
220
+ }
221
+ },
173
222
  })
174
223
 
175
224
  await coordinator.send({
@@ -180,6 +229,8 @@ describe("AgentCoordinator codex integration", () => {
180
229
  model: "gpt-5.4",
181
230
  })
182
231
 
232
+ expect(store.chat.title).toBe("first message")
233
+ releaseTitle()
183
234
  await waitFor(() => store.chat.title === "Generated title")
184
235
  expect(store.messages[0]?.kind).toBe("user_prompt")
185
236
  })
@@ -233,7 +284,11 @@ describe("AgentCoordinator codex integration", () => {
233
284
  codexManager: fakeCodexManager as never,
234
285
  generateTitle: async () => {
235
286
  await titleGate
236
- return "Generated title"
287
+ return {
288
+ title: "Generated title",
289
+ usedFallback: false,
290
+ failureMessage: null,
291
+ }
237
292
  },
238
293
  })
239
294
 
@@ -252,6 +307,76 @@ describe("AgentCoordinator codex integration", () => {
252
307
  expect(store.chat.title).toBe("Manual title")
253
308
  })
254
309
 
310
+ test("reports provider failure without a second rename after the optimistic title", async () => {
311
+ const fakeCodexManager = {
312
+ async startSession() {},
313
+ async startTurn(): Promise<HarnessTurn> {
314
+ async function* stream() {
315
+ yield {
316
+ type: "transcript" as const,
317
+ entry: timestamped({
318
+ kind: "system_init",
319
+ provider: "codex",
320
+ model: "gpt-5.4",
321
+ tools: [],
322
+ agents: [],
323
+ slashCommands: [],
324
+ mcpServers: [],
325
+ }),
326
+ }
327
+ yield {
328
+ type: "transcript" as const,
329
+ entry: timestamped({
330
+ kind: "result",
331
+ subtype: "success",
332
+ isError: false,
333
+ durationMs: 0,
334
+ result: "",
335
+ }),
336
+ }
337
+ }
338
+
339
+ return {
340
+ provider: "codex",
341
+ stream: stream(),
342
+ interrupt: async () => {},
343
+ close: () => {},
344
+ }
345
+ },
346
+ }
347
+
348
+ const store = createFakeStore()
349
+ const backgroundErrors: string[] = []
350
+ const coordinator = new AgentCoordinator({
351
+ store: store as never,
352
+ onStateChange: () => {},
353
+ codexManager: fakeCodexManager as never,
354
+ generateTitle: async () => ({
355
+ title: "first message",
356
+ usedFallback: true,
357
+ failureMessage: "claude failed conversation title generation: Not authenticated",
358
+ }),
359
+ })
360
+ coordinator.setBackgroundErrorReporter((message) => {
361
+ backgroundErrors.push(message)
362
+ })
363
+
364
+ await coordinator.send({
365
+ type: "chat.send",
366
+ chatId: "chat-1",
367
+ provider: "codex",
368
+ content: "first message",
369
+ model: "gpt-5.4",
370
+ })
371
+
372
+ expect(store.chat.title).toBe("first message")
373
+ await waitFor(() => store.turnFinishedCount === 1)
374
+ expect(store.chat.title).toBe("first message")
375
+ expect(backgroundErrors).toEqual([
376
+ "[title-generation] chat chat-1 failed provider title generation: claude failed conversation title generation: Not authenticated",
377
+ ])
378
+ })
379
+
255
380
  test("binds codex provider and reuses the session token on later turns", async () => {
256
381
  const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
257
382
  const fakeCodexManager = {
@@ -619,6 +744,370 @@ describe("AgentCoordinator codex integration", () => {
619
744
  expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
620
745
  })
621
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
+
622
1111
  test("cancelling a waiting codex exit-plan prompt discards it without starting a follow-up turn", async () => {
623
1112
  let releaseInterrupt!: () => void
624
1113
  const interrupted = new Promise<void>((resolve) => {
@@ -715,6 +1204,148 @@ describe("AgentCoordinator codex integration", () => {
715
1204
  })
716
1205
  })
717
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
+
718
1349
  function createFakeStore() {
719
1350
  const chat = {
720
1351
  id: "chat-1",