fix(consult): paced settle is opt-in for the consultation reply; one rejection path for HTTP bodies and stream events (T2/T3 follow-up)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 09:28:37 +08:00
co-authored by Claude Fable 5.1
parent 0bc6659052
commit 891e9c62da
6 changed files with 50 additions and 40 deletions
@@ -42,7 +42,10 @@ test("in-app destinations navigate client-side so the chat survives the round tr
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
assert.match(pageSource, /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
// 新值: 同一句在 rejectRun 里,HTTP 体与流内 request_rejected 事件共用(状态从参数来)
// 原因: TASK-consult-first-frame-and-pacing-20260928 D2 / BUG-1074:开流后拒绝改走事件,调用点数不变(下面仍数到 2)
assert.match(pageSource, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
// And: membership stays in-page; leaving chat is a client-side <Link> now.
assert.doesNotMatch(pageSource, /window\.location\.assign\("\/reports"\)/);
@@ -64,8 +64,9 @@ test("the consultation hook paces the write-out only for a finished run; stop, f
// The stream ended normally: paced unless the user stopped it or the server cut it.
assert.match(hook, /settleFrames\(controller\.signal\.aborted \|\| Boolean\(truncatedFailure\)\);/);
assert.match(hook, /settleFrames\(controller\.signal\.aborted\);/);
// Failure path keeps what arrived, at once.
assert.match(hook, /settleRequested = false;\s*void frames\.settle\(\{ immediate: true \}\);/);
// Failure path keeps what arrived, at once; only the finished consultation reply opts into pacing.
assert.match(hook, /settleRequested = false;\s*void frames\.settle\(\);/);
assert.equal((hook.match(/frames\.settle\(\{ paced: true \}\)/g) ?? []).length, 1);
// The paced write-out owns the display through the streaming reply's `settling` id;
// the interface completes without waiting for it and without clearing it.
assert.match(hook, /setStreamingReply\(\(current\) => \(current\?\.settling \? current : null\)\);/);
@@ -165,8 +166,10 @@ test("③ a JSON rejection becomes one run.failed request_rejected with the same
// Same precedence the client applied to the HTTP body: recovery, then message, then error.
const generation = consultationRejectionEvent(503, { error: "暂时无法生成解读", message: "咨询服务暂时不可用,请稍后再试。", recovery: "稍后重试,或换一个模型继续。" });
assert.equal(generation.type === "run.failed" && generation.message, "稍后重试,或换一个模型继续。");
assert.equal(consultationRejectionEvent(200, {}).type === "run.failed" && consultationRejectionEvent(200, {}).status, 503);
assert.equal(consultationRejectionEvent(503, null).type === "run.failed" && consultationRejectionEvent(503, null).message, STREAM_FIRST_FALLBACK_MESSAGE);
const outOfRange = consultationRejectionEvent(200, {});
assert.equal(outOfRange.type === "run.failed" ? outOfRange.status : undefined, 503);
const empty = consultationRejectionEvent(503, null);
assert.equal(empty.type === "run.failed" ? empty.message : undefined, STREAM_FIRST_FALLBACK_MESSAGE);
for (const event of [insufficient, full, generation]) consultationAgentPublicEventSchema.parse(event);
const response = streamFirstResponse({
@@ -291,8 +294,11 @@ test("the client reads smalltalk and rejections from events, with the same statu
assert.doesNotMatch(hook, /x-jyotish-response-kind/);
assert.match(hook, /if \(event\.responseKind === "smalltalk"\) \{\s*responseKind = "smalltalk";/);
assert.match(hook, /event\.type === "run\.failed" && event\.code === "request_rejected"/);
assert.match(hook, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
assert.match(hook, /throw new ConsultationResponseError\(status, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/);
assert.match(hook, /const rejectRun = \(status: number, message: string, code\?: string\): never => \{\s*if \(status === 401\) window\.location\.assign\("\/login"\);\s*if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
assert.match(hook, /rejectRun\(response\.status, payloadMessage\(errorPayload, "服务暂时不可用"\), payloadCode\(errorPayload\)\);/);
assert.match(hook, /rejectRun\(event\.status \?\? 503, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/);
// One redirect and one billing prompt for both paths (chat-navigation-a11y-contract counts them).
assert.equal((hook.match(/openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/g) ?? []).length, 2);
// The rollback / notice handling keyed on status and code is unchanged.
assert.match(hook, /caught\.code === "session_full"/);
assert.match(hook, /const reserveDidNotCommit = caught\.status === 400\s*\|\| caught\.status === 401\s*\|\| caught\.status === 402\s*\|\| caught\.status === 409;/);
+7 -7
View File
@@ -116,11 +116,11 @@ test("many events collapse into one flush per frame and a paced settle writes th
assert.ok(flushes.at(-1)!.answer.length < 200, "pacing is still behind the network");
// 原值: buffer.settle() 同步一帧放完剩余文字(settled: true 立即到)
// 新值: 默认 settle 按打字节奏在 STREAM_SETTLE_MAX_FRAMES 内写完,最后一帧才 settled: true;immediate 才一帧放完
// 原因: TASK-consult-first-frame-and-pacing-20260928 D3 / BUG-1075:流尾一帧全放让短回答「一下全出来」
// 新值: settle({ paced: true }) 按打字节奏在 STREAM_SETTLE_MAX_FRAMES 内写完,最后一帧才 settled: true;无参 settle() 仍一帧放完(下一条测试)
// 原因: TASK-consult-first-frame-and-pacing-20260928 D3 / BUG-1075:流尾一帧全放让短回答「一下全出来」;其他调用方(校正面的快照合并)保持旧契约
const pendingAtSettle = answer.length - flushes.at(-1)!.answer.length;
let resolved = false;
void buffer.settle().then(() => { resolved = true; });
void buffer.settle({ paced: true }).then(() => { resolved = true; });
assert.equal(flushes.at(-1)!.settled, false, "a paced settle does not flush synchronously");
let settleFrames = 0;
while (fake.scheduledFrames > 0 && settleFrames < 200) {
@@ -152,7 +152,7 @@ test("typing pace: an ordinary backlog is capped at four characters a frame, a l
assert.equal(frames, 160 / STREAM_RELEASE_MAX_CHARS, "a 160-character lump is typed out over 40 frames, not 12");
});
test("a paced settle writes 300 leftover characters within the budget; immediate, stop-style settle is one flush", () => {
test("a paced settle writes 300 leftover characters within the budget; the default settle is one flush", () => {
const fake = fakeScheduler();
const flushes: StreamFrameSnapshot<null>[] = [];
const buffer = createStreamFrameBuffer<null>({
@@ -166,7 +166,7 @@ test("a paced settle writes 300 leftover characters within the budget; immediate
const shownBefore = flushes.at(-1)!.answer.length;
assert.ok(shownBefore < 300);
void buffer.settle();
void buffer.settle({ paced: true });
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 500) {
fake.tick();
@@ -187,7 +187,7 @@ test("a paced settle writes 300 leftover characters within the budget; immediate
});
immediate.setAnswer(text);
const before = flushes.length;
void immediate.settle({ immediate: true });
void immediate.settle();
assert.equal(flushes.length, before + 1);
assert.equal(flushes.at(-1)!.answer, text);
assert.equal(flushes.at(-1)!.settled, true);
@@ -205,7 +205,7 @@ test("dispose during a paced settle lets it finish, then silences the buffer", (
const text = "字".repeat(40);
buffer.setAnswer(text);
fake.tick();
void buffer.settle();
void buffer.settle({ paced: true });
buffer.dispose();
let frames = 0;
while (fake.scheduledFrames > 0 && frames < 100) {