Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse_Chen e7f4030e3f docs: record BUG-340 fix SHA
Independent Staging Quality Gate / validate (push) Successful in 12m14s
Independent Staging Quality Gate / publish (push) Successful in 10m4s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 12:39:40 +08:00
Jesse_Chen 6a44c778c3 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>
2026-08-21 12:39:09 +08:00
21 changed files with 539 additions and 81 deletions
+17 -1
View File
@@ -4583,7 +4583,7 @@
- 修复:可见正文在 `length` 结束,或超时/中止时已有输出,改为 `run.failed` / `answer_truncated`,保留已流出文本,账务走 `cancel`。咨询流设置 `maxOutputTokens = 8192`,并对当前供应商与 `openai` 兼容键关闭 thinking。客户端保存半截助手消息并提示未完成、不会扣点,不再要求 `run.completed` 才落盘。
- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 覆盖 `length` 与超时半截不得 `run.completed`、不得调用 `onComplete``consultation-workflow-contract.test.ts` 锁定输出预算与 thinking disabled`consultation-recovery.test.ts``chat-stream-layout.test.ts` 锁定半截落盘与提示;`agent-observability.test.ts``answer_truncated` 纳入已知错误码。
- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留可见 token 预算;不得依赖 Flash 默认 thinking 与提供方默认 `max_tokens`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。
- 相关记录:BUG-280、BUG-277
- 相关记录:BUG-280、BUG-277、BUG-340
- 复发自:无
- 修复版本:待提交
@@ -5115,6 +5115,22 @@
- 复发自:BUG-335(把入口 portal 到页头时用了 effect 同步 setState
- 修复版本:f4619df2
## BUG-340 | 生时纠正未关闭 thinking,半截回答仍可能被当成完成
- 状态:resolved
- 首次发现:2026-08-21
- 最近更新:2026-08-21
- 影响面:`POST /api/rectification/agent``runV9AgentTurn`、生时纠正与普通咨询等待态
- 用户现象:Agent 写到一半停止,界面却像已经答完;等待期间只有「正在处理…」,看不出在做什么。
- 触发条件:当前会话模型默认开启 thinking / reasoning;可见正文与隐藏推理共用输出预算,或墙钟超时后仍发出 `finish`
- 根因:BUG-305 只修了咨询路径。纠正 `agent.stream` 没有 `maxOutputTokens`、没有关闭 thinking,并把任意 `finish` 当成成功。进度事件 `tool.activity started` 被客户端丢掉,所以长计算期间用户只能干等。
- 修复:咨询与纠正共用 `agentGenerationSettings`8192 可见 tokenthinking disabled)。纠正在 `finish_reason=length` 时走 `answer_truncated`、不扣点、不把半截写入成功 Turn;客户端保留已流出正文并提示未完成。等待态改为公开工具进度(正在比较候选时间等)和 8 秒后的已用时,不展示模型思维链。
- 验证:`frontend/tests/rectification-v9-stream.test.ts` 的 length 夹断不得 `run.completed``frontend/tests/rectification-v9-agent.test.ts` 锁定纠正流的输出预算与 thinking disabled`frontend/tests/agent-activity-progress.test.ts``frontend/tests/rectification-agentic-entry.test.ts``frontend/tests/consultation-workflow-contract.test.ts`
- 防复发:纠正与咨询必须走同一套 generation settings。不得为了等待体验打开 provider thinking。`tool.activity started` 必须驱动 Orb 文案。`finish_reason=length` 不得映射为 `run.completed`
- 相关记录:BUG-305、BUG-282、BUG-329
- 复发自:BUG-305(咨询已修,纠正仍用默认 thinking 与任意 finish
- 修复版本:`6a44c778`
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
- 状态:resolved
@@ -290,6 +290,7 @@ export async function POST(request: Request) {
emit: (event) => send(event),
signal: request.signal,
timeContext,
generationModel: selectedModel.model,
buildAgent: (turnId, skillPackage, attemptId) => Promise.resolve(
getRectificationV9Agent(selectedModel, {
userId,
+22 -1
View File
@@ -241,8 +241,29 @@ button:disabled { cursor: default; opacity: .45; }
}
.markdown-table tr:last-child th,
.markdown-table tr:last-child td { border-bottom: 0; }
.agent-activity-status { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); line-height: 1.5; }
.agent-activity-status {
min-height: 24px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
line-height: 1.5;
}
.agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; }
.agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
.agent-activity-status canvas { flex: 0 0 auto; }
.agent-activity-status__elapsed {
flex: 0 0 auto;
color: var(--color-ink-tertiary);
font-variant-numeric: tabular-nums;
}
.agent-activity-status__trail {
margin: 0;
padding-left: 28px;
color: var(--color-ink-tertiary);
}
.agent-activity-status__text {
color: var(--color-ink-tertiary);
background: linear-gradient(
+36 -8
View File
@@ -92,12 +92,20 @@ import {
BALANCE_CHANGED_EVENT,
membershipHref,
} from "@/lib/membership";
import { chatMessageViews, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
import {
createNdjsonParser,
type AgentExecutionReceipt,
type ConsultationAgentPublicEvent,
} from "@/lib/consultation-agent-events";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
CONSULTATION_DONE_CHART_LABEL,
CONSULTATION_DONE_SKILL_LABEL,
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import {
@@ -3200,7 +3208,13 @@ export default function Home() {
const updateStreamingAnswer = (activity?: AgentActivityView) => {
const partialReply = parseAgentReply(answer).text;
latestPartialReply = partialReply;
setStreamingReply({ sessionId, text: partialReply, activity });
setStreamingReply((current) => ({
sessionId,
text: partialReply,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
}));
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
@@ -3208,15 +3222,29 @@ export default function Home() {
const updateActivity = (event: ConsultationAgentPublicEvent) => {
let activity: AgentActivityView | undefined;
if (event.type === "skill.started") {
activity = { phase: "loading-method", label: "正在读取印度占星分析规则…" };
} else if (event.type === "tool.started") {
activity = { phase: "chart-calculation", label: "正在计算本命盘…" };
activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL };
} else if (event.type === "skill.completed" || event.type === "tool.started") {
activity = {
phase: "chart-calculation",
label: CONSULTATION_CHART_CALCULATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "activity") {
activity = { phase: event.phase, label: event.label };
activity = {
phase: event.phase,
label: event.label,
completedTrail: event.phase === "evidence-validation"
? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL])
: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "tool.completed") {
activity = { phase: "evidence-validation", label: "正在核对可用证据…" };
activity = {
phase: "evidence-validation",
label: CONSULTATION_EVIDENCE_VALIDATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]),
};
} else if (event.type === "answer.delta") {
activity = { phase: "answer-composition", label: "正在组织回答…" };
activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL };
}
if (activity) updateStreamingAnswer(activity);
};
@@ -1,9 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { OrbState } from "thinking-orbs";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { activityElapsedLabel } from "@/lib/chat-message-view";
const labels = {
working: "正在处理任务…",
@@ -27,17 +29,40 @@ prefetchOnIdle(importThinkingOrb);
export type AgentActivityState = OrbState;
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const label = activityElapsedLabel(startedAt, now);
if (!label) return null;
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
}
export function AgentActivityStatus({
state,
label = labels[state],
startedAt,
completedTrail,
}: Readonly<{
state: AgentActivityState;
label?: string;
startedAt?: number;
completedTrail?: string;
}>) {
return (
<div className="agent-activity-status" role="status">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span key={label} className="agent-activity-status__text">{label}</span>
<div className="agent-activity-status">
<div className="agent-activity-status__row">
<div className="agent-activity-status__live" role="status">
<ThinkingOrb aria-hidden="true" state={state} size={20} />
<span key={label} className="agent-activity-status__text">{label}</span>
</div>
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
</div>
{completedTrail ? (
<p className="agent-activity-status__trail" aria-hidden="true">{completedTrail}</p>
) : null}
</div>
);
}
+6 -1
View File
@@ -97,7 +97,12 @@ export function ChatMessageRow({
{message.role === "assistant" ? (
<>
{showActivity && (
<AgentActivityStatus state={activityState} label={activityLabel} />
<AgentActivityStatus
state={activityState}
label={activityLabel}
startedAt={message.activity?.startedAt}
completedTrail={message.activity?.completedTrail}
/>
)}
{message.text && (
<ChatMessageContent
@@ -2,27 +2,14 @@
import { Check, ChevronDown } from "lucide-react";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import { RECTIFICATION_TOOL_DONE_LABELS } from "@/lib/rectification-activity-labels";
import type {
PublicRectificationMethod,
PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "@/lib/rectification-varga-sentence";
const TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-set-focus": "设置对话焦点",
"rectification-resolve-focus": "处理当前焦点",
"rectification-record-evidence-batch": "整理多条事件证据",
"rectification-propose-evidence": "整理事件证据",
"rectification-confirm-evidence": "确认事件证据",
"rectification-revise-evidence": "修订事件证据",
"rectification-compare-candidates": "比较候选时间",
"rectification-read-diagnostics": "检查候选稳健性",
"rectification-offer-candidates": "生成候选建议",
"rectification-accept-candidate": "采用候选时间",
"rectification-confirm-birth-time": "确认校正时间",
"rectification-close-case": "完成校正记录",
};
const TOOL_LABELS = RECTIFICATION_TOOL_DONE_LABELS;
const METHOD_LABELS = PUBLIC_RECTIFICATION_METHOD_LABELS;
@@ -4,7 +4,12 @@ import { ArrowUp, Square } from "lucide-react";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
import {
RECTIFICATION_TOOL_PROGRESS_LABELS,
rectificationCompletedTrail,
rectificationToolActivityPhase,
} from "@/lib/rectification-activity-labels";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
@@ -348,7 +353,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: {
phase: "evidence-validation",
label: "正在处理…",
startedAt: Date.now(),
} },
]);
setDraft("");
@@ -426,7 +435,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
raw += event.text;
const parsed = parseAgentReply(raw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming" }
? {
...message,
text: parsed.text,
state: "streaming",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message));
} else if (event.type === "attempt.reset") {
raw = "";
@@ -434,7 +451,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
? {
...message,
text: "",
state: "thinking",
completedReceipt: undefined,
failed: false,
turnId: undefined,
activity: nextActivityView(undefined, {
phase: "evidence-validation",
label: "正在处理…",
}),
}
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
@@ -447,7 +475,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
} else if (event.type === "tool.activity") {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
if (event.status === "started") continue;
if (event.status === "started") {
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activity: nextActivityView(message.activity, {
phase: rectificationToolActivityPhase(tool),
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
}),
}
: message));
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
@@ -473,20 +513,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
completedReceipt,
failed: false,
turnId: completedTurnId,
activity: undefined,
}];
}
if (streamFailed || hasActivityReceipt(completedReceipt)) {
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
return [{
...message,
text: "",
text: parsed.text,
state: "settled",
completedReceipt,
failed: true,
activity: undefined,
}];
}
return [];
}));
if (!succeeded && completedReceipt.failedTool) {
if (!succeeded && parsed.text) {
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
} else if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
@@ -719,7 +763,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
&& !readonly
&& regeneratingMessageKey === null;
const displayedMessage = regenerating
? { ...message, text: "", state: "thinking" as const }
? {
...message,
text: "",
state: "thinking" as const,
activity: nextActivityView(undefined, {
phase: "answer-composition",
label: "正在组织回答…",
}),
}
: message;
const vargaSentence = message.state === "settled" && !message.failed
? vargaSentenceFromMethods(message.completedReceipt?.methods)
@@ -728,13 +780,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.failed && (
<p className="rectification-activity-failure" role="status">
{message.text
? "回答未完成,已保留现有内容;本次不会扣点。"
: "本轮处理未完成,已保留服务端记录的执行进度。"}
</p>
)}
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
<ChatMessageRow
message={displayedMessage}
showActivity={displayedMessage.state === "thinking"}
showActivity={displayedMessage.state !== "settled"}
vargaSentence={vargaSentence}
/>
)}
@@ -0,0 +1,28 @@
/**
* Visible-answer generation settings shared by consultation and rectification.
*
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
* `max_tokens` with the spoken answer. Without an explicit visible budget and
* thinking turned off, a finished-looking stream can stop mid-heading with
* `finish_reason=length`. The provider id is repeated under `openai` because
* OpenAI-compatible adapters often look there first.
*/
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
const thinkingDisabled = { thinking: { type: "disabled" as const } };
export function agentGenerationSettings(model?: unknown) {
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
? model.providerId
: undefined;
const providerOptions: Record<string, typeof thinkingDisabled> = {
openai: thinkingDisabled,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
return {
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
providerOptions,
};
}
+35
View File
@@ -1,10 +1,45 @@
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
export type AgentActivityView = Readonly<{
phase: PublicActivityPhase;
label: string;
startedAt?: number;
completedTrail?: string;
}>;
export function activityCompletedTrail(steps: readonly string[]): string | undefined {
if (steps.length === 0) return undefined;
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
}
export function nextActivityView(
previous: AgentActivityView | undefined,
next: Omit<AgentActivityView, "startedAt">,
now = Date.now(),
): AgentActivityView {
const startedAt = previous?.label === next.label && previous.startedAt ? previous.startedAt : now;
const completedTrail = next.phase === "answer-composition"
? undefined
: next.completedTrail !== undefined
? next.completedTrail || undefined
: previous?.completedTrail;
return {
phase: next.phase,
label: next.label,
startedAt,
...(completedTrail ? { completedTrail } : {}),
};
}
export function activityElapsedLabel(startedAt: number, now: number): string | null {
const elapsedMs = now - startedAt;
if (elapsedMs < ACTIVITY_ELAPSED_VISIBLE_AFTER_MS) return null;
return `已用时 ${Math.floor(elapsedMs / 1000)}`;
}
export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
@@ -0,0 +1,11 @@
export const CONSULTATION_LOADING_METHOD_LABEL = "正在读取印度占星分析规则…";
export const CONSULTATION_CHART_CALCULATION_LABEL = "正在计算本命盘…";
export const CONSULTATION_EVIDENCE_VALIDATION_LABEL = "正在核对可用证据…";
export const CONSULTATION_COMPOSING_LABEL = "正在组织回答…";
export const CONSULTATION_DONE_SKILL_LABEL = "读取分析方法";
export const CONSULTATION_DONE_CHART_LABEL = "计算本命盘";
export function chartCalculationProgressLabel(current: number, total: number): string {
if (total <= 1) return CONSULTATION_CHART_CALCULATION_LABEL;
return `正在计算本命盘(第 ${current}/${total} 项)…`;
}
@@ -0,0 +1,64 @@
import { activityCompletedTrail } from "./chat-message-view.ts";
import type { PublicActivityPhase } from "./consultation-agent-events.ts";
import type { PublicRectificationTool } from "./rectification-agentic/v9/public-receipt.ts";
export const RECTIFICATION_TOOL_DONE_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-set-focus": "设置对话焦点",
"rectification-resolve-focus": "处理当前焦点",
"rectification-record-evidence-batch": "整理多条事件证据",
"rectification-propose-evidence": "整理事件证据",
"rectification-confirm-evidence": "确认事件证据",
"rectification-revise-evidence": "修订事件证据",
"rectification-compare-candidates": "比较候选时间",
"rectification-read-diagnostics": "检查候选稳健性",
"rectification-offer-candidates": "生成候选建议",
"rectification-accept-candidate": "采用候选时间",
"rectification-confirm-birth-time": "确认校正时间",
"rectification-close-case": "完成校正记录",
};
export const RECTIFICATION_TOOL_PROGRESS_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "正在读取校正记录…",
"rectification-set-focus": "正在设置对话焦点…",
"rectification-resolve-focus": "正在处理当前焦点…",
"rectification-record-evidence-batch": "正在整理多条事件证据…",
"rectification-propose-evidence": "正在整理事件证据…",
"rectification-confirm-evidence": "正在确认事件证据…",
"rectification-revise-evidence": "正在修订事件证据…",
"rectification-compare-candidates": "正在比较候选时间…",
"rectification-read-diagnostics": "正在检查候选稳健性…",
"rectification-offer-candidates": "正在生成候选建议…",
"rectification-accept-candidate": "正在采用候选时间…",
"rectification-confirm-birth-time": "正在确认校正时间…",
"rectification-close-case": "正在完成校正记录…",
};
const COMPARE_TOOLS = new Set<PublicRectificationTool>([
"rectification-compare-candidates",
"rectification-read-diagnostics",
"rectification-offer-candidates",
]);
const LOAD_TOOLS = new Set<PublicRectificationTool>([
"rectification-read-case",
"rectification-set-focus",
"rectification-resolve-focus",
]);
export function rectificationCompletedTrail(steps: readonly PublicRectificationTool[]): string | undefined {
return activityCompletedTrail(steps.map((tool) => RECTIFICATION_TOOL_DONE_LABELS[tool]));
}
export function rectificationToolActivityPhase(tool: PublicRectificationTool): PublicActivityPhase {
if (LOAD_TOOLS.has(tool)) return "loading-method";
if (COMPARE_TOOLS.has(tool)) return "chart-calculation";
if (
tool === "rectification-accept-candidate"
|| tool === "rectification-confirm-birth-time"
|| tool === "rectification-close-case"
) {
return "answer-composition";
}
return "evidence-validation";
}
@@ -20,6 +20,8 @@ import {
type V9CaseDossier,
} from "./tool-service";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status";
import { agentGenerationSettings } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import {
resolveExactSkillPackage,
type ResolvedSkillPackageIdentity,
@@ -58,6 +60,7 @@ export type V9AgentRunOptions = Readonly<{
emit(event: PublicStreamEvent): Promise<void> | void;
signal?: AbortSignal;
timeContext?: string;
generationModel?: unknown;
}>;
export type V9AgentRunResult = Readonly<{
@@ -100,6 +103,14 @@ const RETRYABLE_ERROR_CODES = new Set([
"focus_persistence_failed",
]);
function streamFinishReason(chunk: {
type: string;
payload?: { stepResult?: { reason?: unknown }; reason?: unknown };
}): ReturnType<typeof toAgentModelFinishReason> | null {
if (chunk.type !== "finish") return null;
return toAgentModelFinishReason(chunk.payload?.stepResult?.reason ?? chunk.payload?.reason);
}
function first(value: unknown): unknown {
if (Array.isArray(value)) return value[0] ?? null;
if (value && typeof value === "object" && "value" in value) {
@@ -129,6 +140,7 @@ function safeErrorCode(error: unknown): string {
"case_not_loaded",
"repeated_tool_call",
"focus_persistence_failed",
"answer_truncated",
]) {
if (message.includes(code)) return code;
}
@@ -489,12 +501,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel);
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: {
maxSteps: number;
abortSignal: AbortSignal;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking: { type: "disabled" } }>;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
@@ -503,7 +518,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
): Promise<{
fullStream: AsyncIterable<{
type: string;
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
payload?: {
toolName?: unknown;
text?: unknown;
args?: unknown;
error?: unknown;
stepResult?: { reason?: unknown };
reason?: unknown;
};
object?: unknown;
}>;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
@@ -511,6 +533,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...generation,
// Thinking-mode providers reject named/required tool_choice. Restrict
// the first step to read-case and keep tool_choice auto; the runner
// still refuses any other public tool before case.loaded.
@@ -522,6 +545,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
: undefined,
});
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
for await (const chunk of result.fullStream) {
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
if (chunk.type === "tool-call") {
@@ -592,7 +617,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
for (const toolName of streamToolNames(chunk as never)) toolsUsed.add(toolName);
if (chunk.type === "error" || chunk.type === "abort") streamFailed = true;
if (chunk.type === "finish") finished = true;
if (chunk.type === "finish") {
finished = true;
finishReason = streamFinishReason(chunk);
}
}
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
@@ -600,6 +628,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
if (finishReason === "length") {
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
+15 -30
View File
@@ -13,6 +13,8 @@ import { createConsultationPlan, type ConsultationPlan } from "../lib/consultati
import type { TechniqueAuditRow, WorkflowReceipt } from "../lib/consultation-agent-events.ts";
import { normalizeTechniqueAuditRows } from "../lib/consultation-technique-audit.ts";
import type { AgentModelFinishReason } from "../lib/agent-observability.ts";
import { agentGenerationSettings } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts";
import {
consultationEvidencePacketSchema,
consultationInputSchema,
@@ -24,6 +26,8 @@ import {
type ConsultationEvidencePacket,
} from "./consultation-workflow.ts";
export { AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
// The budgets that bound one consultation run. They all constrain the same
// wall clock, so they are declared together and must be changed together.
//
@@ -42,7 +46,6 @@ import {
// calculation inside the 110s budget—so it is measured, not guessed.
export const AGENT_MAX_STEPS = 8;
export const AGENT_TIMEOUT_MS = 110_000;
export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;
const CONSULTATION_DOMAIN_DURATION_MS = 21_000;
const CONSULTATION_ANSWER_RESERVE_MS = 45_000;
export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS;
@@ -51,31 +54,8 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS),
);
const thinkingDisabled = { thinking: { type: "disabled" as const } };
/**
* Visible-answer generation settings for one consult stream.
*
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
* `max_tokens` with the spoken answer. Without an explicit visible budget and
* thinking turned off, a finished-looking stream can stop mid-heading with
* `finish_reason=length`. The provider id is repeated under `openai` because
* OpenAI-compatible adapters often look there first.
*/
export function consultationGenerationSettings(model?: unknown) {
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
? model.providerId
: undefined;
const providerOptions: Record<string, typeof thinkingDisabled> = {
openai: thinkingDisabled,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
return {
modelSettings: { maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS },
providerOptions,
};
return agentGenerationSettings(model);
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
@@ -562,16 +542,21 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
const currentCalculation = (async () => {
try {
const userIntent = ctx.plan?.userIntent ?? input.question;
await context.writer?.custom({
type: "data-jyotish-activity",
data: { phase: "chart-calculation", label: "正在计算本命盘" },
});
const executions: DomainExecution[] = [];
for (const domain of domains) {
for (let index = 0; index < domains.length; index += 1) {
const domain = domains[index];
if (!domain) continue;
// Every domain shares the run's single abort deadline, so a plan
// that runs long would abort mid-loop and lose the domains already
// calculated. Stop while there is still time to answer instead.
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
await context.writer?.custom({
type: "data-jyotish-activity",
data: {
phase: "chart-calculation",
label: chartCalculationProgressLabel(index + 1, domains.length),
},
});
const domainPlan = ctx.plan
&& domains.length === 1
&& ctx.plan.requestedDomains.length === 1
@@ -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[] = [];