diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index e16b67ef..9e7858b1 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -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 可见 token,thinking 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)
+- 修复版本:待提交
+
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
- 状态:resolved
diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts
index 49944793..b153317a 100644
--- a/frontend/src/app/api/rectification/agent/route.ts
+++ b/frontend/src/app/api/rectification/agent/route.ts
@@ -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,
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 239a9477..1fb23f5d 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -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(
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index c87ebd6f..fb6cfaf0 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -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);
};
diff --git a/frontend/src/components/agent-activity-status.tsx b/frontend/src/components/agent-activity-status.tsx
index f377e743..3fcfa7d3 100644
--- a/frontend/src/components/agent-activity-status.tsx
+++ b/frontend/src/components/agent-activity-status.tsx
@@ -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 {label};
+}
+
export function AgentActivityStatus({
state,
label = labels[state],
+ startedAt,
+ completedTrail,
}: Readonly<{
state: AgentActivityState;
label?: string;
+ startedAt?: number;
+ completedTrail?: string;
}>) {
return (
-
-
-
{label}
+
+
+
+
+ {label}
+
+ {startedAt ?
: null}
+
+ {completedTrail ? (
+
{completedTrail}
+ ) : null}
);
}
diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx
index 304d21df..c6e46100 100644
--- a/frontend/src/components/chat-message-row.tsx
+++ b/frontend/src/components/chat-message-row.tsx
@@ -97,7 +97,12 @@ export function ChatMessageRow({
{message.role === "assistant" ? (
<>
{showActivity && (
-
+
)}
{message.text && (
> = {
- "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;
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 2d8ed3d7..d601de1a 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -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) {
{message.state === "settled" && message.failed && (
- 本轮处理未完成,已保留服务端记录的执行进度。
+ {message.text
+ ? "回答未完成,已保留现有内容;本次不会扣点。"
+ : "本轮处理未完成,已保留服务端记录的执行进度。"}
)}
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
)}
diff --git a/frontend/src/lib/agent-generation-settings.ts b/frontend/src/lib/agent-generation-settings.ts
new file mode 100644
index 00000000..bc000413
--- /dev/null
+++ b/frontend/src/lib/agent-generation-settings.ts
@@ -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
= {
+ openai: thinkingDisabled,
+ };
+ if (providerId) providerOptions[providerId] = thinkingDisabled;
+ return {
+ modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
+ providerOptions,
+ };
+}
diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts
index ae7d8fc5..4d7755de 100644
--- a/frontend/src/lib/chat-message-view.ts
+++ b/frontend/src/lib/chat-message-view.ts
@@ -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,
+ 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;
diff --git a/frontend/src/lib/consultation-activity-labels.ts b/frontend/src/lib/consultation-activity-labels.ts
new file mode 100644
index 00000000..ba976ef1
--- /dev/null
+++ b/frontend/src/lib/consultation-activity-labels.ts
@@ -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} 项)…`;
+}
diff --git a/frontend/src/lib/rectification-activity-labels.ts b/frontend/src/lib/rectification-activity-labels.ts
new file mode 100644
index 00000000..18412d79
--- /dev/null
+++ b/frontend/src/lib/rectification-activity-labels.ts
@@ -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> = {
+ "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> = {
+ "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([
+ "rectification-compare-candidates",
+ "rectification-read-diagnostics",
+ "rectification-offer-candidates",
+]);
+
+const LOAD_TOOLS = new Set([
+ "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";
+}
diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
index 839739f7..3b2b839e 100644
--- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts
+++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
@@ -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;
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 | 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;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
@@ -503,7 +518,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise;
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
@@ -511,6 +533,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise | 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 = {
- 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
diff --git a/frontend/tests/agent-activity-progress.test.ts b/frontend/tests/agent-activity-progress.test.ts
new file mode 100644
index 00000000..c66c7892
--- /dev/null
+++ b/frontend/tests/agent-activity-progress.test.ts
@@ -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/);
+});
diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts
index 59f8804a..85a1194e 100644
--- a/frontend/tests/chat-stream-layout.test.ts
+++ b/frontend/tests/chat-stream-layout.test.ts
@@ -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, / {
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/);
});
diff --git a/frontend/tests/rectification-activity-receipt.test.ts b/frontend/tests/rectification-activity-receipt.test.ts
index de806c1b..3419ef8c 100644
--- a/frontend/tests/rectification-activity-receipt.test.ts
+++ b/frontend/tests/rectification-activity-receipt.test.ts
@@ -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/);
});
diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts
index 158cf0d5..fdbf310c 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -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, / {
@@ -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", () => {
diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts
index 6def9032..40e4d5b1 100644
--- a/frontend/tests/rectification-v9-agent.test.ts
+++ b/frontend/tests/rectification-v9-agent.test.ts
@@ -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;
} = {};
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(
diff --git a/frontend/tests/rectification-v9-stream.test.ts b/frontend/tests/rectification-v9-stream.test.ts
index 054612e1..6491ba56 100644
--- a/frontend/tests/rectification-v9-stream.test.ts
+++ b/frontend/tests/rectification-v9-stream.test.ts
@@ -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[] = [];