fix(web): show live agent work progress and fail truncated rectification answers

Rectification dropped tool.activity started events and treated length finishes as completed. Share generation settings with consultation, keep the activity line through streaming, and name multi-domain chart calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-21 12:39:09 +08:00
co-authored by Cursor
parent 036a756be3
commit 6a44c778c3
21 changed files with 539 additions and 81 deletions
@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import { agentGenerationSettings, AGENT_MAX_OUTPUT_TOKENS } from "../src/lib/agent-generation-settings.ts";
import {
activityCompletedTrail,
activityElapsedLabel,
nextActivityView,
} from "../src/lib/chat-message-view.ts";
import {
chartCalculationProgressLabel,
CONSULTATION_CHART_CALCULATION_LABEL,
} from "../src/lib/consultation-activity-labels.ts";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "../src/lib/rectification-activity-labels.ts";
test("generation settings reserve visible tokens and disable thinking", () => {
const settings = agentGenerationSettings({ providerId: "deepseek" });
assert.equal(AGENT_MAX_OUTPUT_TOKENS, 8192);
assert.equal(settings.modelSettings.maxOutputTokens, 8192);
assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "disabled" } });
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "disabled" } });
});
test("activity elapsed copy stays hidden until eight seconds", () => {
assert.equal(activityElapsedLabel(1_000, 8_999), null);
assert.equal(activityElapsedLabel(1_000, 9_000), "已用时 8 秒");
assert.equal(activityElapsedLabel(1_000, 25_000), "已用时 24 秒");
});
test("the same activity label keeps its start time", () => {
const first = nextActivityView(undefined, {
phase: "chart-calculation",
label: "正在比较候选时间…",
}, 10);
const same = nextActivityView(first, {
phase: "chart-calculation",
label: "正在比较候选时间…",
}, 40);
const next = nextActivityView(same, {
phase: "answer-composition",
label: "正在组织回答…",
}, 50);
assert.equal(first.startedAt, 10);
assert.equal(same.startedAt, 10);
assert.equal(next.startedAt, 50);
});
test("completed-step trail stays short and drops while composing", () => {
assert.equal(activityCompletedTrail([]), undefined);
assert.equal(
activityCompletedTrail(["读取校正记录", "整理事件证据"]),
"已完成:读取校正记录 · 整理事件证据",
);
assert.equal(
activityCompletedTrail(["一", "二", "三", "四"]),
"已完成:二 · 三 · 四",
);
const waiting = nextActivityView(undefined, {
phase: "chart-calculation",
label: "正在比较候选时间…",
completedTrail: activityCompletedTrail(["读取校正记录", "整理事件证据"]),
}, 10);
assert.equal(waiting.completedTrail, "已完成:读取校正记录 · 整理事件证据");
const composing = nextActivityView(waiting, {
phase: "answer-composition",
label: "正在组织回答…",
}, 20);
assert.equal(composing.completedTrail, undefined);
});
test("live rectification labels name the actual public tool", () => {
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-read-case"], "正在读取校正记录…");
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-compare-candidates"], "正在比较候选时间…");
assert.equal(rectificationToolActivityPhase("rectification-read-case"), "loading-method");
assert.equal(rectificationToolActivityPhase("rectification-compare-candidates"), "chart-calculation");
assert.equal(rectificationToolActivityPhase("rectification-propose-evidence"), "evidence-validation");
assert.equal(
rectificationCompletedTrail(["rectification-read-case", "rectification-propose-evidence"]),
"已完成:读取校正记录 · 整理事件证据",
);
});
test("multi-domain chart calculation names the current item without domain ids", () => {
assert.equal(chartCalculationProgressLabel(1, 1), CONSULTATION_CHART_CALCULATION_LABEL);
assert.equal(chartCalculationProgressLabel(2, 3), "正在计算本命盘(第 2/3 项)…");
assert.doesNotMatch(chartCalculationProgressLabel(2, 3), /career|wealth|timing|marriage/);
});
+10 -1
View File
@@ -62,10 +62,19 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
assert.match(activitySource, /className="agent-activity-status__text"/);
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
assert.match(activitySource, /className="agent-activity-status__trail" aria-hidden="true"/);
assert.match(activitySource, /role="status"/);
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
assert.match(globalStyles, /\.agent-activity-status__row/);
assert.match(globalStyles, /\.agent-activity-status__trail/);
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成|completed/);
assert.match(pageSource, /nextActivityView/);
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
assert.match(pageSource, /activityCompletedTrail\(\[CONSULTATION_DONE_SKILL_LABEL\]\)/);
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成/);
assert.doesNotMatch(activitySource, /state === "completed"|: "completed"/);
assert.doesNotMatch(messageRowSource, /: "completed"/);
assert.doesNotMatch(globalStyles, /\.thinking\b/);
assert.match(pageSource, /application\/x-ndjson/);
@@ -23,7 +23,8 @@ test("consultation plans are server-owned and bounded", () => {
assert.match(tools, /const userIntent = ctx\.plan\?\.userIntent \?\? input\.question/);
assert.match(tools, /input\.domains === undefined && context\.plan && context\.theme/);
assert.match(tools, /return \[context\.theme\]/);
assert.match(tools, /const domainPlan = ctx\.plan[\s\S]*createConsultationPlan\(\{/);
assert.match(tools, /chartCalculationProgressLabel\(index \+ 1, domains\.length\)/);
assert.match(tools, /phase: "chart-calculation"/);
assert.match(tools, /userIntent,[\s\S]*theme: domain/);
assert.match(tools, /question: userIntent,[\s\S]*theme: domain/);
assert.match(tools, /plan: domainPlan/);
@@ -66,10 +67,13 @@ test("the model step budget and the wall-clock budget are declared as one pair",
});
test("consult streams cap visible output and disable thinking instead of sharing the token budget with hidden reasoning", () => {
assert.match(tools, /export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;/);
const settings = readFileSync(new URL("../src/lib/agent-generation-settings.ts", import.meta.url), "utf8");
assert.match(settings, /export const AGENT_MAX_OUTPUT_TOKENS = 8192;/);
assert.match(settings, /thinking: \{ type: "disabled"/);
assert.match(settings, /maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS/);
assert.match(tools, /function consultationGenerationSettings/);
assert.match(tools, /thinking: \{ type: "disabled"/);
assert.match(tools, /maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS/);
assert.match(tools, /return agentGenerationSettings\(model\)/);
assert.match(tools, /AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS/);
assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
});
@@ -100,6 +100,7 @@ test("answer deltas preserve a still-running server activity", () => {
chatSource.indexOf('event.type === "run.failed"'),
);
assert.match(deltaBranch, /state: "streaming"/);
assert.match(deltaBranch, /正在组织回答/);
assert.doesNotMatch(deltaBranch, /activeActivity:\s*undefined/);
});
@@ -36,6 +36,10 @@ const houseTable = readFileSync(
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
"utf8",
);
const progressLabels = readFileSync(
new URL("../src/lib/rectification-activity-labels.ts", import.meta.url),
"utf8",
);
const completedActivityReceipt = readFileSync(
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
"utf8",
@@ -231,6 +235,7 @@ test("rectification uses one case-level entitlement and the session-pinned model
assert.match(route, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
assert.match(route, /requestId: billingRequestId/);
assert.match(route, /modelConfigVersion: selectedModel\.configVersion/);
assert.match(route, /generationModel: selectedModel\.model/);
assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
});
@@ -243,7 +248,7 @@ test("Agentic rectification scrolls the conversation container as streamed messa
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
});
test("rectification keeps receipts for the varga sentence and hides Activity from the user", () => {
test("rectification keeps receipts for the varga sentence and shows live tool progress", () => {
const activityHelper = chat.slice(
chat.indexOf("function completedReceiptFromPersisted"),
chat.indexOf("export function RectificationAgenticChat"),
@@ -254,12 +259,15 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
assert.match(activityHelper, /receipt\.methods/);
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
assert.match(chat, /vargaSentenceFromMethods/);
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
assert.match(board, /RectificationHouseTableView/);
assert.doesNotMatch(chat, /activeActivity/);
assert.doesNotMatch(chat, /正在读取校正记录/);
assert.match(chat, /RECTIFICATION_TOOL_PROGRESS_LABELS/);
assert.match(chat, /rectificationCompletedTrail\(activityReceiptState\.completedSteps\)/);
assert.match(progressLabels, /正在读取校正记录/);
assert.match(progressLabels, /正在比较候选时间/);
assert.doesNotMatch(chat, /<CompletedActivityReceipt/);
assert.match(chat, /event\.type === "tool\.activity"/);
assert.match(chat, /event\.status === "started"/);
@@ -279,6 +287,8 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮做了什么"]) {
assert.doesNotMatch(chat, new RegExp(genericCopy));
}
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
});
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
@@ -431,7 +441,7 @@ test("rectification composer can stop a live agent run", () => {
assert.match(chat, /aria-label="停止回答"/);
assert.match(chat, /event\.type === "attempt\.reset"/);
assert.match(chat, /caught\.name === "AbortError"/);
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
});
test("conversation and house board reuse the quiet overlay scrollbar", () => {
@@ -239,6 +239,8 @@ test("server-loaded Skill is bound before the provider and the first model step
let observedMessages: unknown[] = [];
let observedStreamOptions: {
prepareStep?: (input: { stepNumber: number }) => unknown;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking?: { type?: string } }>;
} = {};
const agent = fakeAgentStream([
chunk("start"),
@@ -285,6 +287,8 @@ test("server-loaded Skill is bound before the provider and the first model step
});
assert.equal(typeof firstStep?.toolChoice, "string");
assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined);
assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 8192);
assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } });
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
@@ -449,6 +449,41 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(还不能确认唯一分钟";
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => dossierFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
});
const { options, emitted, billing } = runOptions({
accounting: accounting.client,
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("text-delta", { text: pinched }),
chunk("finish", { stepResult: { reason: "length" } }),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, false);
assert.equal(result.errorCode, "answer_truncated");
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.filter((event) => event.type === "run.completed").length, 0);
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: pinched }],
);
const turnFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
assert.equal(turnFinalize?.args.p_assistant_message, null);
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
});
test("answer deltas and tool activity are published before billing settles", async () => {
let billingStarted = false;
const seenBeforeBilling: string[] = [];