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.test.ts
CHANGED
|
@@ -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 () =>
|
|
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
|
|
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",
|