|
|
|
|
@@ -57,6 +57,23 @@ function buildKvServerMessagePayload(): Buffer {
|
|
|
|
|
return lenPrefixed(4, Buffer.alloc(0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// AgentServerMessage { exec_server_message (2): { id (1): 9, mcp_args (11): { tool_name (5): str } } }
|
|
|
|
|
function buildExecMcpPayload(): Buffer {
|
|
|
|
|
const mcpArgs = lenPrefixed(5, Buffer.from("magic_tool"));
|
|
|
|
|
const esm = Buffer.concat([tag(1, 0), v(9), lenPrefixed(11, mcpArgs)]);
|
|
|
|
|
return lenPrefixed(2, esm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Faithful model of driveH2's per-frame endReason teardown (cursor.ts): after
|
|
|
|
|
// each decoded frame a truthy endReason detaches listeners and stops reading,
|
|
|
|
|
// so any frame still buffered after it is dropped.
|
|
|
|
|
function driveFrames(ctx: StreamCtx, frames: Buffer[]): void {
|
|
|
|
|
for (const f of frames) {
|
|
|
|
|
processFrame(f, ctx, new Set());
|
|
|
|
|
if (ctx.endReason) return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// JSON error payload (Connect-RPC error envelope)
|
|
|
|
|
function buildJsonErrorPayload(): Buffer {
|
|
|
|
|
return Buffer.from(
|
|
|
|
|
@@ -117,14 +134,64 @@ test("processFrame accumulates token_delta", () => {
|
|
|
|
|
assert.equal(ctx.tokenDelta, 55);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("processFrame sets endReason on kv_server_message after text", () => {
|
|
|
|
|
const ctx = newStreamCtx("auto", () => {});
|
|
|
|
|
test("processFrame sets endReason on kv_server_message after text for composer models", () => {
|
|
|
|
|
// Composer family keeps the plain-chat short-circuit: KV is the verified
|
|
|
|
|
// early end-of-turn signal and a tool call never follows kv_after_text.
|
|
|
|
|
const ctx = newStreamCtx("cursor/composer-2.5", () => {});
|
|
|
|
|
processFrame(buildTextDeltaPayload("hi"), ctx, new Set());
|
|
|
|
|
processFrame(buildKvServerMessagePayload(), ctx, new Set());
|
|
|
|
|
assert.equal(ctx.endReason, "kv_after_text");
|
|
|
|
|
assert.equal(ctx.kvAfterTextSeen, true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("processFrame does not end turn on kv_server_message for non-composer models", () => {
|
|
|
|
|
// Non-composer models (cursor/grok-4.5-high, auto) emit the KV checkpoint as
|
|
|
|
|
// a blob-store side-channel frame with no turn-completion semantics — it can
|
|
|
|
|
// arrive mid-stream before a pending exec_mcp. It must never terminate here;
|
|
|
|
|
// only the real terminal signals (turn_ended / tool_call_completed) decide.
|
|
|
|
|
for (const model of ["cursor/grok-4.5-high", "auto"]) {
|
|
|
|
|
const ctx = newStreamCtx(model, () => {});
|
|
|
|
|
processFrame(buildTextDeltaPayload("hi"), ctx, new Set());
|
|
|
|
|
processFrame(buildKvServerMessagePayload(), ctx, new Set());
|
|
|
|
|
assert.equal(ctx.endReason, null, `model ${model} must not end on kv_after_text`);
|
|
|
|
|
assert.equal(ctx.kvAfterTextSeen, true, `model ${model} still observes the KV checkpoint`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("REGRESSION #10215: non-composer kv_after_text before exec_mcp must not drop the tool call", () => {
|
|
|
|
|
// text → kv_server_message → exec_mcp must still process the tool call:
|
|
|
|
|
// the KV checkpoint (with no turn semantics on this family) must not tear the
|
|
|
|
|
// frame loop down before the pending exec_mcp is decoded. Prior to the fix
|
|
|
|
|
// this left ctx.toolCalls=0 → finish_reason "stop" (narration-only truncation).
|
|
|
|
|
for (const model of ["cursor/grok-4.5-high", "auto"]) {
|
|
|
|
|
const ctx = newStreamCtx(model, () => {});
|
|
|
|
|
driveFrames(ctx, [
|
|
|
|
|
buildTextDeltaPayload("a long preamble before the tool call"),
|
|
|
|
|
buildKvServerMessagePayload(),
|
|
|
|
|
buildExecMcpPayload(),
|
|
|
|
|
]);
|
|
|
|
|
assert.equal(ctx.toolCalls.length, 1, `model ${model} must keep the pending tool call`);
|
|
|
|
|
assert.equal(ctx.endReason, "tool_calls", `model ${model} ends on the real tool signal`);
|
|
|
|
|
assert.equal(ctx.kvAfterTextSeen, true);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("REGRESSION #10215: long preamble (>2.5K chars) then KV then exec_mcp keeps the tool call", () => {
|
|
|
|
|
// Covers the at-risk band the reporter identified (2505-2933 chars of text
|
|
|
|
|
// before the tool call on cursor/grok-4.5-high). A KV checkpoint arriving
|
|
|
|
|
// mid-preamble must not truncate the still-pending exec_mcp.
|
|
|
|
|
const longPreamble =
|
|
|
|
|
"The model streams a lengthy preamble before invoking a tool. ".repeat(60);
|
|
|
|
|
assert.ok(longPreamble.length > 2500);
|
|
|
|
|
for (const model of ["cursor/grok-4.5-high", "auto"]) {
|
|
|
|
|
const ctx = newStreamCtx(model, () => {});
|
|
|
|
|
driveFrames(ctx, [buildTextDeltaPayload(longPreamble), buildKvServerMessagePayload(), buildExecMcpPayload()]);
|
|
|
|
|
assert.equal(ctx.toolCalls.length, 1, `model ${model} must keep the tool call`);
|
|
|
|
|
assert.equal(ctx.endReason, "tool_calls");
|
|
|
|
|
assert.ok(ctx.totalText.length > 2500);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("buildCursorUsage degrades to prompt-only counts for an empty response", () => {
|
|
|
|
|
// emitUsage now always emits on the success path (OpenAI streaming contract),
|
|
|
|
|
// relying on buildCursorUsage producing a valid usage object even when the
|
|
|
|
|
|