fix(kiro): validate terminal streams before emitting output
Validate AWS EventStream framing, header bounds, CRCs, error frames, and terminal stop metadata before exposing Kiro output. Classify stop reasons into dispositions (complete / retryable / terminal_incomplete / refusal) and retry once when the stream ends with a malformed tool call, ellipsis-only output, or a short future-action sentence. Fail closed: propagate streaming failures as error SSE (502) instead of collapsing them into a successful stop, so incomplete responses no longer leak as final answers. Detect the observed evidence-prefixed trailing progress final without broadening the Chinese heuristic to completed findings.
This commit is contained in:
@@ -32,10 +32,23 @@ function createMockFrame(eventType, payloadObj) {
|
||||
offset += headerValueBytes.length;
|
||||
|
||||
buffer.set(payloadBytes, offset);
|
||||
|
||||
|
||||
view.setUint32(8, crc32(buffer.subarray(0, 8)), false);
|
||||
view.setUint32(totalLength - 4, crc32(buffer.subarray(0, totalLength - 4)), false);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function crc32(bytes) {
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
||||
}
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
async function readAllSSE(stream) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -130,14 +143,16 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
expect(contentChunks.length).toBe(0);
|
||||
});
|
||||
|
||||
it("emits a terminal chunk at messageStop before the upstream stream closes", async () => {
|
||||
it("waits for clean EOF before emitting stop after messageStop", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("assistantResponseEvent", { content: "OK" });
|
||||
const f2 = createMockFrame("messageStopEvent", {});
|
||||
|
||||
let upstreamController;
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
upstreamController = controller;
|
||||
controller.enqueue(f1);
|
||||
controller.enqueue(f2);
|
||||
}
|
||||
@@ -147,11 +162,16 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
const reader = transformedResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
for (let i = 0; i < 4 && !output.includes("\"finish_reason\":\"stop\""); i++) {
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
expect(output).not.toContain("\"finish_reason\":\"stop\"");
|
||||
|
||||
upstreamController.close();
|
||||
while (!output.includes("\"finish_reason\":\"stop\"")) {
|
||||
const { value: nextValue, done } = await readNextWithTimeout(reader);
|
||||
if (done) break;
|
||||
output += decoder.decode(nextValue, { stream: true });
|
||||
}
|
||||
await reader.cancel();
|
||||
|
||||
expect(output).toContain("\"finish_reason\":\"stop\"");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user