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:
@@ -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/);
|
||||
});
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user