|
1 | | -import { describe, it, expect, afterEach, beforeEach } from "vitest"; |
| 1 | +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; |
2 | 2 | import * as http from "node:http"; |
3 | 3 | import type { Fixture } from "../types.js"; |
4 | 4 | import { createServer, type ServerInstance } from "../server.js"; |
@@ -937,6 +937,63 @@ describe("SSE event builders", () => { |
937 | 937 | expect((deltas[1].delta as Record<string, unknown>).text).toBe("DEF"); |
938 | 938 | expect((deltas[2].delta as Record<string, unknown>).text).toBe("GH"); |
939 | 939 | }); |
| 940 | + |
| 941 | + it("falls back to empty args and warns on malformed tool-call arguments (streaming)", () => { |
| 942 | + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); |
| 943 | + try { |
| 944 | + const events = buildInteractionsToolCallSSEEvents( |
| 945 | + [{ name: "fn", arguments: "not-json", id: "call_1" }], |
| 946 | + "aimock-int-0", |
| 947 | + new Logger("warn"), |
| 948 | + ); |
| 949 | + const argDelta = events.find( |
| 950 | + (e) => |
| 951 | + e.event_type === "step.delta" && |
| 952 | + (e.delta as Record<string, unknown>).type === "arguments_delta", |
| 953 | + )!; |
| 954 | + // Malformed args degrade to a valid empty-object fragment, not garbage. |
| 955 | + expect((argDelta.delta as Record<string, unknown>).arguments).toBe("{}"); |
| 956 | + expect(warnSpy).toHaveBeenCalled(); |
| 957 | + } finally { |
| 958 | + warnSpy.mockRestore(); |
| 959 | + } |
| 960 | + }); |
| 961 | + |
| 962 | + it("emits usage and requires_action status on interaction.completed for tool calls", () => { |
| 963 | + const events = buildInteractionsToolCallSSEEvents( |
| 964 | + [{ name: "fn", arguments: '{"a":1}', id: "call_1" }], |
| 965 | + "aimock-int-0", |
| 966 | + logger, |
| 967 | + { usage: { input_tokens: 3, output_tokens: 7 } }, |
| 968 | + ); |
| 969 | + const completed = events.find((e) => e.event_type === "interaction.completed")!; |
| 970 | + const interaction = completed.interaction as Record<string, unknown>; |
| 971 | + expect(interaction.status).toBe("requires_action"); |
| 972 | + expect(interaction.usage).toEqual({ |
| 973 | + total_input_tokens: 3, |
| 974 | + total_output_tokens: 7, |
| 975 | + total_tokens: 10, |
| 976 | + }); |
| 977 | + }); |
| 978 | + |
| 979 | + it("emits usage and requires_action status on interaction.completed for content+tools", () => { |
| 980 | + const events = buildInteractionsContentWithToolCallsSSEEvents( |
| 981 | + "Text", |
| 982 | + [{ name: "fn", arguments: "{}", id: "call_1" }], |
| 983 | + "aimock-int-0", |
| 984 | + 100, |
| 985 | + logger, |
| 986 | + { usage: { input_tokens: 2, output_tokens: 4 } }, |
| 987 | + ); |
| 988 | + const completed = events.find((e) => e.event_type === "interaction.completed")!; |
| 989 | + const interaction = completed.interaction as Record<string, unknown>; |
| 990 | + expect(interaction.status).toBe("requires_action"); |
| 991 | + expect(interaction.usage).toEqual({ |
| 992 | + total_input_tokens: 2, |
| 993 | + total_output_tokens: 4, |
| 994 | + total_tokens: 6, |
| 995 | + }); |
| 996 | + }); |
940 | 997 | }); |
941 | 998 |
|
942 | 999 | // ─── Integration tests: non-streaming ─────────────────────────────────── |
@@ -1155,6 +1212,10 @@ describe("Gemini Interactions — streaming", () => { |
1155 | 1212 | expect(argDeltas).toHaveLength(1); |
1156 | 1213 | const argsStr = (argDeltas[0].delta as Record<string, unknown>).arguments as string; |
1157 | 1214 | expect(JSON.parse(argsStr)).toEqual({ city: "NYC" }); |
| 1215 | + |
| 1216 | + // The streamed interaction terminates in requires_action for a tool call. |
| 1217 | + const completed = events.find((e) => e.event_type === "interaction.completed")!; |
| 1218 | + expect((completed.interaction as Record<string, unknown>).status).toBe("requires_action"); |
1158 | 1219 | }); |
1159 | 1220 |
|
1160 | 1221 | it("assigns correct indices for content+tools stream", async () => { |
@@ -1412,6 +1473,23 @@ describe("collapseGeminiInteractionsSSE", () => { |
1412 | 1473 | expect(result.firstDroppedSample).toMatch(/not valid JSON/); |
1413 | 1474 | }); |
1414 | 1475 |
|
| 1476 | + it("drops an arguments_delta that arrives before its step.start (ordering)", () => { |
| 1477 | + // The SDK emits step.start before its arguments_delta fragments; a fragment |
| 1478 | + // arriving first can't correlate, so it is flagged rather than mis-attributed. |
| 1479 | + const sse = [ |
| 1480 | + 'data: {"event_type":"step.delta","index":0,"delta":{"type":"arguments_delta","arguments":"{\\"x\\":1}"},"event_id":"evt_1"}', |
| 1481 | + 'data: {"event_type":"step.start","index":0,"step":{"type":"function_call","id":"call_1","name":"fn","arguments":{}},"event_id":"evt_2"}', |
| 1482 | + 'data: {"event_type":"step.stop","index":0,"event_id":"evt_3"}', |
| 1483 | + ].join("\n\n"); |
| 1484 | + const result = collapseGeminiInteractionsSSE(sse); |
| 1485 | + expect(result.droppedChunks).toBe(1); |
| 1486 | + expect(result.firstDroppedSample).toMatch(/no correlating step\.start/); |
| 1487 | + // The call still surfaces (identity preserved), opening with empty args. |
| 1488 | + expect(result.toolCalls).toHaveLength(1); |
| 1489 | + expect(result.toolCalls![0].name).toBe("fn"); |
| 1490 | + expect(result.toolCalls![0].arguments).toBe("{}"); |
| 1491 | + }); |
| 1492 | + |
1415 | 1493 | it("preserves identity of a function_call step.start that arrives without an index", () => { |
1416 | 1494 | const sse = [ |
1417 | 1495 | 'data: {"event_type":"step.start","step":{"type":"function_call","id":"call_1","name":"fn","arguments":{"x":1}},"event_id":"evt_1"}', |
|
0 commit comments