@vacbo/opencode-anthropic-fix 0.1.7 → 0.1.9
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/README.md +88 -88
- package/dist/opencode-anthropic-auth-cli.mjs +804 -507
- package/dist/opencode-anthropic-auth-plugin.js +4751 -4109
- package/package.json +67 -59
- package/src/__tests__/billing-edge-cases.test.ts +59 -59
- package/src/__tests__/bun-proxy.parallel.test.ts +388 -382
- package/src/__tests__/cc-comparison.test.ts +87 -87
- package/src/__tests__/cc-credentials.test.ts +254 -250
- package/src/__tests__/cch-drift-checker.test.ts +51 -51
- package/src/__tests__/cch-native-style.test.ts +56 -56
- package/src/__tests__/debug-gating.test.ts +42 -42
- package/src/__tests__/decomposition-smoke.test.ts +68 -68
- package/src/__tests__/fingerprint-regression.test.ts +575 -566
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +271 -271
- package/src/__tests__/helpers/conversation-history.ts +119 -119
- package/src/__tests__/helpers/deferred.smoke.test.ts +103 -103
- package/src/__tests__/helpers/deferred.ts +69 -69
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +155 -155
- package/src/__tests__/helpers/in-memory-storage.ts +88 -88
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +68 -68
- package/src/__tests__/helpers/mock-bun-proxy.ts +189 -189
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +273 -273
- package/src/__tests__/helpers/plugin-fetch-harness.ts +288 -288
- package/src/__tests__/helpers/sse.smoke.test.ts +236 -236
- package/src/__tests__/helpers/sse.ts +209 -209
- package/src/__tests__/index.parallel.test.ts +605 -595
- package/src/__tests__/sanitization-regex.test.ts +112 -112
- package/src/__tests__/state-bounds.test.ts +90 -90
- package/src/account-identity.test.ts +197 -192
- package/src/account-identity.ts +69 -67
- package/src/account-state.test.ts +86 -86
- package/src/account-state.ts +25 -25
- package/src/accounts/matching.test.ts +335 -0
- package/src/accounts/matching.ts +167 -0
- package/src/accounts/persistence.test.ts +345 -0
- package/src/accounts/persistence.ts +432 -0
- package/src/accounts/repair.test.ts +276 -0
- package/src/accounts/repair.ts +407 -0
- package/src/accounts.dedup.test.ts +621 -621
- package/src/accounts.test.ts +933 -929
- package/src/accounts.ts +633 -989
- package/src/backoff.test.ts +345 -345
- package/src/backoff.ts +219 -219
- package/src/betas.ts +124 -124
- package/src/bun-fetch.test.ts +345 -342
- package/src/bun-fetch.ts +424 -424
- package/src/bun-proxy.test.ts +25 -25
- package/src/bun-proxy.ts +209 -209
- package/src/cc-credentials.ts +111 -111
- package/src/circuit-breaker.test.ts +184 -184
- package/src/circuit-breaker.ts +169 -169
- package/src/cli/commands/auth.ts +963 -0
- package/src/cli/commands/config.ts +547 -0
- package/src/cli/formatting.test.ts +406 -0
- package/src/cli/formatting.ts +219 -0
- package/src/cli.ts +255 -2022
- package/src/commands/handlers/betas.ts +100 -0
- package/src/commands/handlers/config.ts +99 -0
- package/src/commands/handlers/files.ts +375 -0
- package/src/commands/oauth-flow.ts +181 -166
- package/src/commands/prompts.ts +61 -61
- package/src/commands/router.test.ts +421 -0
- package/src/commands/router.ts +143 -635
- package/src/config.test.ts +482 -482
- package/src/config.ts +412 -404
- package/src/constants.ts +48 -48
- package/src/drift/cch-constants.ts +95 -95
- package/src/env.ts +111 -105
- package/src/headers/billing.ts +33 -33
- package/src/headers/builder.ts +130 -130
- package/src/headers/cch.ts +75 -75
- package/src/headers/stainless.ts +25 -25
- package/src/headers/user-agent.ts +23 -23
- package/src/index.ts +436 -828
- package/src/models.ts +27 -27
- package/src/oauth.test.ts +102 -102
- package/src/oauth.ts +178 -178
- package/src/parent-pid-watcher.test.ts +148 -148
- package/src/parent-pid-watcher.ts +69 -69
- package/src/plugin-helpers.ts +82 -82
- package/src/refresh-helpers.ts +145 -139
- package/src/refresh-lock.test.ts +94 -94
- package/src/refresh-lock.ts +93 -93
- package/src/request/body.history.test.ts +579 -571
- package/src/request/body.ts +255 -255
- package/src/request/metadata.ts +65 -65
- package/src/request/retry.test.ts +156 -156
- package/src/request/retry.ts +67 -67
- package/src/request/url.ts +21 -21
- package/src/request-orchestration-helpers.ts +648 -0
- package/src/response/index.ts +5 -5
- package/src/response/mcp.ts +58 -58
- package/src/response/streaming.test.ts +313 -311
- package/src/response/streaming.ts +412 -410
- package/src/rotation.test.ts +304 -301
- package/src/rotation.ts +205 -205
- package/src/storage.test.ts +547 -547
- package/src/storage.ts +315 -291
- package/src/system-prompt/builder.ts +38 -38
- package/src/system-prompt/index.ts +5 -5
- package/src/system-prompt/normalize.ts +60 -60
- package/src/system-prompt/sanitize.ts +30 -30
- package/src/thinking.ts +21 -20
- package/src/token-refresh.test.ts +265 -265
- package/src/token-refresh.ts +219 -214
- package/src/types.ts +30 -30
- package/dist/bun-proxy.mjs +0 -291
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
4
|
+
chunkUtf8AtOffsets,
|
|
5
|
+
contentBlockDeltaEvent,
|
|
6
|
+
contentBlockStartEvent,
|
|
7
|
+
contentBlockStopEvent,
|
|
8
|
+
encodeSSEEvent,
|
|
9
|
+
encodeSSEStream,
|
|
10
|
+
makeSSEResponse,
|
|
11
|
+
messageDeltaEvent,
|
|
12
|
+
messageStartEvent,
|
|
13
|
+
messageStopEvent,
|
|
14
14
|
} from "../__tests__/helpers/sse.js";
|
|
15
15
|
import { StreamTruncatedError, transformResponse } from "./streaming.js";
|
|
16
16
|
|
|
@@ -18,365 +18,367 @@ const encoder = new TextEncoder();
|
|
|
18
18
|
const decoder = new TextDecoder();
|
|
19
19
|
|
|
20
20
|
function joinBlocks(...blocks: string[]): string {
|
|
21
|
-
|
|
21
|
+
return blocks.join("");
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function encodeToolUseStartEvent(
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
name: string,
|
|
26
|
+
input: Record<string, unknown> = { path: "/tmp/demo.txt" },
|
|
27
|
+
multiline = false,
|
|
28
28
|
): string {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
29
|
+
const payload = {
|
|
30
|
+
type: "content_block_start",
|
|
31
|
+
index: 0,
|
|
32
|
+
content_block: {
|
|
33
|
+
type: "tool_use",
|
|
34
|
+
id: "toolu_123",
|
|
35
|
+
name,
|
|
36
|
+
input,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return encodeSSEEvent({
|
|
41
|
+
data: multiline ? JSON.stringify(payload, null, 2) : JSON.stringify(payload),
|
|
42
|
+
});
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function encodeInputJsonDeltaEvent(partialJson: string): string {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
46
|
+
return encodeSSEEvent({
|
|
47
|
+
data: JSON.stringify({
|
|
48
|
+
type: "content_block_delta",
|
|
49
|
+
index: 0,
|
|
50
|
+
delta: {
|
|
51
|
+
type: "input_json_delta",
|
|
52
|
+
partial_json: partialJson,
|
|
53
|
+
},
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function makeChunkedSSEResponse(text: string, byteOffsets: number[]): Response {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
59
|
+
const chunks = chunkUtf8AtOffsets(text, byteOffsets);
|
|
60
|
+
|
|
61
|
+
return makeSSEResponse(
|
|
62
|
+
new ReadableStream<Uint8Array>({
|
|
63
|
+
start(controller) {
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
controller.enqueue(chunk);
|
|
66
|
+
}
|
|
67
|
+
controller.close();
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function makeSSEFromByteChunks(chunks: Uint8Array[], onCancel?: (reason: unknown) => void): Response {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
return makeSSEResponse(
|
|
75
|
+
new ReadableStream<Uint8Array>({
|
|
76
|
+
start(controller) {
|
|
77
|
+
for (const chunk of chunks) {
|
|
78
|
+
controller.enqueue(chunk);
|
|
79
|
+
}
|
|
80
|
+
controller.close();
|
|
81
|
+
},
|
|
82
|
+
cancel(reason) {
|
|
83
|
+
onCancel?.(reason);
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
function createControlledSSE(
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
chunks: Uint8Array[],
|
|
91
|
+
onCancel?: (reason: unknown) => void,
|
|
92
92
|
): {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
93
|
+
response: Response;
|
|
94
|
+
emit(index: number): void;
|
|
95
|
+
close(): void;
|
|
96
96
|
} {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
97
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
98
|
+
|
|
99
|
+
const response = makeSSEResponse(
|
|
100
|
+
new ReadableStream<Uint8Array>({
|
|
101
|
+
start(innerController) {
|
|
102
|
+
controller = innerController;
|
|
103
|
+
},
|
|
104
|
+
cancel(reason) {
|
|
105
|
+
onCancel?.(reason);
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
response,
|
|
112
|
+
emit(index: number) {
|
|
113
|
+
controller?.enqueue(chunks[index]!);
|
|
114
|
+
},
|
|
115
|
+
close() {
|
|
116
|
+
controller?.close();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
async function getPromiseState<T>(promise: Promise<T>): Promise<"pending" | "fulfilled" | "rejected"> {
|
|
122
|
-
|
|
122
|
+
let state: "pending" | "fulfilled" | "rejected" = "pending";
|
|
123
123
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
124
|
+
void promise.then(
|
|
125
|
+
() => {
|
|
126
|
+
state = "fulfilled";
|
|
127
|
+
},
|
|
128
|
+
() => {
|
|
129
|
+
state = "rejected";
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
await Promise.resolve();
|
|
134
|
+
await Promise.resolve();
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
return state;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
describe("transformResponse RED - SSE termination and framing", () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
it("preserves multi-byte UTF-8 payloads across chunk boundaries before message_stop", async () => {
|
|
155
|
-
const stream = joinBlocks(
|
|
156
|
-
encodeSSEStream([messageStartEvent()]),
|
|
157
|
-
encodeToolUseStartEvent(
|
|
158
|
-
"mcp_unicode_lookup",
|
|
159
|
-
{
|
|
160
|
-
path: "/tmp/café-🎉.txt",
|
|
161
|
-
note: "привет мир",
|
|
162
|
-
},
|
|
163
|
-
true,
|
|
164
|
-
),
|
|
165
|
-
encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
|
|
166
|
-
);
|
|
167
|
-
|
|
168
|
-
const text = await transformResponse(makeChunkedSSEResponse(stream, [1, 2, 3, 5, 8, 13, 21, 34])).text();
|
|
140
|
+
it("accepts a multiline tool_use stream only when the final event is message_stop", async () => {
|
|
141
|
+
const stream = joinBlocks(
|
|
142
|
+
encodeSSEStream([messageStartEvent()]),
|
|
143
|
+
encodeToolUseStartEvent("mcp_read_file", { path: "/tmp/demo.txt" }, true),
|
|
144
|
+
encodeSSEStream([contentBlockStopEvent(0), messageDeltaEvent(), messageStopEvent()]),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const text = await transformResponse(makeSSEResponse(stream)).text();
|
|
148
|
+
|
|
149
|
+
expect(text).toContain('"type": "message_stop"');
|
|
150
|
+
expect(text).toContain('"name": "read_file"');
|
|
151
|
+
expect(text).not.toContain("mcp_read_file");
|
|
152
|
+
});
|
|
169
153
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
154
|
+
it("preserves multi-byte UTF-8 payloads across chunk boundaries before message_stop", async () => {
|
|
155
|
+
const stream = joinBlocks(
|
|
156
|
+
encodeSSEStream([messageStartEvent()]),
|
|
157
|
+
encodeToolUseStartEvent(
|
|
158
|
+
"mcp_unicode_lookup",
|
|
159
|
+
{
|
|
160
|
+
path: "/tmp/café-🎉.txt",
|
|
161
|
+
note: "привет мир",
|
|
162
|
+
},
|
|
163
|
+
true,
|
|
164
|
+
),
|
|
165
|
+
encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const text = await transformResponse(makeChunkedSSEResponse(stream, [1, 2, 3, 5, 8, 13, 21, 34])).text();
|
|
169
|
+
|
|
170
|
+
expect(text).toContain("café-🎉.txt");
|
|
171
|
+
expect(text).toContain("привет мир");
|
|
172
|
+
expect(text).toContain('"name": "unicode_lookup"');
|
|
173
|
+
});
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
175
|
+
it("rewrites multiline data payloads as one event block instead of line-by-line", async () => {
|
|
176
|
+
const stream = joinBlocks(
|
|
177
|
+
encodeToolUseStartEvent("mcp_shell_exec", { command: "ls -la" }, true),
|
|
178
|
+
encodeSSEStream([messageStopEvent()]),
|
|
179
|
+
);
|
|
180
180
|
|
|
181
|
-
|
|
181
|
+
const text = await transformResponse(makeSSEResponse(stream)).text();
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
expect(text).toContain('"name": "shell_exec"');
|
|
184
|
+
expect(text).not.toContain("mcp_shell_exec");
|
|
185
|
+
});
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
187
|
+
it("does not emit rewritten bytes until a split event block is complete", async () => {
|
|
188
|
+
const block = encodeToolUseStartEvent("mcp_read_file", { path: "/tmp/demo.txt" }, true);
|
|
189
|
+
const splitPoint = block.indexOf("\n") + 1;
|
|
190
|
+
const chunks = [encoder.encode(block.slice(0, splitPoint)), encoder.encode(block.slice(splitPoint))];
|
|
191
|
+
const controlled = createControlledSSE(chunks);
|
|
192
192
|
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
const transformed = transformResponse(controlled.response);
|
|
194
|
+
const reader = transformed.body!.getReader();
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
controlled.emit(0);
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
const firstRead = reader.read();
|
|
199
|
+
expect(await getPromiseState(firstRead)).toBe("pending");
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
controlled.emit(1);
|
|
202
|
+
controlled.close();
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
204
|
+
const resolved = await firstRead;
|
|
205
|
+
expect(decoder.decode(resolved.value)).toContain('"name": "read_file"');
|
|
206
|
+
});
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
describe("transformResponse RED - truncation and validation", () => {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
210
|
+
it("rejects a truncated stream that ends after message_delta without message_stop", async () => {
|
|
211
|
+
const stream = encodeSSEStream([
|
|
212
|
+
messageStartEvent(),
|
|
213
|
+
contentBlockStartEvent(0),
|
|
214
|
+
contentBlockDeltaEvent(0, "hello"),
|
|
215
|
+
contentBlockStopEvent(0),
|
|
216
|
+
messageDeltaEvent(),
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
const error = await transformResponse(makeSSEResponse(stream))
|
|
220
|
+
.text()
|
|
221
|
+
.catch((streamError: unknown) => streamError);
|
|
222
|
+
|
|
223
|
+
expect(error).toBeInstanceOf(StreamTruncatedError);
|
|
224
|
+
expect(error).toBeInstanceOf(Error);
|
|
225
|
+
expect((error as StreamTruncatedError).message).toMatch(/message_stop|truncated/i);
|
|
226
|
+
expect((error as StreamTruncatedError).context).toMatchObject({
|
|
227
|
+
inFlightEvent: "message_delta",
|
|
228
|
+
lastEventType: "message_delta",
|
|
229
|
+
});
|
|
229
230
|
});
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/truncated|terminator/i);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("rejects an event:error terminator with a descriptive stream failure", async () => {
|
|
241
|
-
const errorTerminator = encodeSSEEvent({
|
|
242
|
-
event: "error",
|
|
243
|
-
data: JSON.stringify({
|
|
244
|
-
type: "error",
|
|
245
|
-
error: {
|
|
246
|
-
type: "stream_error",
|
|
247
|
-
message: "stream aborted by upstream",
|
|
248
|
-
},
|
|
249
|
-
}),
|
|
231
|
+
|
|
232
|
+
it("rejects a final message_stop event that is missing its terminating blank line", async () => {
|
|
233
|
+
const completeStop = encodeSSEStream([messageStopEvent()]);
|
|
234
|
+
const truncatedStop = completeStop.slice(0, -2);
|
|
235
|
+
const stream = joinBlocks(encodeSSEStream([messageStartEvent()]), truncatedStop);
|
|
236
|
+
|
|
237
|
+
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/truncated|terminator/i);
|
|
250
238
|
});
|
|
251
|
-
const stream = joinBlocks(encodeSSEStream([messageStartEvent()]), errorTerminator);
|
|
252
239
|
|
|
253
|
-
|
|
254
|
-
|
|
240
|
+
it("rejects an event:error terminator with a descriptive stream failure", async () => {
|
|
241
|
+
const errorTerminator = encodeSSEEvent({
|
|
242
|
+
event: "error",
|
|
243
|
+
data: JSON.stringify({
|
|
244
|
+
type: "error",
|
|
245
|
+
error: {
|
|
246
|
+
type: "stream_error",
|
|
247
|
+
message: "stream aborted by upstream",
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
const stream = joinBlocks(encodeSSEStream([messageStartEvent()]), errorTerminator);
|
|
252
|
+
|
|
253
|
+
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/stream aborted by upstream/i);
|
|
254
|
+
});
|
|
255
255
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
256
|
+
it("rejects malformed JSON event blocks instead of silently ignoring them", async () => {
|
|
257
|
+
const malformed = joinBlocks(
|
|
258
|
+
encodeSSEStream([messageStartEvent()]),
|
|
259
|
+
'event: message\ndata: {"type":"content_block_start","index":0,\n\n',
|
|
260
|
+
encodeSSEStream([messageStopEvent()]),
|
|
261
|
+
);
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
await expect(transformResponse(makeSSEResponse(malformed)).text()).rejects.toThrow(/malformed|invalid sse/i);
|
|
264
|
+
});
|
|
265
265
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
266
|
+
it("rejects orphan content_block_delta events", async () => {
|
|
267
|
+
const stream = encodeSSEStream([
|
|
268
|
+
messageStartEvent(),
|
|
269
|
+
contentBlockDeltaEvent(0, "orphan delta"),
|
|
270
|
+
messageStopEvent(),
|
|
271
|
+
]);
|
|
272
272
|
|
|
273
|
-
|
|
274
|
-
|
|
273
|
+
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/orphan|content_block_delta/i);
|
|
274
|
+
});
|
|
275
275
|
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
it("rejects orphan content_block_stop events", async () => {
|
|
277
|
+
const stream = encodeSSEStream([messageStartEvent(), contentBlockStopEvent(0), messageStopEvent()]);
|
|
278
278
|
|
|
279
|
-
|
|
280
|
-
|
|
279
|
+
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(/orphan|content_block_stop/i);
|
|
280
|
+
});
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
282
|
+
it("rejects a truncated tool_use block at EOF", async () => {
|
|
283
|
+
const stream = encodeSSEStream([
|
|
284
|
+
messageStartEvent(),
|
|
285
|
+
contentBlockStartEvent(0, {
|
|
286
|
+
content_block: {
|
|
287
|
+
type: "tool_use",
|
|
288
|
+
id: "toolu_123",
|
|
289
|
+
name: "mcp_read_file",
|
|
290
|
+
input: { path: "/tmp/demo.txt" },
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
messageDeltaEvent(),
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
const error = await transformResponse(makeSSEResponse(stream))
|
|
297
|
+
.text()
|
|
298
|
+
.catch((streamError: unknown) => streamError);
|
|
299
|
+
|
|
300
|
+
expect(error).toBeInstanceOf(StreamTruncatedError);
|
|
301
|
+
expect((error as StreamTruncatedError).message).toMatch(/truncated/i);
|
|
302
|
+
expect((error as StreamTruncatedError).context).toMatchObject({
|
|
303
|
+
inFlightEvent: "content_block_start(tool_use)",
|
|
304
|
+
openContentBlockIndex: 0,
|
|
305
|
+
hasPartialJson: false,
|
|
306
|
+
});
|
|
306
307
|
});
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
it("rejects incomplete input_json_delta tool payloads even if message_stop arrives", async () => {
|
|
310
|
-
const stream = joinBlocks(
|
|
311
|
-
encodeSSEStream([messageStartEvent()]),
|
|
312
|
-
encodeSSEStream([
|
|
313
|
-
contentBlockStartEvent(0, {
|
|
314
|
-
content_block: {
|
|
315
|
-
type: "tool_use",
|
|
316
|
-
id: "toolu_123",
|
|
317
|
-
name: "mcp_read_file",
|
|
318
|
-
input: {},
|
|
319
|
-
},
|
|
320
|
-
}),
|
|
321
|
-
]),
|
|
322
|
-
encodeInputJsonDeltaEvent('{"path":"/tmp/demo.txt"'),
|
|
323
|
-
encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
|
|
324
|
-
);
|
|
325
308
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
309
|
+
it("rejects incomplete input_json_delta tool payloads even if message_stop arrives", async () => {
|
|
310
|
+
const stream = joinBlocks(
|
|
311
|
+
encodeSSEStream([messageStartEvent()]),
|
|
312
|
+
encodeSSEStream([
|
|
313
|
+
contentBlockStartEvent(0, {
|
|
314
|
+
content_block: {
|
|
315
|
+
type: "tool_use",
|
|
316
|
+
id: "toolu_123",
|
|
317
|
+
name: "mcp_read_file",
|
|
318
|
+
input: {},
|
|
319
|
+
},
|
|
320
|
+
}),
|
|
321
|
+
]),
|
|
322
|
+
encodeInputJsonDeltaEvent('{"path":"/tmp/demo.txt"'),
|
|
323
|
+
encodeSSEStream([contentBlockStopEvent(0), messageStopEvent()]),
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
await expect(transformResponse(makeSSEResponse(stream)).text()).rejects.toThrow(
|
|
327
|
+
/partial_json|incomplete tool_use/i,
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
330
|
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
it("rejects oversized unterminated event buffers before they grow unbounded", async () => {
|
|
332
|
+
const oversized = `data: ${"x".repeat(256 * 1024)}`;
|
|
333
333
|
|
|
334
|
-
|
|
335
|
-
|
|
334
|
+
await expect(transformResponse(makeSSEResponse(oversized)).text()).rejects.toThrow(
|
|
335
|
+
/buffer|limit|unterminated/i,
|
|
336
|
+
);
|
|
337
|
+
});
|
|
336
338
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
339
|
+
it("rejects malformed UTF-8 buffered at EOF during the final decoder flush", async () => {
|
|
340
|
+
const validStream = encoder.encode(encodeSSEStream([messageStartEvent(), messageStopEvent()]));
|
|
341
|
+
const invalidTail = new Uint8Array([0xc3]);
|
|
342
|
+
const response = makeSSEFromByteChunks([validStream, invalidTail]);
|
|
341
343
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
await expect(transformResponse(response).text()).rejects.toThrow(/utf-8|decoder|malformed/i);
|
|
345
|
+
});
|
|
344
346
|
});
|
|
345
347
|
|
|
346
348
|
describe("transformResponse RED - stream control", () => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
it("bypasses the SSE transform path for non-event-stream responses", () => {
|
|
367
|
-
const response = new Response(
|
|
368
|
-
JSON.stringify({
|
|
369
|
-
content: [{ type: "tool_use", name: "mcp_read_file" }],
|
|
370
|
-
}),
|
|
371
|
-
{
|
|
372
|
-
headers: {
|
|
373
|
-
"content-type": "application/json",
|
|
374
|
-
},
|
|
375
|
-
},
|
|
376
|
-
);
|
|
377
|
-
|
|
378
|
-
const transformed = transformResponse(response);
|
|
349
|
+
it("propagates cancel() to the upstream body reader", async () => {
|
|
350
|
+
const cancelSpy = vi.fn();
|
|
351
|
+
const response = makeSSEResponse(
|
|
352
|
+
new ReadableStream<Uint8Array>({
|
|
353
|
+
pull() {
|
|
354
|
+
return Promise.resolve();
|
|
355
|
+
},
|
|
356
|
+
cancel(reason) {
|
|
357
|
+
cancelSpy(reason);
|
|
358
|
+
},
|
|
359
|
+
}),
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const transformed = transformResponse(response);
|
|
363
|
+
await transformed.body!.cancel("stop-now");
|
|
364
|
+
|
|
365
|
+
expect(cancelSpy).toHaveBeenCalledWith("stop-now");
|
|
366
|
+
});
|
|
379
367
|
|
|
380
|
-
|
|
381
|
-
|
|
368
|
+
it("bypasses the SSE transform path for non-event-stream responses", () => {
|
|
369
|
+
const response = new Response(
|
|
370
|
+
JSON.stringify({
|
|
371
|
+
content: [{ type: "tool_use", name: "mcp_read_file" }],
|
|
372
|
+
}),
|
|
373
|
+
{
|
|
374
|
+
headers: {
|
|
375
|
+
"content-type": "application/json",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const transformed = transformResponse(response);
|
|
381
|
+
|
|
382
|
+
expect(transformed).toBe(response);
|
|
383
|
+
});
|
|
382
384
|
});
|