@agentxjs/core 1.9.1-dev

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.
Files changed (77) hide show
  1. package/package.json +31 -0
  2. package/src/agent/AgentStateMachine.ts +151 -0
  3. package/src/agent/README.md +296 -0
  4. package/src/agent/__tests__/AgentStateMachine.test.ts +346 -0
  5. package/src/agent/__tests__/createAgent.test.ts +728 -0
  6. package/src/agent/__tests__/engine/internal/messageAssemblerProcessor.test.ts +567 -0
  7. package/src/agent/__tests__/engine/internal/stateEventProcessor.test.ts +315 -0
  8. package/src/agent/__tests__/engine/internal/turnTrackerProcessor.test.ts +340 -0
  9. package/src/agent/__tests__/engine/mealy/Mealy.test.ts +370 -0
  10. package/src/agent/__tests__/engine/mealy/Store.test.ts +123 -0
  11. package/src/agent/__tests__/engine/mealy/combinators.test.ts +322 -0
  12. package/src/agent/createAgent.ts +467 -0
  13. package/src/agent/engine/AgentProcessor.ts +106 -0
  14. package/src/agent/engine/MealyMachine.ts +184 -0
  15. package/src/agent/engine/internal/index.ts +35 -0
  16. package/src/agent/engine/internal/messageAssemblerProcessor.ts +550 -0
  17. package/src/agent/engine/internal/stateEventProcessor.ts +313 -0
  18. package/src/agent/engine/internal/turnTrackerProcessor.ts +239 -0
  19. package/src/agent/engine/mealy/Mealy.ts +308 -0
  20. package/src/agent/engine/mealy/Processor.ts +70 -0
  21. package/src/agent/engine/mealy/Sink.ts +56 -0
  22. package/src/agent/engine/mealy/Source.ts +51 -0
  23. package/src/agent/engine/mealy/Store.ts +98 -0
  24. package/src/agent/engine/mealy/combinators.ts +176 -0
  25. package/src/agent/engine/mealy/index.ts +45 -0
  26. package/src/agent/index.ts +106 -0
  27. package/src/agent/types/engine.ts +395 -0
  28. package/src/agent/types/event.ts +478 -0
  29. package/src/agent/types/index.ts +197 -0
  30. package/src/agent/types/message.ts +387 -0
  31. package/src/common/index.ts +8 -0
  32. package/src/common/logger/ConsoleLogger.ts +137 -0
  33. package/src/common/logger/LoggerFactoryImpl.ts +123 -0
  34. package/src/common/logger/index.ts +26 -0
  35. package/src/common/logger/types.ts +98 -0
  36. package/src/container/Container.ts +185 -0
  37. package/src/container/index.ts +44 -0
  38. package/src/container/types.ts +71 -0
  39. package/src/driver/index.ts +42 -0
  40. package/src/driver/types.ts +363 -0
  41. package/src/event/EventBus.ts +260 -0
  42. package/src/event/README.md +237 -0
  43. package/src/event/__tests__/EventBus.test.ts +251 -0
  44. package/src/event/index.ts +46 -0
  45. package/src/event/types/agent.ts +512 -0
  46. package/src/event/types/base.ts +241 -0
  47. package/src/event/types/bus.ts +429 -0
  48. package/src/event/types/command.ts +749 -0
  49. package/src/event/types/container.ts +471 -0
  50. package/src/event/types/driver.ts +452 -0
  51. package/src/event/types/index.ts +26 -0
  52. package/src/event/types/session.ts +314 -0
  53. package/src/image/Image.ts +203 -0
  54. package/src/image/index.ts +36 -0
  55. package/src/image/types.ts +77 -0
  56. package/src/index.ts +20 -0
  57. package/src/mq/OffsetGenerator.ts +48 -0
  58. package/src/mq/README.md +166 -0
  59. package/src/mq/__tests__/OffsetGenerator.test.ts +121 -0
  60. package/src/mq/index.ts +18 -0
  61. package/src/mq/types.ts +172 -0
  62. package/src/network/RpcClient.ts +455 -0
  63. package/src/network/index.ts +76 -0
  64. package/src/network/jsonrpc.ts +336 -0
  65. package/src/network/protocol.ts +90 -0
  66. package/src/network/types.ts +284 -0
  67. package/src/persistence/index.ts +27 -0
  68. package/src/persistence/types.ts +226 -0
  69. package/src/runtime/AgentXRuntime.ts +501 -0
  70. package/src/runtime/index.ts +56 -0
  71. package/src/runtime/types.ts +236 -0
  72. package/src/session/Session.ts +71 -0
  73. package/src/session/index.ts +25 -0
  74. package/src/session/types.ts +77 -0
  75. package/src/workspace/index.ts +27 -0
  76. package/src/workspace/types.ts +131 -0
  77. package/tsconfig.json +10 -0
