fix(consult): 降级正文过 Pass 4,去重并停止空跑 compose(BUG-959/960/961)
降级交付复用 releasePass4Sentences;每次 attempt 清空 uncontractedText;成功降级后跳过 compose。
This commit is contained in:
@@ -466,6 +466,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
stream: ChunkStream,
|
||||
attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
|
||||
) {
|
||||
// Each attempt owns its own uncontracted buffer. Accumulating across the
|
||||
// contract retry delivered the first draft and the retry as one answer.
|
||||
uncontractedText = "";
|
||||
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
|
||||
let held = "";
|
||||
let composingSent = Boolean(attempt.suppressCompositionActivity);
|
||||
@@ -687,6 +690,46 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deliverDegradedAnswer(
|
||||
controller: ReadableStreamDefaultController<Uint8Array> | undefined,
|
||||
) {
|
||||
const origin = fullOutput;
|
||||
if (options.pass4Mode) {
|
||||
await releasePass4Sentences(controller, uncontractedText, false);
|
||||
await releasePass4Sentences(controller, "", true);
|
||||
} else if (/\S/.test(uncontractedText)) {
|
||||
if (!firstOutput) {
|
||||
firstOutput = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
send(controller, { type: "answer.delta", text: uncontractedText });
|
||||
fullOutput += uncontractedText;
|
||||
emitted = true;
|
||||
}
|
||||
if (!/\S/.test(fullOutput.slice(origin.length))) {
|
||||
if (options.pass4Mode === "general_no_birth_time") {
|
||||
if (!firstOutput) {
|
||||
firstOutput = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
send(controller, { type: "answer.delta", text: GENERAL_NO_BIRTH_TIME_REFUSAL });
|
||||
fullOutput += GENERAL_NO_BIRTH_TIME_REFUSAL;
|
||||
emitted = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: CONTRACT_DEGRADED_STEP,
|
||||
status: "failed",
|
||||
});
|
||||
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
|
||||
fullOutput += CONTRACT_DEGRADED_NOTE;
|
||||
emitted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const sideEvent = options.sideEvent
|
||||
@@ -700,6 +743,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
for (const event of skillBoundEvents) send(controller, event);
|
||||
flushThinkingPlan(controller);
|
||||
try {
|
||||
let deliveredDegraded = false;
|
||||
await consumeAttempt(controller, options.stream, {
|
||||
drainSpoken: Boolean(options.composeAnswer),
|
||||
});
|
||||
@@ -715,21 +759,9 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
&& options.state.consultationToolSuccessCount === 0
|
||||
&& /\S/.test(uncontractedText);
|
||||
if (canDegrade) {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: CONTRACT_DEGRADED_STEP,
|
||||
status: "failed",
|
||||
});
|
||||
if (!firstOutput) {
|
||||
firstOutput = true;
|
||||
await options.onFirstOutput?.();
|
||||
}
|
||||
send(controller, { type: "answer.delta", text: uncontractedText });
|
||||
fullOutput += uncontractedText;
|
||||
send(controller, { type: "answer.delta", text: CONTRACT_DEGRADED_NOTE });
|
||||
fullOutput += CONTRACT_DEGRADED_NOTE;
|
||||
emitted = true;
|
||||
} else {
|
||||
deliveredDegraded = await deliverDegradedAnswer(controller);
|
||||
}
|
||||
if (!deliveredDegraded) {
|
||||
appendConsultationRuntimeStep(options.state, {
|
||||
kind: "validation",
|
||||
name: RUNTIME_CONTRACT_INCOMPLETE_STEP,
|
||||
@@ -738,18 +770,20 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
throw new Error("runtime_contract_incomplete");
|
||||
}
|
||||
}
|
||||
const findings = await publishFindings(controller);
|
||||
const composed = await composeOnce(controller, findings);
|
||||
if (!composed) {
|
||||
await continueCurrentAnswer(controller);
|
||||
if (options.pass4Mode) await finishPass4(controller, "", findings);
|
||||
}
|
||||
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
|
||||
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
|
||||
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
|
||||
const retryOrigin = fullOutput;
|
||||
await consumeAttempt(controller, await options.retryForAnswer());
|
||||
if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false);
|
||||
if (!deliveredDegraded) {
|
||||
const findings = await publishFindings(controller);
|
||||
const composed = await composeOnce(controller, findings);
|
||||
if (!composed) {
|
||||
await continueCurrentAnswer(controller);
|
||||
if (options.pass4Mode) await finishPass4(controller, "", findings);
|
||||
}
|
||||
if (!/\S/.test(fullOutput) && options.retryForAnswer) {
|
||||
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-retry", status: "completed" });
|
||||
send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
|
||||
const retryOrigin = fullOutput;
|
||||
await consumeAttempt(controller, await options.retryForAnswer());
|
||||
if (options.pass4Mode) await finishPass4(controller, retryOrigin, findings, false);
|
||||
}
|
||||
}
|
||||
if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
|
||||
settling = true;
|
||||
|
||||
@@ -1279,6 +1279,7 @@ test("incomplete runtime contract with body is delivered degraded instead of dis
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
onError: () => { failed += 1; },
|
||||
@@ -1310,6 +1311,7 @@ test("incomplete runtime contract without body still fails closed (BUG-956)", as
|
||||
async function* chunks() {}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
onError: () => { failed += 1; },
|
||||
@@ -1329,6 +1331,157 @@ test("incomplete runtime contract without body still fails closed (BUG-956)", as
|
||||
step.kind === "validation" && step.name === "runtime-contract-incomplete" && step.status === "failed"));
|
||||
});
|
||||
|
||||
test("degraded delivery drops guarantee sentences through Pass 4 (BUG-959)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let completed = 0;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } };
|
||||
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(completed, 1);
|
||||
const answers = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text);
|
||||
assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false);
|
||||
assert.match(answers.join(""), /方向上可以推进/);
|
||||
assert.equal(answers.join("").includes(CONTRACT_DEGRADED_NOTE), true);
|
||||
const completedEvent = events.find((event) => (event as { type?: string }).type === "run.completed") as {
|
||||
receipt?: { steps: Array<{ kind: string; name: string; status: string }> };
|
||||
};
|
||||
assert.ok(completedEvent.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "contract-degraded" && step.status === "failed"));
|
||||
assert.ok(completedEvent.receipt?.steps.some((step) =>
|
||||
step.kind === "validation" && step.name === "pass4-reject:guarantee" && step.status === "failed"));
|
||||
});
|
||||
|
||||
test("degraded delivery that Pass 4 empties stays incomplete instead of a note-only answer (BUG-959)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
onComplete: () => { completed += 1; },
|
||||
onError: () => { failed += 1; },
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(completed, 0);
|
||||
assert.equal(failed, 1);
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), false);
|
||||
const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as {
|
||||
code: string;
|
||||
receipt?: { steps: Array<{ name: string }> };
|
||||
};
|
||||
assert.equal(failure.code, "runtime_contract_incomplete");
|
||||
assert.ok(failure.receipt?.steps.some((step) => step.name === "pass4-reject:guarantee"));
|
||||
assert.ok(failure.receipt?.steps.some((step) => step.name === "runtime-contract-incomplete"));
|
||||
});
|
||||
|
||||
test("degraded delivery uses the refusal when Pass 4 drops every general-mode sentence (BUG-959)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "general_no_birth_time",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true);
|
||||
assert.doesNotMatch(answer, /你的上升是巨蟹座/);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
test("degraded delivery keeps only the last attempt body (BUG-960)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
async function* first() {
|
||||
yield { type: "text-delta", payload: { text: "第一段。" } };
|
||||
}
|
||||
async function* second() {
|
||||
yield { type: "text-delta", payload: { text: "第二段。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
retry: async () => second(),
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /第二段/);
|
||||
assert.doesNotMatch(answer, /第一段/);
|
||||
assert.equal(answer.includes(CONTRACT_DEGRADED_NOTE), true);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
test("degraded delivery does not start a compose pass (BUG-961)", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
let composeCalls = 0;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "方向上可以推进。" } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "blocked", receipt: () => ({ ...receipt(state), steps: publicConsultationRuntimeSteps(state) }),
|
||||
composeAnswer: async () => {
|
||||
composeCalls += 1;
|
||||
async function* composed() {
|
||||
yield { type: "text-delta", payload: { text: "不该出现的 compose。" } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
return composed();
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(composeCalls, 0);
|
||||
assert.equal(events.some((event) => {
|
||||
const item = event as { type?: string; phase?: string };
|
||||
return item.type === "phase.started" && item.phase === "compose";
|
||||
}), false);
|
||||
const answer = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text)
|
||||
.join("");
|
||||
assert.match(answer, /方向上可以推进/);
|
||||
assert.doesNotMatch(answer, /不该出现的 compose/);
|
||||
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
|
||||
});
|
||||
|
||||
test("skill-binding abort is a distinct receipt step from a missing tool call (BUG-955)", async () => {
|
||||
const bindingState = createConsultationRuntimeState();
|
||||
let bindingError = "";
|
||||
|
||||
Reference in New Issue
Block a user