@@ -0,0 +1,315 @@
1
+ /**
2
+ * stateEventProcessor.test.ts - Unit tests for state event processor
3
+ *
4
+ * Tests the stateless event transformer: Stream Events -> State Events
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from "bun:test";
8
+ import {
9
+ stateEventProcessor,
10
+ createInitialStateEventProcessorContext,
11
+ type StateEventProcessorContext,
12
+ type StateEventProcessorInput,
13
+ type StateEventProcessorOutput,
14
+ } from "../../../engine/internal/stateEventProcessor";
15
+
16
+ // Helper to create test events
17
+ function createStreamEvent(
18
+ type: string,
19
+ data: unknown = {},
20
+ timestamp = Date.now()
21
+ ): StateEventProcessorInput {
22
+ return { type, data, timestamp } as StateEventProcessorInput;
23
+ }
24
+
25
+ describe("stateEventProcessor", () => {
26
+ let context: StateEventProcessorContext;
27
+
28
+ beforeEach(() => {
29
+ context = createInitialStateEventProcessorContext();
30
+ });
31
+
32
+ describe("initial context", () => {
33
+ it("should create empty initial context", () => {
34
+ const initialContext = createInitialStateEventProcessorContext();
35
+ expect(initialContext).toEqual({});
36
+ });
37
+ });
38
+
39
+ describe("message_start event", () => {
40
+ it("should emit conversation_start event", () => {
41
+ const event = createStreamEvent("message_start", { messageId: "msg_123" });
42
+
43
+ const [newContext, outputs] = stateEventProcessor(context, event);
44
+
45
+ expect(outputs).toHaveLength(1);
46
+ expect(outputs[0].type).toBe("conversation_start");
47
+ expect(outputs[0].data.messageId).toBe("msg_123");
48
+ expect(outputs[0].timestamp).toBeDefined();
49
+
50
+ // Context should remain unchanged
51
+ expect(newContext).toEqual(context);
52
+ });
53
+ });
54
+
55
+ describe("message_delta event", () => {
56
+ it("should not emit any event (no-op)", () => {
57
+ const event = createStreamEvent("message_delta", { stopReason: "end_turn" });
58
+
59
+ const [newContext, outputs] = stateEventProcessor(context, event);
60
+
61
+ expect(outputs).toHaveLength(0);
62
+ expect(newContext).toEqual(context);
63
+ });
64
+ });
65
+
66
+ describe("message_stop event", () => {
67
+ it("should emit conversation_end for end_turn stop reason", () => {
68
+ const event = createStreamEvent("message_stop", { stopReason: "end_turn" });
69
+
70
+ const [newContext, outputs] = stateEventProcessor(context, event);
71
+
72
+ expect(outputs).toHaveLength(1);
73
+ expect(outputs[0].type).toBe("conversation_end");
74
+ expect(outputs[0].data.reason).toBe("completed");
75
+ });
76
+
77
+ it("should emit conversation_end for max_tokens stop reason", () => {
78
+ const event = createStreamEvent("message_stop", { stopReason: "max_tokens" });
79
+
80
+ const [, outputs] = stateEventProcessor(context, event);
81
+
82
+ expect(outputs).toHaveLength(1);
83
+ expect(outputs[0].type).toBe("conversation_end");
84
+ });
85
+
86
+ it("should emit conversation_end for stop_sequence stop reason", () => {
87
+ const event = createStreamEvent("message_stop", { stopReason: "stop_sequence" });
88
+
89
+ const [, outputs] = stateEventProcessor(context, event);
90
+
91
+ expect(outputs).toHaveLength(1);
92
+ expect(outputs[0].type).toBe("conversation_end");
93
+ });
94
+
95
+ it("should NOT emit conversation_end for tool_use stop reason", () => {
96
+ const event = createStreamEvent("message_stop", { stopReason: "tool_use" });
97
+
98
+ const [newContext, outputs] = stateEventProcessor(context, event);
99
+
100
+ expect(outputs).toHaveLength(0);
101
+ expect(newContext).toEqual(context);
102
+ });
103
+ });
104
+
105
+ describe("text_delta event", () => {
106
+ it("should emit conversation_responding event", () => {
107
+ const event = createStreamEvent("text_delta", { text: "Hello" });
108
+
109
+ const [newContext, outputs] = stateEventProcessor(context, event);
110
+
111
+ expect(outputs).toHaveLength(1);
112
+ expect(outputs[0].type).toBe("conversation_responding");
113
+ expect(outputs[0].data).toEqual({});
114
+ expect(outputs[0].timestamp).toBeDefined();
115
+ });
116
+ });
117
+
118
+ describe("tool_use_start event", () => {
119
+ it("should emit tool_planned and tool_executing events", () => {
120
+ const event = createStreamEvent("tool_use_start", {
121
+ toolCallId: "tool_123",
122
+ toolName: "search",
123
+ });
124
+
125
+ const [newContext, outputs] = stateEventProcessor(context, event);
126
+
127
+ expect(outputs).toHaveLength(2);
128
+
129
+ // First output: tool_planned
130
+ expect(outputs[0].type).toBe("tool_planned");
131
+ expect(outputs[0].data.toolId).toBe("tool_123");
132
+ expect(outputs[0].data.toolName).toBe("search");
133
+
134
+ // Second output: tool_executing
135
+ expect(outputs[1].type).toBe("tool_executing");
136
+ expect(outputs[1].data.toolId).toBe("tool_123");
137
+ expect(outputs[1].data.toolName).toBe("search");
138
+ expect(outputs[1].data.input).toEqual({});
139
+ });
140
+ });
141
+
142
+ describe("tool_use_stop event", () => {
143
+ it("should not emit any event (pass through)", () => {
144
+ const event = createStreamEvent("tool_use_stop", {});
145
+
146
+ const [newContext, outputs] = stateEventProcessor(context, event);
147
+
148
+ expect(outputs).toHaveLength(0);
149
+ expect(newContext).toEqual(context);
150
+ });
151
+ });
152
+
153
+ describe("error_received event", () => {
154
+ it("should emit error_occurred event with error details", () => {
155
+ const event = createStreamEvent("error_received", {
156
+ message: "API rate limit exceeded",
157
+ errorCode: "rate_limit",
158
+ });
159
+
160
+ const [newContext, outputs] = stateEventProcessor(context, event);
161
+
162
+ expect(outputs).toHaveLength(1);
163
+ expect(outputs[0].type).toBe("error_occurred");
164
+ expect(outputs[0].data.code).toBe("rate_limit");
165
+ expect(outputs[0].data.message).toBe("API rate limit exceeded");
166
+ expect(outputs[0].data.recoverable).toBe(true);
167
+ });
168
+
169
+ it("should use unknown_error code when errorCode is missing", () => {
170
+ const event = createStreamEvent("error_received", {
171
+ message: "Something went wrong",
172
+ });
173
+
174
+ const [, outputs] = stateEventProcessor(context, event);
175
+
176
+ expect(outputs[0].data.code).toBe("unknown_error");
177
+ });
178
+ });
179
+
180
+ describe("unhandled events", () => {
181
+ it("should pass through unhandled events without output", () => {
182
+ const event = createStreamEvent("input_json_delta", { partialJson: "{}" });
183
+
184
+ const [newContext, outputs] = stateEventProcessor(context, event);
185
+
186
+ expect(outputs).toHaveLength(0);
187
+ expect(newContext).toEqual(context);
188
+ });
189
+
190
+ it("should handle completely unknown event types", () => {
191
+ const event = createStreamEvent("completely_unknown_event", { anything: true });
192
+
193
+ const [newContext, outputs] = stateEventProcessor(context, event);
194
+
195
+ expect(outputs).toHaveLength(0);
196
+ expect(newContext).toEqual(context);
197
+ });
198
+ });
199
+
200
+ describe("complete conversation flow", () => {
201
+ it("should produce correct state events for simple message", () => {
202
+ const allOutputs: StateEventProcessorOutput[] = [];
203
+
204
+ // message_start
205
+ const [, o1] = stateEventProcessor(
206
+ context,
207
+ createStreamEvent("message_start", { messageId: "msg_1" })
208
+ );
209
+ allOutputs.push(...o1);
210
+
211
+ // text_delta
212
+ const [, o2] = stateEventProcessor(
213
+ context,
214
+ createStreamEvent("text_delta", { text: "Hello" })
215
+ );
216
+ allOutputs.push(...o2);
217
+
218
+ // message_stop
219
+ const [, o3] = stateEventProcessor(
220
+ context,
221
+ createStreamEvent("message_stop", { stopReason: "end_turn" })
222
+ );
223
+ allOutputs.push(...o3);
224
+
225
+ expect(allOutputs).toHaveLength(3);
226
+ expect(allOutputs.map((o) => o.type)).toEqual([
227
+ "conversation_start",
228
+ "conversation_responding",
229
+ "conversation_end",
230
+ ]);
231
+ });
232
+
233
+ it("should produce correct state events for tool call flow", () => {
234
+ const allOutputs: StateEventProcessorOutput[] = [];
235
+
236
+ // message_start
237
+ const [, o1] = stateEventProcessor(
238
+ context,
239
+ createStreamEvent("message_start", { messageId: "msg_1" })
240
+ );
241
+ allOutputs.push(...o1);
242
+
243
+ // tool_use_start
244
+ const [, o2] = stateEventProcessor(
245
+ context,
246
+ createStreamEvent("tool_use_start", { toolCallId: "tool_1", toolName: "search" })
247
+ );
248
+ allOutputs.push(...o2);
249
+
250
+ // tool_use_stop
251
+ const [, o3] = stateEventProcessor(context, createStreamEvent("tool_use_stop", {}));
252
+ allOutputs.push(...o3);
253
+
254
+ // message_stop with tool_use (should NOT emit conversation_end)
255
+ const [, o4] = stateEventProcessor(
256
+ context,
257
+ createStreamEvent("message_stop", { stopReason: "tool_use" })
258
+ );
259
+ allOutputs.push(...o4);
260
+
261
+ // New message after tool result
262
+ const [, o5] = stateEventProcessor(
263
+ context,
264
+ createStreamEvent("message_start", { messageId: "msg_2" })
265
+ );
266
+ allOutputs.push(...o5);
267
+
268
+ // text_delta with final response
269
+ const [, o6] = stateEventProcessor(
270
+ context,
271
+ createStreamEvent("text_delta", { text: "Result" })
272
+ );
273
+ allOutputs.push(...o6);
274
+
275
+ // Final message_stop
276
+ const [, o7] = stateEventProcessor(
277
+ context,
278
+ createStreamEvent("message_stop", { stopReason: "end_turn" })
279
+ );
280
+ allOutputs.push(...o7);
281
+
282
+ expect(allOutputs.map((o) => o.type)).toEqual([
283
+ "conversation_start", // msg_1 start
284
+ "tool_planned", // tool started
285
+ "tool_executing", // tool executing
286
+ // No output for tool_use_stop
287
+ // No conversation_end for tool_use
288
+ "conversation_start", // msg_2 start
289
+ "conversation_responding", // text delta
290
+ "conversation_end", // final end
291
+ ]);
292
+ });
293
+ });
294
+
295
+ describe("statelessness", () => {
296
+ it("should not modify context across multiple calls", () => {
297
+ const originalContext = createInitialStateEventProcessorContext();
298
+
299
+ // Process multiple events
300
+ stateEventProcessor(originalContext, createStreamEvent("message_start", { messageId: "1" }));
301
+ stateEventProcessor(originalContext, createStreamEvent("text_delta", { text: "Hello" }));
302
+ stateEventProcessor(
303
+ originalContext,
304
+ createStreamEvent("tool_use_start", { toolCallId: "t1", toolName: "test" })
305
+ );
306
+ stateEventProcessor(
307
+ originalContext,
308
+ createStreamEvent("error_received", { message: "error" })
309
+ );
310
+
311
+ // Original context should remain unchanged
312
+ expect(originalContext).toEqual({});
313
+ });
314
+ });
315
+ });
@@ -0,0 +1,340 @@
1
+ /**
2
+ * turnTrackerProcessor.test.ts - Unit tests for turn tracker processor
3
+ *
4
+ * Tests the pure Mealy transition function that tracks request-response turn pairs.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from "bun:test";
8
+ import {
9
+ turnTrackerProcessor,
10
+ createInitialTurnTrackerState,
11
+ type TurnTrackerState,
12
+ type TurnTrackerInput,
13
+ type TurnTrackerOutput,
14
+ } from "../../../engine/internal/turnTrackerProcessor";
15
+
16
+ // Helper to create test events
17
+ function createEvent(type: string, data: unknown, timestamp = Date.now()): TurnTrackerInput {
18
+ return { type, data, timestamp } as TurnTrackerInput;
19
+ }
20
+
21
+ // Helper to create user message event
22
+ function createUserMessageEvent(
23
+ content: string,
24
+ messageId: string = `msg_${Date.now()}`,
25
+ timestamp = Date.now()
26
+ ): TurnTrackerInput {
27
+ return createEvent(
28
+ "user_message",
29
+ {
30
+ id: messageId,
31
+ role: "user",
32
+ subtype: "user",
33
+ content,
34
+ timestamp,
35
+ },
36
+ timestamp
37
+ );
38
+ }
39
+
40
+ // Helper to create message_stop event
41
+ function createMessageStopEvent(stopReason: string, timestamp = Date.now()): TurnTrackerInput {
42
+ return createEvent("message_stop", { stopReason }, timestamp);
43
+ }
44
+
45
+ describe("turnTrackerProcessor", () => {
46
+ let state: TurnTrackerState;
47
+
48
+ beforeEach(() => {
49
+ state = createInitialTurnTrackerState();
50
+ });
51
+
52
+ describe("initial state", () => {
53
+ it("should create correct initial state", () => {
54
+ const initialState = createInitialTurnTrackerState();
55
+
56
+ expect(initialState.pendingTurn).toBeNull();
57
+ expect(initialState.costPerInputToken).toBe(0.000003);
58
+ expect(initialState.costPerOutputToken).toBe(0.000015);
59
+ });
60
+ });
61
+
62
+ describe("user_message event", () => {
63
+ it("should create pending turn and emit turn_request", () => {
64
+ const event = createUserMessageEvent("Hello", "msg_123", 1000);
65
+
66
+ const [newState, outputs] = turnTrackerProcessor(state, event);
67
+
68
+ // Should have pending turn
69
+ expect(newState.pendingTurn).not.toBeNull();
70
+ expect(newState.pendingTurn?.messageId).toBe("msg_123");
71
+ expect(newState.pendingTurn?.content).toBe("Hello");
72
+ expect(newState.pendingTurn?.requestedAt).toBe(1000);
73
+ expect(newState.pendingTurn?.turnId).toBeDefined();
74
+
75
+ // Should emit turn_request
76
+ expect(outputs).toHaveLength(1);
77
+ expect(outputs[0].type).toBe("turn_request");
78
+ expect(outputs[0].data.messageId).toBe("msg_123");
79
+ expect(outputs[0].data.content).toBe("Hello");
80
+ expect(outputs[0].data.timestamp).toBe(1000);
81
+ });
82
+
83
+ it("should generate unique turn IDs", () => {
84
+ const [state1, outputs1] = turnTrackerProcessor(
85
+ state,
86
+ createUserMessageEvent("First", "msg_1")
87
+ );
88
+
89
+ // Complete the turn
90
+ const [state2] = turnTrackerProcessor(state1, createMessageStopEvent("end_turn"));
91
+
92
+ // Start new turn
93
+ const [state3, outputs3] = turnTrackerProcessor(
94
+ state2,
95
+ createUserMessageEvent("Second", "msg_2")
96
+ );
97
+
98
+ expect(outputs1[0].data.turnId).not.toBe(outputs3[0].data.turnId);
99
+ });
100
+
101
+ it("should handle empty content", () => {
102
+ const event = createUserMessageEvent("", "msg_empty");
103
+
104
+ const [newState, outputs] = turnTrackerProcessor(state, event);
105
+
106
+ expect(newState.pendingTurn?.content).toBe("");
107
+ expect(outputs[0].data.content).toBe("");
108
+ });
109
+ });
110
+
111
+ describe("message_stop event", () => {
112
+ describe("with pending turn", () => {
113
+ beforeEach(() => {
114
+ // Setup pending turn
115
+ state = {
116
+ ...state,
117
+ pendingTurn: {
118
+ turnId: "turn_123",
119
+ messageId: "msg_123",
120
+ content: "Test message",
121
+ requestedAt: 1000,
122
+ },
123
+ };
124
+ });
125
+
126
+ it("should complete turn and emit turn_response for end_turn", () => {
127
+ const event = createMessageStopEvent("end_turn", 2000);
128
+
129
+ const [newState, outputs] = turnTrackerProcessor(state, event);
130
+
131
+ expect(newState.pendingTurn).toBeNull();
132
+
133
+ expect(outputs).toHaveLength(1);
134
+ expect(outputs[0].type).toBe("turn_response");
135
+ expect(outputs[0].data.turnId).toBe("turn_123");
136
+ expect(outputs[0].data.messageId).toBe("msg_123");
137
+ expect(outputs[0].data.duration).toBe(1000); // 2000 - 1000
138
+ expect(outputs[0].data.timestamp).toBe(2000);
139
+ });
140
+
141
+ it("should complete turn for max_tokens", () => {
142
+ const event = createMessageStopEvent("max_tokens", 3000);
143
+
144
+ const [newState, outputs] = turnTrackerProcessor(state, event);
145
+
146
+ expect(newState.pendingTurn).toBeNull();
147
+ expect(outputs).toHaveLength(1);
148
+ expect(outputs[0].type).toBe("turn_response");
149
+ expect(outputs[0].data.duration).toBe(2000); // 3000 - 1000
150
+ });
151
+
152
+ it("should complete turn for stop_sequence", () => {
153
+ const event = createMessageStopEvent("stop_sequence", 1500);
154
+
155
+ const [newState, outputs] = turnTrackerProcessor(state, event);
156
+
157
+ expect(newState.pendingTurn).toBeNull();
158
+ expect(outputs).toHaveLength(1);
159
+ expect(outputs[0].type).toBe("turn_response");
160
+ expect(outputs[0].data.duration).toBe(500); // 1500 - 1000
161
+ });
162
+
163
+ it("should NOT complete turn for tool_use", () => {
164
+ const event = createMessageStopEvent("tool_use", 2000);
165
+
166
+ const [newState, outputs] = turnTrackerProcessor(state, event);
167
+
168
+ // Should keep pending turn
169
+ expect(newState.pendingTurn).not.toBeNull();
170
+ expect(newState.pendingTurn?.turnId).toBe("turn_123");
171
+
172
+ // Should not emit output
173
+ expect(outputs).toHaveLength(0);
174
+ });
175
+
176
+ it("should include usage information", () => {
177
+ const event = createMessageStopEvent("end_turn", 2000);
178
+
179
+ const [, outputs] = turnTrackerProcessor(state, event);
180
+
181
+ expect(outputs[0].data.usage).toBeDefined();
182
+ expect(outputs[0].data.usage.inputTokens).toBe(0);
183
+ expect(outputs[0].data.usage.outputTokens).toBe(0);
184
+ });
185
+ });
186
+
187
+ describe("without pending turn", () => {
188
+ it("should not emit output when no pending turn", () => {
189
+ const event = createMessageStopEvent("end_turn", 2000);
190
+
191
+ const [newState, outputs] = turnTrackerProcessor(state, event);
192
+
193
+ expect(newState).toEqual(state);
194
+ expect(outputs).toHaveLength(0);
195
+ });
196
+ });
197
+ });
198
+
199
+ describe("assistant_message event", () => {
200
+ it("should not emit output (handled in message_stop)", () => {
201
+ state = {
202
+ ...state,
203
+ pendingTurn: {
204
+ turnId: "turn_123",
205
+ messageId: "msg_123",
206
+ content: "Test",
207
+ requestedAt: 1000,
208
+ },
209
+ };
210
+
211
+ const event = createEvent("assistant_message", {
212
+ id: "msg_assistant",
213
+ role: "assistant",
214
+ content: [{ type: "text", text: "Response" }],
215
+ });
216
+
217
+ const [newState, outputs] = turnTrackerProcessor(state, event);
218
+
219
+ // State should be unchanged
220
+ expect(newState.pendingTurn).not.toBeNull();
221
+
222
+ // No output
223
+ expect(outputs).toHaveLength(0);
224
+ });
225
+ });
226
+
227
+ describe("unhandled events", () => {
228
+ it("should pass through unhandled events", () => {
229
+ const event = createEvent("text_delta", { text: "Hello" });
230
+
231
+ const [newState, outputs] = turnTrackerProcessor(state, event);
232
+
233
+ expect(newState).toEqual(state);
234
+ expect(outputs).toHaveLength(0);
235
+ });
236
+ });
237
+
238
+ describe("complete turn flow", () => {
239
+ it("should track complete turn from request to response", () => {
240
+ const allOutputs: TurnTrackerOutput[] = [];
241
+ let currentState = createInitialTurnTrackerState();
242
+
243
+ // User sends message at time 1000
244
+ const [s1, o1] = turnTrackerProcessor(
245
+ currentState,
246
+ createUserMessageEvent("What is 2+2?", "msg_1", 1000)
247
+ );
248
+ currentState = s1;
249
+ allOutputs.push(...o1);
250
+
251
+ // Assistant responds, message_stop at time 2500
252
+ const [s2, o2] = turnTrackerProcessor(currentState, createMessageStopEvent("end_turn", 2500));
253
+ currentState = s2;
254
+ allOutputs.push(...o2);
255
+
256
+ expect(allOutputs).toHaveLength(2);
257
+
258
+ // turn_request
259
+ expect(allOutputs[0].type).toBe("turn_request");
260
+ expect(allOutputs[0].data.content).toBe("What is 2+2?");
261
+
262
+ // turn_response
263
+ expect(allOutputs[1].type).toBe("turn_response");
264
+ expect(allOutputs[1].data.duration).toBe(1500);
265
+
266
+ // State should be clean
267
+ expect(currentState.pendingTurn).toBeNull();
268
+ });
269
+
270
+ it("should track turn with tool use (turn continues)", () => {
271
+ const allOutputs: TurnTrackerOutput[] = [];
272
+ let currentState = createInitialTurnTrackerState();
273
+
274
+ // User sends message
275
+ const [s1, o1] = turnTrackerProcessor(
276
+ currentState,
277
+ createUserMessageEvent("Search for X", "msg_1", 1000)
278
+ );
279
+ currentState = s1;
280
+ allOutputs.push(...o1);
281
+
282
+ // First message_stop with tool_use - turn should NOT complete
283
+ const [s2, o2] = turnTrackerProcessor(currentState, createMessageStopEvent("tool_use", 1500));
284
+ currentState = s2;
285
+ allOutputs.push(...o2);
286
+
287
+ expect(currentState.pendingTurn).not.toBeNull();
288
+ expect(allOutputs).toHaveLength(1); // Only turn_request
289
+
290
+ // Second message_stop with end_turn - turn completes
291
+ const [s3, o3] = turnTrackerProcessor(currentState, createMessageStopEvent("end_turn", 3000));
292
+ currentState = s3;
293
+ allOutputs.push(...o3);
294
+
295
+ expect(allOutputs).toHaveLength(2); // turn_request + turn_response
296
+ expect(allOutputs[1].type).toBe("turn_response");
297
+ expect(allOutputs[1].data.duration).toBe(2000); // 3000 - 1000
298
+ expect(currentState.pendingTurn).toBeNull();
299
+ });
300
+
301
+ it("should handle multiple consecutive turns", () => {
302
+ let currentState = createInitialTurnTrackerState();
303
+ const allTurnIds: string[] = [];
304
+
305
+ // First turn
306
+ const [s1, o1] = turnTrackerProcessor(
307
+ currentState,
308
+ createUserMessageEvent("First", "msg_1", 1000)
309
+ );
310
+ currentState = s1;
311
+ allTurnIds.push(o1[0].data.turnId);
312
+
313
+ const [s2] = turnTrackerProcessor(currentState, createMessageStopEvent("end_turn", 1500));
314
+ currentState = s2;
315
+
316
+ // Second turn
317
+ const [s3, o3] = turnTrackerProcessor(
318
+ currentState,
319
+ createUserMessageEvent("Second", "msg_2", 2000)
320
+ );
321
+ currentState = s3;
322
+ allTurnIds.push(o3[0].data.turnId);
323
+
324
+ const [s4] = turnTrackerProcessor(currentState, createMessageStopEvent("end_turn", 2500));
325
+ currentState = s4;
326
+
327
+ // Third turn
328
+ const [s5, o5] = turnTrackerProcessor(
329
+ currentState,
330
+ createUserMessageEvent("Third", "msg_3", 3000)
331
+ );
332
+ currentState = s5;
333
+ allTurnIds.push(o5[0].data.turnId);
334
+
335
+ // All turn IDs should be unique
336
+ const uniqueIds = new Set(allTurnIds);
337
+ expect(uniqueIds.size).toBe(3);
338
+ });
339
+ });
340
+ });