diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 672e1dd3..f3b0bebd 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -5435,6 +5435,22 @@
- 复发自:BUG-176(同一物理 Turn 的助手正文在恢复时丢失;后来又用最后一条 id 做 key,把 opening 状态一起冲掉)
- 修复版本:`f03a2706`
+## BUG-359 | 生时纠正把截断的过程自述落成完成回复
+
+- 状态:resolved
+- 首次发现:2026-08-23
+- 最近更新:2026-08-23
+- 影响面:生时纠正对话、普通咨询会话、`runV9AgentTurn`、`streamAgentResponse`、Turn / 会话水合
+- 用户现象:工具已写入带日期经历并完成评分后,折叠「思考」和「回复」出现同一段第三人称过程自述;回复在句中被截断,Turn 仍显示完成并扣点。普通咨询在 thinking 关闭时也会把过程写进正文。
+- 触发条件:供应商 thinking 关闭后,模型把中文过程自述写进 `text-delta` 并在写完口语结论前结束。
+- 根因:BUG-354 为保住正文额度关闭了纠正和咨询的 provider thinking。`reasoning-delta` 不再出现,过程自述改走正文。BUG-357 再用正则从正文里切「思考 / 回复」,分类漏了「用户在上一轮 / 批量工具」,finalize 还把单段自述回灌成完成回复。
+- 修复:纠正与咨询重新打开 provider thinking。`reasoning-delta` 进折叠「思考」,`text-delta` 进「回复」。输出预算改为正文 16384 + 思考 8192,避免 CoT 抢正文额度。`length` 续写仍关闭 thinking。正则只作正文漏检网和旧 Turn 水合,不再当主分流。没有口语结论时走 `empty_stream` 重试。
+- 验证:`frontend/tests/rectification-spoken-answer.test.ts`、`frontend/tests/rectification-v9-stream.test.ts`、`frontend/tests/rectification-v9-agent.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/consultation-agentic-runtime.test.ts`、`frontend/tests/consultation-workflow-contract.test.ts`、`frontend/tests/agent-activity-progress.test.ts`、`frontend/tests/chat-stream-layout.test.ts`
+- 防复发:纠正与咨询组答若打开 thinking,必须把思考预算加进 `maxOutputTokens`,且不得把 `reasoning-delta` 混进 `answer.delta`。不得用正则当思考/回复主分类器。`length` 续写必须关 thinking。纯过程自述不得 `run.completed`。
+- 相关记录:BUG-345、BUG-346、BUG-354、BUG-357、BUG-358
+- 复发自:BUG-354(关 thinking 后过程进正文)、BUG-357(用正则从正文里切两栏)
+- 修复版本:unreleased
+
diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts
index 3eee9de5..7b59819e 100644
--- a/frontend/src/app/api/consult/route.ts
+++ b/frontend/src/app/api/consult/route.ts
@@ -36,11 +36,14 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
-import { consultationContinuePrompt, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
+import { consultationContinuePrompt, consultationSectionPrompt, type PublicThinkingSection } from "@/lib/consultation-thinking-plan";
import {
AGENT_MAX_STEPS,
AGENT_TIMEOUT_MS,
+ AGENT_SLICE_MAX_STEPS,
+ consultationContinueGenerationSettings,
consultationGenerationSettings,
+ consultationSliceGenerationSettings,
createConsultationAgentContext,
createWindowConsultationAgentContext,
consultationModelStepTelemetry,
@@ -732,7 +735,10 @@ export async function POST(request: Request) {
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
- ], streamOptions);
+ ], {
+ ...streamOptions,
+ ...consultationContinueGenerationSettings(selectedModel.model),
+ });
usages.push(continued.totalUsage);
return continued.fullStream;
};
@@ -831,7 +837,10 @@ export async function POST(request: Request) {
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
- ], streamOptions);
+ ], {
+ ...streamOptions,
+ ...consultationContinueGenerationSettings(selectedModel.model),
+ });
usages.push(continued.totalUsage);
return continued.fullStream;
};
@@ -930,10 +939,26 @@ export async function POST(request: Request) {
...baseMessages,
{ role: "assistant" as const, content: output },
{ role: "user" as const, content: consultationContinuePrompt(output) },
- ], streamOptions);
+ ], {
+ ...streamOptions,
+ ...consultationContinueGenerationSettings(selectedModel.model),
+ });
usages.push(continued.totalUsage);
return continued.fullStream;
};
+ const composeSection = async (heading: string, priorOutput: string) => {
+ const sliced = await agent.stream([
+ ...baseMessages,
+ ...(priorOutput.trim() ? [{ role: "assistant" as const, content: priorOutput }] : []),
+ { role: "user" as const, content: consultationSectionPrompt(heading, priorOutput) },
+ ], {
+ ...streamOptions,
+ maxSteps: AGENT_SLICE_MAX_STEPS,
+ ...consultationSliceGenerationSettings(selectedModel.model),
+ });
+ usages.push(sliced.totalUsage);
+ return sliced.fullStream;
+ };
const executionReceipt = (): AgentExecutionReceipt => ({
runId: requestId,
runtime: "mastra-agentic",
@@ -960,6 +985,7 @@ export async function POST(request: Request) {
retry,
retryForAnswer,
continueAfterLength,
+ composeSection,
continueAfterDisconnect: true,
transformText: (text) => createBirthTimeModeOutputGuard(
consultationMode,
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 5040896f..485b8e73 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -844,11 +844,45 @@ button:disabled { cursor: default; opacity: .45; }
line-height: 1.55;
white-space: pre-wrap;
}
-.consultation-step-tree__intent {
- margin: 0 0 var(--space-2);
+.consultation-step-tree__group {
+ display: grid;
+ gap: var(--space-2);
+}
+.consultation-step-tree__stage {
+ display: grid;
+ grid-template-columns: 20px minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 24px;
+ margin: 0;
+ color: var(--color-ink-strong);
+ font-family: inherit;
+ font-size: var(--type-caption);
+ font-weight: 600;
+ line-height: 1.35;
+ text-wrap: balance;
+}
+.consultation-step-tree__stage-index,
+.consultation-step-tree__stage-mark {
+ width: 20px;
+ height: 20px;
+ display: grid;
+ place-items: center;
+ border-radius: var(--radius-xs);
+ background: var(--color-canvas-muted);
+ box-shadow: inset 0 0 0 1px var(--color-border);
color: var(--color-ink-secondary);
- font-size: 13px;
- line-height: 1.5;
+ font-size: 10px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: 0.02em;
+}
+.consultation-step-tree__stage-mark {
+ background: transparent;
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-border) 80%, transparent);
+}
+.consultation-step-tree__stage-title {
+ min-width: 0;
}
.agent-thinking-marker.is-pending {
background: var(--color-canvas-muted);
@@ -862,7 +896,14 @@ button:disabled { cursor: default; opacity: .45; }
gap: var(--space-6);
}
.consultation-step-tree__group + .consultation-step-tree__group {
- margin-top: var(--space-4);
+ margin-top: var(--space-6);
+}
+.consultation-step-tree__reasoning {
+ margin: 0 0 var(--space-4);
+ color: var(--color-ink-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.55;
+ white-space: pre-wrap;
}
.consultation-report-analysis {
min-width: 0;
@@ -878,6 +919,109 @@ button:disabled { cursor: default; opacity: .45; }
padding-bottom: var(--space-3);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
}
+.consultation-run-timeline {
+ min-width: 0;
+}
+.consultation-run-timeline > summary,
+.consultation-run-timeline__details > summary {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 20px;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: 24px;
+ cursor: pointer;
+ list-style: none;
+ color: var(--color-ink-tertiary);
+}
+.consultation-run-timeline > summary::-webkit-details-marker,
+.consultation-run-timeline__details > summary::-webkit-details-marker {
+ display: none;
+}
+.consultation-run-timeline__summary-label {
+ min-width: 0;
+}
+.consultation-run-timeline__list {
+ margin-top: var(--space-2);
+}
+.consultation-run-timeline__row {
+ align-items: start;
+}
+.consultation-run-timeline__row > .consultation-run-timeline__details {
+ grid-column: 1 / -1;
+ min-width: 0;
+}
+.consultation-run-timeline__details > summary {
+ grid-template-columns: 20px minmax(0, 1fr) 20px;
+ color: inherit;
+}
+.consultation-run-timeline__label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+.consultation-run-timeline__kind-icon {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 auto;
+ color: var(--color-ink-tertiary);
+}
+.consultation-run-timeline__chevron {
+ width: 12px;
+ height: 12px;
+ flex: 0 0 auto;
+ color: var(--color-ink-tertiary);
+ transition: transform 160ms ease;
+}
+.consultation-run-timeline[open] > summary > .consultation-run-timeline__chevron,
+.consultation-run-timeline__details[open] > summary > .consultation-run-timeline__chevron {
+ transform: rotate(180deg);
+}
+.consultation-run-timeline__spinner {
+ animation: consultation-run-spin 0.8s linear infinite;
+}
+@keyframes consultation-run-spin {
+ to { transform: rotate(360deg); }
+}
+.consultation-run-timeline__body {
+ display: grid;
+ gap: var(--space-2);
+ margin: var(--space-2) 0 0 28px;
+}
+.consultation-run-timeline__queries {
+ margin: 0;
+ padding-left: 1.2em;
+ color: var(--color-ink-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.5;
+}
+.consultation-run-timeline__sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.consultation-run-timeline__source {
+ border-radius: 999px;
+ padding: 2px 8px;
+ background: var(--color-canvas-muted);
+ color: var(--color-ink-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.4;
+}
+.consultation-run-timeline__thinking {
+ margin: 0;
+ color: var(--color-ink-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.55;
+ white-space: pre-wrap;
+}
+@media (prefers-reduced-motion: reduce) {
+ .consultation-run-timeline__spinner,
+ .consultation-run-timeline__chevron {
+ animation: none;
+ transition: none;
+ }
+}
.message-markdown ul.markdown-list,
.message-markdown ol.markdown-list {
display: grid;
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 44c0ca03..29cb543a 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -101,6 +101,11 @@ import {
membershipHref,
} from "@/lib/membership";
import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
+import {
+ emptyConsultationTimeline,
+ reduceConsultationTimeline,
+ type ConsultationTimelineRow,
+} from "@/lib/consultation-run-timeline";
import {
createNdjsonParser,
type AgentExecutionReceipt,
@@ -232,6 +237,7 @@ type StreamingReply = {
activity?: AgentActivityView;
thinkingText?: string;
thinkingSections?: PublicThinkingSection[];
+ timeline?: readonly ConsultationTimelineRow[];
};
type BirthPlace = {
label: string;
@@ -1214,6 +1220,9 @@ export default function Home() {
const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingSections
: undefined;
+ const activeStreamingTimeline = streamingReply && streamingReply.sessionId === activeSession?.id
+ ? streamingReply.timeline
+ : undefined;
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
@@ -3265,9 +3274,11 @@ export default function Home() {
};
setConsultationPhase("streaming");
}
- setStreamingReply({ sessionId, text: "" });
+ setStreamingReply({ sessionId, text: "", timeline: [] });
let latestPartialReply = "";
let thinkingSections: PublicThinkingSection[] = [];
+ let streamedThinking = "";
+ let timelineState = emptyConsultationTimeline();
try {
const response = await fetch("/api/consult", {
method: "POST",
@@ -3336,7 +3347,9 @@ export default function Home() {
setStreamingReply((current) => ({
sessionId,
text: partialReply,
+ thinkingText: streamedThinking.trim() || undefined,
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
+ timeline: timelineState.rows,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
@@ -3377,7 +3390,19 @@ export default function Home() {
if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) {
const parser = createNdjsonParser((event) => {
+ timelineState = reduceConsultationTimeline(timelineState, event);
if (event.type === "answer.delta") answer += event.text;
+ if (event.type === "thinking.delta" && typeof event.text === "string") {
+ streamedThinking += event.text;
+ setStreamingReply((current) => ({
+ sessionId,
+ text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
+ thinkingText: streamedThinking.trim() || undefined,
+ thinkingSections: current?.sessionId === sessionId ? current.thinkingSections : thinkingSections,
+ timeline: timelineState.rows,
+ activity: current?.sessionId === sessionId ? current.activity : undefined,
+ }));
+ }
if (event.type === "thinking.section") {
thinkingSections = applyThinkingSectionProgress(
upsertThinkingSection(thinkingSections, {
@@ -3391,7 +3416,9 @@ export default function Home() {
setStreamingReply((current) => ({
sessionId,
text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
+ thinkingText: streamedThinking.trim() || undefined,
thinkingSections,
+ timeline: timelineState.rows,
activity: current?.sessionId === sessionId ? current.activity : undefined,
}));
}
@@ -3430,6 +3457,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
+ ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
@@ -3491,6 +3519,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
+ ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
@@ -3535,12 +3564,13 @@ export default function Home() {
if (restore) {
updateSession(sessionId, () => restore);
void persistSession(restore).catch(() => {});
- } else if (thinkingSections.length || latestPartialReply) {
+ } else if (thinkingSections.length || latestPartialReply || streamedThinking.trim()) {
const failedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, {
role: "assistant",
text: latestPartialReply,
+ ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
}],
updatedAt: timestamp(),
@@ -3977,7 +4007,7 @@ export default function Home() {
) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections).map((message, index, views) => {
+ {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections, activeStreamingTimeline).map((message, index, views) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx
index 17667de5..191cf184 100644
--- a/frontend/src/components/chat-message-row.tsx
+++ b/frontend/src/components/chat-message-row.tsx
@@ -3,6 +3,7 @@
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
import { ChatMessageContent } from "@/components/chat-message-content";
+import { ConsultationRunTimeline } from "@/components/consultation-run-timeline";
import { ConsultationThinkingReport } from "@/components/consultation-thinking-report";
import type { ChatMessageView } from "@/lib/chat-message-view";
import { useEffect, useLayoutEffect, useRef } from "react";
@@ -61,9 +62,11 @@ export function ChatMessageRow({
const activityLabel = message.activity?.label
?? (message.state === "thinking" ? "正在处理…" : undefined);
const hasAnswer = Boolean(message.text.trim());
+ const consultTimeline = message.timeline;
const thinkingSections = message.thinkingSections ?? [];
- const showReport = thinkingSections.length > 0;
- const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim()));
+ const showReport = consultTimeline === undefined && thinkingSections.length > 0;
+ const showLiveActivity = showActivity && !showReport && consultTimeline === undefined;
+ const showThinkingPanel = showLiveActivity || (!showReport && consultTimeline === undefined && Boolean(message.thinkingText?.trim()));
const showSpokenAnswer = !showReport && Boolean(message.text);
const stackedThinkingAndAnswer = showThinkingPanel && showSpokenAnswer;
@@ -100,11 +103,11 @@ export function ChatMessageRow({
completedTrail={message.activity?.completedTrail}
thinkingText={message.thinkingText}
hasAnswer={hasAnswer}
- showLive={showActivity}
+ showLive={showLiveActivity}
/>
)
: null;
- const spokenAnswer = showSpokenAnswer
+ const spokenAnswer = showSpokenAnswer || (consultTimeline !== undefined && hasAnswer)
? (
{message.role === "assistant" ? (
- <>
- {showReport && (
-
+
- )}
- {stackedThinkingAndAnswer ? (
-
- {thinkingPanel}
+ {hasAnswer ? (
-
- ) : (
- <>
- {thinkingPanel}
- {spokenAnswer}
- >
- )}
- >
+ ) : null}
+
+ ) : (
+ <>
+ {showReport && (
+
+ )}
+ {stackedThinkingAndAnswer ? (
+
+ {thinkingPanel}
+
+
+ ) : (
+ <>
+ {thinkingPanel}
+ {spokenAnswer}
+ >
+ )}
+ >
+ )
) : {message.text}
}
diff --git a/frontend/src/components/consultation-run-timeline.tsx b/frontend/src/components/consultation-run-timeline.tsx
new file mode 100644
index 00000000..b1fe9270
--- /dev/null
+++ b/frontend/src/components/consultation-run-timeline.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
+
+import type {
+ ConsultationTimelineKind,
+ ConsultationTimelineRow,
+} from "@/lib/consultation-run-timeline";
+
+const KIND_ICONS: Record = {
+ method: BookOpen,
+ calculate: Layers,
+ think: ListTodo,
+ write: PenLine,
+};
+
+export function ConsultationRunTimeline({
+ rows,
+ live = false,
+}: Readonly<{
+ rows: readonly ConsultationTimelineRow[];
+ live?: boolean;
+}>) {
+ if (rows.length === 0) return null;
+
+ return (
+
+
+
+ {live ? "正在分析" : `已完成 ${rows.length} 步`}
+
+
+
+
+ {rows.map((row) => (
+
+ ))}
+
+
+ );
+}
+
+function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
+ const KindIcon = KIND_ICONS[row.kind];
+ const expandable = Boolean(
+ (row.queries && row.queries.length > 0)
+ || (row.sources && row.sources.length > 0)
+ || row.thinkingText?.trim(),
+ );
+ const marker = (
+
+ {row.status === "live"
+ ?
+ : }
+
+ );
+ const label = (
+
+
+ {row.label}
+
+ );
+
+ if (!expandable) {
+ return (
+
+ {marker}
+ {label}
+
+ );
+ }
+
+ return (
+
+
+
+ {marker}
+ {label}
+
+
+
+ {row.queries && row.queries.length > 0 ? (
+
+ {row.queries.map((query) => (
+ - {query}
+ ))}
+
+ ) : null}
+ {row.sources && row.sources.length > 0 ? (
+
+ {row.sources.map((source) => (
+ {source}
+ ))}
+
+ ) : null}
+ {row.thinkingText?.trim() ? (
+
{row.thinkingText}
+ ) : null}
+
+
+
+ );
+}
diff --git a/frontend/src/components/consultation-thinking-report.tsx b/frontend/src/components/consultation-thinking-report.tsx
index ea8bf098..8b978b3f 100644
--- a/frontend/src/components/consultation-thinking-report.tsx
+++ b/frontend/src/components/consultation-thinking-report.tsx
@@ -11,6 +11,7 @@ import {
export function ConsultationThinkingReport({
sections,
answer,
+ thinkingText,
live = false,
liveLabel,
liveState,
@@ -20,6 +21,7 @@ export function ConsultationThinkingReport({
}: Readonly<{
sections: readonly PublicThinkingSection[];
answer: string;
+ thinkingText?: string;
live?: boolean;
liveLabel?: string;
liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping";
@@ -39,6 +41,7 @@ export function ConsultationThinkingReport({
({
id: section.id,
intent: section.title,
diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx
index 157c511a..79809a3c 100644
--- a/frontend/src/components/rectification-agentic-chat.tsx
+++ b/frontend/src/components/rectification-agentic-chat.tsx
@@ -33,7 +33,10 @@ import {
isPublicRectificationMethod,
isPublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
-import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer";
+import {
+ finalizeRectificationSpokenAndThinking,
+ settleRectificationSpokenAndThinking,
+} from "@/lib/rectification-agentic/v9/spoken-answer";
import {
CHOICE_STOP_MESSAGE,
choiceCardUserMessage,
@@ -207,7 +210,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
const thinkingText = split.thinking.trim() || undefined;
return [{
role: "assistant",
- text: split.spoken || raw,
+ text: split.spoken,
...(thinkingText ? { thinkingText } : {}),
renderKey: key,
state: turn.status === "completed" || failed ? "settled" : "thinking",
@@ -461,12 +464,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
- const parsed = parseAgentReply(raw);
+ const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
+ const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: parsed.text,
- state: "streaming",
+ thinkingText: settled.thinking.trim() || undefined,
+ state: parsed.text ? "streaming" : "thinking",
activity: nextActivityView(message.activity, {
phase: "answer-composition",
label: "正在组织回答…",
@@ -475,11 +480,14 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
thinkingRaw += event.text;
+ const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
+ const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
- thinkingText: thinkingRaw,
- state: raw ? "streaming" : "thinking",
+ text: parsed.text,
+ thinkingText: settled.thinking.trim() || undefined,
+ state: parsed.text ? "streaming" : "thinking",
}
: message));
} else if (event.type === "attempt.reset") {
@@ -540,7 +548,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}
- const parsed = parseAgentReply(raw);
+ const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
+ const parsed = parseAgentReply(settled.spoken);
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
@@ -548,6 +557,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
return [{
...message,
text: parsed.text,
+ thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: false,
@@ -555,10 +565,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
activity: undefined,
}];
}
- if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
+ if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text || settled.thinking.trim()) {
return [{
...message,
text: parsed.text,
+ thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: true,
@@ -587,13 +598,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? caught.name === "AbortError"
: caught instanceof Error && caught.name === "AbortError";
if (aborted) {
- const parsed = parseAgentReply(raw);
+ const settled = settleRectificationSpokenAndThinking(raw, thinkingRaw);
+ const parsed = parseAgentReply(settled.spoken);
setMessages((current) => current.flatMap((message): RenderMessage[] => {
if (message.renderKey !== assistantRenderKey) return [message];
- if (parsed.text) {
+ if (parsed.text || settled.thinking.trim()) {
return [{
...message,
text: parsed.text,
+ thinkingText: settled.thinking.trim() || undefined,
state: "settled",
completedReceipt,
failed: false,
diff --git a/frontend/src/components/thinking-step-tree.tsx b/frontend/src/components/thinking-step-tree.tsx
index d2eb70ff..c89c95a3 100644
--- a/frontend/src/components/thinking-step-tree.tsx
+++ b/frontend/src/components/thinking-step-tree.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useId, useState } from "react";
import dynamic from "next/dynamic";
import { Check } from "lucide-react";
import type { OrbState } from "thinking-orbs";
@@ -117,6 +117,7 @@ function StepList({
export function ThinkingStepTree({
caption,
intent,
+ reasoning,
steps = [],
groups,
hiddenCount,
@@ -129,6 +130,7 @@ export function ThinkingStepTree({
}: Readonly<{
caption?: string;
intent?: string;
+ reasoning?: string;
steps?: readonly PublicThinkingStep[];
groups?: readonly ThinkingStepGroup[];
hiddenCount?: number;
@@ -140,6 +142,7 @@ export function ThinkingStepTree({
defaultOpen?: boolean;
}>) {
const [userOpen, setUserOpen] = useState(null);
+ const treeId = useId();
const groupItems = groups ?? [{
id: "default",
intent,
@@ -147,21 +150,43 @@ export function ThinkingStepTree({
live,
}];
const showAll = revealAll || hiddenCount === 0;
+ const stagedCount = groupItems.filter((group) => Boolean(group.intent)).length;
const body = (
<>
- {groupItems.map((group) => (
-
- {group.intent ?
{group.intent}
: null}
-
-
- ))}
+ {groupItems.map((group, index) => {
+ const stageId = `${treeId}-${group.id}-stage`;
+ return (
+
+ {group.intent ? (
+
+ {stagedCount > 1 ? (
+
+ {index + 1}
+
+ ) : (
+
+ )}
+
+ {stagedCount > 1 ? 阶段 {index + 1}: : null}
+ {group.intent}
+
+
+ ) : null}
+
+
+ );
+ })}
>
);
@@ -177,7 +202,12 @@ export function ThinkingStepTree({
}}
>
{caption}
- {body}
+
+ {reasoning?.trim() ? (
+
{reasoning}
+ ) : null}
+ {body}
+
);
}
diff --git a/frontend/src/lib/agent-generation-settings.ts b/frontend/src/lib/agent-generation-settings.ts
index 7935bb99..f18e3602 100644
--- a/frontend/src/lib/agent-generation-settings.ts
+++ b/frontend/src/lib/agent-generation-settings.ts
@@ -1,24 +1,49 @@
/**
* Spoken-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. The compose budget is therefore the
- * visible report only: thinking stays disabled so a Level 2 body is not
- * pinched by a hidden chain of thought. Callers that still need provider
- * thinking must pass it explicitly; they do not inherit this answer budget
- * as a combined thinking-plus-content cap.
+ * DeepSeek hidden reasoning and visible content can share one `max_tokens`
+ * cap. The spoken budget stays 16384; when provider thinking is on, the
+ * output cap is spoken + thinking so CoT cannot pinch the user-facing reply.
+ * Reasoning must travel on `reasoning-delta` / `thinking.delta`, never on
+ * `answer.delta`.
*/
export const AGENT_ANSWER_OUTPUT_TOKENS = 16_384;
+export const AGENT_THINKING_OUTPUT_TOKENS = 8_192;
+export const AGENT_SLICE_ANSWER_OUTPUT_TOKENS = 8_192;
+export const AGENT_SLICE_THINKING_OUTPUT_TOKENS = 2_048;
/** @deprecated Use AGENT_ANSWER_OUTPUT_TOKENS; kept as the compose-budget alias. */
export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS;
-type ThinkingMode = "enabled" | "disabled";
+export type ThinkingMode = "enabled" | "disabled";
+export type ReasoningEffort = "low" | "medium" | "high";
+
+export function agentOutputTokenBudget(
+ thinking: ThinkingMode,
+ options: { answerTokens?: number; thinkingTokens?: number } = {},
+): number {
+ const answerTokens = options.answerTokens ?? AGENT_ANSWER_OUTPUT_TOKENS;
+ if (thinking !== "enabled") return answerTokens;
+ return answerTokens + (options.thinkingTokens ?? AGENT_THINKING_OUTPUT_TOKENS);
+}
export function agentGenerationSettings(
model?: unknown,
- options: { thinking?: ThinkingMode } = {},
+ options: {
+ thinking?: ThinkingMode;
+ answerTokens?: number;
+ thinkingTokens?: number;
+ reasoningEffort?: ReasoningEffort;
+ } = {},
) {
- const thinking = { thinking: { type: (options.thinking ?? "disabled") as ThinkingMode } };
+ const thinkingMode: ThinkingMode = options.thinking ?? "disabled";
+ const thinking = {
+ thinking: {
+ type: thinkingMode,
+ ...(thinkingMode === "enabled" && options.reasoningEffort
+ ? { reasoningEffort: options.reasoningEffort }
+ : {}),
+ },
+ };
const providerId = typeof model === "string"
? model
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
@@ -29,7 +54,12 @@ export function agentGenerationSettings(
};
if (providerId) providerOptions[providerId] = thinking;
return {
- modelSettings: { maxOutputTokens: AGENT_ANSWER_OUTPUT_TOKENS },
+ modelSettings: {
+ maxOutputTokens: agentOutputTokenBudget(thinkingMode, {
+ answerTokens: options.answerTokens,
+ thinkingTokens: options.thinkingTokens,
+ }),
+ },
providerOptions,
};
}
diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts
index b7c52b63..5efdbdd1 100644
--- a/frontend/src/lib/chat-message-view.ts
+++ b/frontend/src/lib/chat-message-view.ts
@@ -1,5 +1,9 @@
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
+import {
+ consultationTimelineFromSettled,
+ type ConsultationTimelineRow,
+} from "./consultation-run-timeline.ts";
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
@@ -62,6 +66,7 @@ export type ChatMessageView = ChatMessage & {
readonly renderKey: string;
readonly state: "settled" | "streaming" | "thinking";
readonly activity?: AgentActivityView;
+ readonly timeline?: readonly ConsultationTimelineRow[];
};
export function chatMessageViews(
@@ -71,11 +76,15 @@ export function chatMessageViews(
activity?: AgentActivityView,
thinkingText?: string,
thinkingSections?: readonly PublicThinkingSection[],
+ timeline?: readonly ConsultationTimelineRow[],
): readonly ChatMessageView[] {
const settled = messages.map((message, index) => ({
...message,
renderKey: `message-${index}`,
state: "settled" as const,
+ ...(message.role === "assistant"
+ ? { timeline: consultationTimelineFromSettled(message) }
+ : {}),
}));
if (!loading || messages.at(-1)?.role === "assistant") return settled;
@@ -86,6 +95,7 @@ export function chatMessageViews(
text: streamingText,
thinkingText,
thinkingSections,
+ timeline: timeline ?? [],
renderKey: `message-${messages.length}`,
state: streamingText ? "streaming" : "thinking",
activity,
diff --git a/frontend/src/lib/consultation-activity-labels.ts b/frontend/src/lib/consultation-activity-labels.ts
index ba976ef1..d61538df 100644
--- a/frontend/src/lib/consultation-activity-labels.ts
+++ b/frontend/src/lib/consultation-activity-labels.ts
@@ -9,3 +9,8 @@ export function chartCalculationProgressLabel(current: number, total: number): s
if (total <= 1) return CONSULTATION_CHART_CALCULATION_LABEL;
return `正在计算本命盘(第 ${current}/${total} 项)…`;
}
+
+export function consultationWriteLabel(heading: string, live: boolean): string {
+ const title = heading.trim() || "回答";
+ return live ? `正在写${title}…` : `写${title}`;
+}
diff --git a/frontend/src/lib/consultation-run-timeline.ts b/frontend/src/lib/consultation-run-timeline.ts
new file mode 100644
index 00000000..827f1ad2
--- /dev/null
+++ b/frontend/src/lib/consultation-run-timeline.ts
@@ -0,0 +1,361 @@
+import type { AgentExecutionReceipt, ConsultationAgentPublicEvent } from "./consultation-agent-events.ts";
+import {
+ CONSULTATION_CHART_CALCULATION_LABEL,
+ CONSULTATION_COMPOSING_LABEL,
+ CONSULTATION_DONE_CHART_LABEL,
+ CONSULTATION_DONE_SKILL_LABEL,
+ CONSULTATION_LOADING_METHOD_LABEL,
+ consultationWriteLabel,
+} from "./consultation-activity-labels.ts";
+import {
+ consultationDomainDefinition,
+ normalizeConsultationDomain,
+} from "./consultation-domain-registry.ts";
+import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
+
+export const CONSULTATION_TIMELINE_KINDS = ["method", "calculate", "think", "write"] as const;
+export type ConsultationTimelineKind = (typeof CONSULTATION_TIMELINE_KINDS)[number];
+export type ConsultationTimelineStatus = "live" | "done";
+
+export type ConsultationTimelineRow = Readonly<{
+ id: string;
+ kind: ConsultationTimelineKind;
+ status: ConsultationTimelineStatus;
+ label: string;
+ queries?: readonly string[];
+ sources?: readonly string[];
+ thinkingText?: string;
+}>;
+
+export type ConsultationTimelineState = Readonly<{
+ rows: readonly ConsultationTimelineRow[];
+ answer: string;
+}>;
+
+const METHOD_ID = "method";
+const CALCULATE_ID = "calculate";
+const OPEN_THINK_ID = "think-open";
+
+export function emptyConsultationTimeline(): ConsultationTimelineState {
+ return { rows: [], answer: "" };
+}
+
+export function reduceConsultationTimeline(
+ state: ConsultationTimelineState,
+ event: ConsultationAgentPublicEvent,
+): ConsultationTimelineState {
+ if (event.type === "skill.started") {
+ return upsertRow(state, {
+ id: METHOD_ID,
+ kind: "method",
+ status: "live",
+ label: CONSULTATION_LOADING_METHOD_LABEL,
+ });
+ }
+ if (event.type === "skill.completed") {
+ return completeRow(state, METHOD_ID, CONSULTATION_DONE_SKILL_LABEL);
+ }
+ if (event.type === "tool.started") {
+ return upsertRow(completeLiveThink(state), {
+ id: CALCULATE_ID,
+ kind: "calculate",
+ status: "live",
+ label: event.label || CONSULTATION_CHART_CALCULATION_LABEL,
+ });
+ }
+ if (event.type === "activity") {
+ if (event.phase === "chart-calculation") {
+ return patchRow(state, CALCULATE_ID, {
+ status: "live",
+ label: event.label,
+ }, {
+ id: CALCULATE_ID,
+ kind: "calculate",
+ status: "live",
+ label: event.label,
+ });
+ }
+ if (event.phase === "evidence-validation") {
+ return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
+ }
+ if (event.phase === "answer-composition") {
+ return upsertWrite(completeLiveThink(state), event.label);
+ }
+ return state;
+ }
+ if (event.type === "tool.completed") {
+ return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
+ }
+ if (event.type === "tool.failed") {
+ return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
+ }
+ if (event.type === "thinking.section") {
+ const section: PublicThinkingSection = {
+ id: event.id,
+ title: event.title,
+ heading: event.heading,
+ steps: event.steps,
+ };
+ const withCalc = enrichCalculate(completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL), section);
+ const withoutOpen = completeRow(withCalc, OPEN_THINK_ID);
+ return upsertRow(withoutOpen, {
+ id: `think-${section.id}`,
+ kind: "think",
+ status: "live",
+ label: consultationThinkTitle(section.title),
+ });
+ }
+ if (event.type === "thinking.delta") {
+ const think = lastRow(state.rows, (row) => row.kind === "think" && row.status === "live")
+ ?? {
+ id: OPEN_THINK_ID,
+ kind: "think" as const,
+ status: "live" as const,
+ label: "正在分析…",
+ };
+ const thinkingText = `${think.thinkingText ?? ""}${event.text}`.slice(0, 4_000);
+ return upsertRow(state, { ...think, status: "live", thinkingText });
+ }
+ if (event.type === "answer.delta") {
+ const next = { ...completeLiveThink(state), answer: `${state.answer}${event.text}` };
+ return syncWriteRows(next, false);
+ }
+ if (event.type === "run.completed" || event.type === "run.failed") {
+ const next = syncWriteRows(completeLiveThink(state), true);
+ return {
+ ...next,
+ rows: next.rows.map((row) => (
+ row.status === "done" ? row : { ...row, status: "done" as const, label: doneLabel(row) }
+ )),
+ };
+ }
+ return state;
+}
+
+export function reduceConsultationTimelineEvents(
+ events: readonly ConsultationAgentPublicEvent[],
+ state = emptyConsultationTimeline(),
+): ConsultationTimelineState {
+ return events.reduce(reduceConsultationTimeline, state);
+}
+
+export function consultationTimelineFromSettled(input: {
+ text?: string;
+ thinkingText?: string;
+ thinkingSections?: readonly PublicThinkingSection[];
+ agentExecutionReceipt?: AgentExecutionReceipt;
+}): ConsultationTimelineRow[] {
+ const text = input.text?.trim() ?? "";
+ const sections = input.thinkingSections ?? [];
+ const receipt = input.agentExecutionReceipt;
+ if (!text && !sections.length && !input.thinkingText?.trim() && !receipt) return [];
+
+ const events: ConsultationAgentPublicEvent[] = [
+ { type: "skill.started", name: "jyotish-vedic-astrology" },
+ { type: "skill.completed", name: "jyotish-vedic-astrology" },
+ ];
+ const calculated = receipt?.steps.some((step) => step.kind === "tool")
+ || sections.some((section) => section.id.startsWith("domain-"));
+ if (calculated) {
+ events.push({
+ type: "tool.started",
+ callId: "settled",
+ tool: "run-jyotish-consultation",
+ label: CONSULTATION_CHART_CALCULATION_LABEL,
+ });
+ events.push({
+ type: "tool.completed",
+ callId: "settled",
+ tool: "run-jyotish-consultation",
+ status: "ready",
+ durationMs: 0,
+ });
+ }
+ for (const section of sections) {
+ events.push({ type: "thinking.section", ...section });
+ }
+ if (input.thinkingText?.trim() && sections.length === 0) {
+ events.push({ type: "thinking.delta", text: input.thinkingText });
+ }
+ if (text) events.push({ type: "answer.delta", text });
+ events.push({
+ type: "run.completed",
+ receipt: receipt ?? {
+ runId: "settled",
+ runtime: "mastra-agentic",
+ skill: {
+ name: "jyotish-vedic-astrology",
+ loaded: true,
+ referenceReads: 0,
+ methodologySections: 0,
+ },
+ steps: [],
+ workflow: { route: "settled", status: "ready", preciseTiming: "blocked", missingLayers: [] },
+ },
+ });
+ let state = reduceConsultationTimelineEvents(events);
+ if (input.thinkingText?.trim() && sections.length > 0) {
+ const lastThink = [...state.rows].reverse().find((row) => row.kind === "think");
+ if (lastThink) {
+ state = upsertRow(state, { ...lastThink, thinkingText: input.thinkingText.slice(0, 4_000) });
+ }
+ }
+ return state.rows;
+}
+
+export function consultationThinkTitle(title: string): string {
+ const cleaned = title.replace(/\s+/g, " ").trim();
+ if (cleaned.length <= 20) return cleaned || "正在分析";
+ return cleaned.slice(0, 20);
+}
+
+function answerHeadings(text: string): string[] {
+ return [...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? "").filter(Boolean);
+}
+
+function queriesFromSection(section: PublicThinkingSection): string[] {
+ const domain = section.id.startsWith("domain-")
+ ? normalizeConsultationDomain(section.id.slice("domain-".length))
+ : null;
+ if (!domain) return [];
+ const definition = consultationDomainDefinition(domain);
+ return uniqueLabels([definition.label, ...definition.evidencePreview]).slice(0, 8);
+}
+
+function sourcesFromSection(section: PublicThinkingSection): string[] {
+ return uniqueLabels(section.steps
+ .map((step) => step.label.replace(/^对照\s+/, "").trim())
+ .filter((label) => label.length > 0 && label.length <= 24)).slice(0, 8);
+}
+
+function uniqueLabels(values: readonly string[]): string[] {
+ const seen = new Set();
+ const kept: string[] = [];
+ for (const value of values) {
+ const label = value.replace(/\s+/g, " ").trim();
+ if (!label || seen.has(label)) continue;
+ seen.add(label);
+ kept.push(label);
+ }
+ return kept;
+}
+
+function lastRow(
+ rows: readonly ConsultationTimelineRow[],
+ match: (row: ConsultationTimelineRow) => boolean,
+): ConsultationTimelineRow | undefined {
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
+ const row = rows[index];
+ if (row && match(row)) return row;
+ }
+ return undefined;
+}
+
+function upsertRow(state: ConsultationTimelineState, row: ConsultationTimelineRow): ConsultationTimelineState {
+ const index = state.rows.findIndex((item) => item.id === row.id);
+ if (index < 0) return { ...state, rows: [...state.rows, row] };
+ return {
+ ...state,
+ rows: state.rows.map((item, current) => (current === index ? { ...item, ...row } : item)),
+ };
+}
+
+function patchRow(
+ state: ConsultationTimelineState,
+ id: string,
+ patch: Partial,
+ create?: ConsultationTimelineRow,
+): ConsultationTimelineState {
+ const existing = state.rows.find((row) => row.id === id);
+ if (!existing) return create ? upsertRow(state, create) : state;
+ return upsertRow(state, { ...existing, ...patch, id, kind: existing.kind });
+}
+
+function completeRow(
+ state: ConsultationTimelineState,
+ id: string,
+ label?: string,
+): ConsultationTimelineState {
+ const existing = state.rows.find((row) => row.id === id);
+ if (!existing) return state;
+ return upsertRow(state, {
+ ...existing,
+ status: "done",
+ label: label ?? doneLabel(existing),
+ });
+}
+
+function completeLiveThink(state: ConsultationTimelineState): ConsultationTimelineState {
+ return {
+ ...state,
+ rows: state.rows.map((row) => (
+ row.kind === "think" && row.status === "live"
+ ? { ...row, status: "done" as const, label: doneLabel(row) }
+ : row
+ )),
+ };
+}
+
+function doneLabel(row: ConsultationTimelineRow): string {
+ if (row.kind === "method") return CONSULTATION_DONE_SKILL_LABEL;
+ if (row.kind === "calculate") return CONSULTATION_DONE_CHART_LABEL;
+ if (row.kind === "write") {
+ const heading = row.id.startsWith("write-") ? row.id.slice("write-".length) : "";
+ return heading ? consultationWriteLabel(heading, false) : row.label.replace(/…$/, "").replace(/^正在/, "");
+ }
+ return row.label.replace(/…$/, "").replace(/^正在/, "") || row.label;
+}
+
+function upsertWrite(state: ConsultationTimelineState, liveLabel: string): ConsultationTimelineState {
+ const headings = answerHeadings(state.answer);
+ const heading = headings.at(-1);
+ if (!heading) {
+ return upsertRow(state, {
+ id: "write-open",
+ kind: "write",
+ status: "live",
+ label: liveLabel || CONSULTATION_COMPOSING_LABEL,
+ });
+ }
+ return syncWriteRows(state, false);
+}
+
+function syncWriteRows(state: ConsultationTimelineState, settled: boolean): ConsultationTimelineState {
+ const headings = answerHeadings(state.answer);
+ if (headings.length === 0) {
+ if (!state.answer.trim() && !settled) return state;
+ if (!state.answer.trim()) return state;
+ return upsertRow(completeRow(state, "write-open"), {
+ id: "write-open",
+ kind: "write",
+ status: settled ? "done" : "live",
+ label: settled ? "组织回答" : CONSULTATION_COMPOSING_LABEL,
+ });
+ }
+ let next = completeRow(state, "write-open");
+ next = {
+ ...next,
+ rows: next.rows.filter((row) => row.id !== "write-open"),
+ };
+ headings.forEach((heading, index) => {
+ const last = index === headings.length - 1;
+ next = upsertRow(next, {
+ id: `write-${heading}`,
+ kind: "write",
+ status: settled || !last ? "done" : "live",
+ label: consultationWriteLabel(heading, !settled && last),
+ });
+ });
+ return next;
+}
+
+function enrichCalculate(
+ state: ConsultationTimelineState,
+ section: PublicThinkingSection,
+): ConsultationTimelineState {
+ const calculate = state.rows.find((row) => row.id === CALCULATE_ID);
+ if (!calculate) return state;
+ const queries = uniqueLabels([...(calculate.queries ?? []), ...queriesFromSection(section)]).slice(0, 8);
+ const sources = uniqueLabels([...(calculate.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8);
+ return upsertRow(state, { ...calculate, queries, sources });
+}
diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts
index 6d655de3..f2064d7d 100644
--- a/frontend/src/lib/consultation-thinking-plan.ts
+++ b/frontend/src/lib/consultation-thinking-plan.ts
@@ -76,6 +76,16 @@ export function consultationReportHeadings(domains: readonly ConsultationDomain[
];
}
+export function consultationComposeHeadingGroups(
+ plan: readonly PublicThinkingSection[],
+): Array<{ section: PublicThinkingSection; headings: readonly string[] }> {
+ return plan.map((section) => (
+ section.id === "close"
+ ? { section, headings: [REPORT_HEADING.audit, REPORT_HEADING.wrap] }
+ : { section, headings: [section.heading] }
+ ));
+}
+
export function natalConsultationThinkingPlan(input: {
domains: readonly ConsultationDomain[];
requiredBlocks?: readonly string[];
@@ -285,3 +295,15 @@ export function consultationContinuePrompt(output: string): string {
output.slice(-800),
].join("\n");
}
+
+export function consultationSectionPrompt(heading: string, priorOutput: string): string {
+ const title = heading.trim() || REPORT_HEADING.foundation;
+ return [
+ "服务器计算已经完成。不要再调用排盘工具,不要重算,不要读取其他二级标题。",
+ `只写这一个二级标题及其正文:## ${title}`,
+ "不要写其他 ## 标题,不要复述已经写出的段落,不要写思考过程清单。",
+ priorOutput.trim()
+ ? `已经写出的上文(冻结,勿重复):\n${priorOutput.slice(-4000)}`
+ : "这是正文的第一节。",
+ ].join("\n");
+}
diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run.ts b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
index a2f506cb..2e8fb92a 100644
--- a/frontend/src/lib/rectification-agentic/v9/agent-run.ts
+++ b/frontend/src/lib/rectification-agentic/v9/agent-run.ts
@@ -35,11 +35,7 @@ import {
isPublicRectificationToolName,
type PublicStreamEvent,
} from "./stream-mapping";
-import {
- finalizeRectificationSpokenAndThinking,
- nextStableChannelDelta,
- splitRectificationSpokenAndThinking,
-} from "./spoken-answer";
+import { splitRectificationSpokenAndThinking } from "./spoken-answer";
export type V9RunBilling = Readonly<{
reserve(): Promise<{ success: boolean; reason?: string; status: number }>;
@@ -473,9 +469,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise();
const events: PublicStreamEvent[] = [];
@@ -517,7 +510,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise Promise,
- emit: (event: PublicStreamEvent) => Promise | void,
- finalize = false,
-): Promise<{ thinking: string; spoken: string; spokenDelta: string }> {
- const split = finalize
- ? finalizeRectificationSpokenAndThinking(buffer)
- : splitRectificationSpokenAndThinking(buffer);
- const thinkingDelta = nextStableChannelDelta(publishedThinking, split.thinking);
- const thinking = split.thinking.startsWith(publishedThinking) ? split.thinking : publishedThinking;
- if (thinkingDelta) {
- const thinkingEvent = toPublicThinkingDelta(thinkingDelta);
- if (thinkingEvent) await publish(thinkingEvent);
- }
- const spokenDelta = nextStableChannelDelta(publishedSpoken, split.spoken);
- const spoken = split.spoken.startsWith(publishedSpoken) ? split.spoken : publishedSpoken;
- const emitSpoken = Boolean(spokenDelta) && Boolean(answerText.trim() || spokenDelta.trim());
- if (emitSpoken) await emit({ type: "answer.delta", text: spokenDelta });
- return {
- thinking,
- spoken,
- spokenDelta: emitSpoken ? spokenDelta : "",
- };
-}
-
export { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION };
diff --git a/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts
index dfdf7c01..fc178957 100644
--- a/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts
+++ b/frontend/src/lib/rectification-agentic/v9/spoken-answer.ts
@@ -1,16 +1,15 @@
/**
- * Separate process narration from the user-facing rectification reply.
+ * Legacy and leak-net helpers for rectification replies.
*
- * Provider thinking stays disabled so hidden CoT cannot pinch the spoken
- * budget. The model still dumps Chinese self-talk onto text-delta; this
- * splitter routes that talk onto `thinking.delta` and keeps only the spoken
- * conclusion on `answer.delta`.
+ * Live thinking comes from provider `reasoning-delta`. This module only:
+ * - recovers old turns that stored process talk in `assistant_message`
+ * - drops process-only `text-delta` leaks so they cannot complete as the reply
*/
const CJK_RE = /[\u4e00-\u9fff]/;
const INTERNAL_TOKEN_RE = /\b(?:datePrecision|occurredFrom|occurredTo|proposedKind|education_start|missing_evidence|SKILL\.md|rectification-[a-z0-9-]+|focusId|evidenceId|display_date_label)\b/;
-const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch)|权衡:|内部矛盾|思维链|调用 batch|datePrecision|occurredFrom|occurredTo/;
-const THIRD_PERSON_USER_RE = /用户(?:提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份)/;
+const PROCESS_ZH_RE = /skill\s*规则|不得猜补|让我(?:调用|记录|batch|提交)|我(?:决定|倾向|batch|需要用)|权衡:|内部矛盾|思维链|调用 batch|批量工具|写入(?:这些)?证据|datePrecision|occurredFrom|occurredTo/;
+const THIRD_PERSON_USER_RE = /用户(?:在上|提到|先(?:说|提到)|说|自己|的核心|想表达|原话|的最终|对年份|提供了)/;
export type SplitSpokenAndThinking = Readonly<{
thinking: string;
@@ -68,10 +67,17 @@ export function finalizeRectificationSpokenAndThinking(text: string): SplitSpoke
const split = splitRectificationSpokenAndThinking(text);
if (split.spoken.trim()) return split;
if (!split.thinking.trim()) return { thinking: "", spoken: text };
- const paragraphs = splitParagraphs(split.thinking);
- if (paragraphs.length < 2) return { thinking: "", spoken: text };
- return {
- thinking: paragraphs.slice(0, -1).join("\n\n"),
- spoken: paragraphs.at(-1) ?? text,
- };
+ return { thinking: split.thinking, spoken: "" };
+}
+
+export function settleRectificationSpokenAndThinking(
+ answerRaw: string,
+ thinkingRaw = "",
+): SplitSpokenAndThinking {
+ const leak = splitRectificationSpokenAndThinking(answerRaw);
+ const channelThinking = thinkingRaw.trim();
+ const spoken = leak.spoken.trim();
+ if (channelThinking) return { thinking: channelThinking, spoken };
+ if (!spoken && leak.thinking.trim()) return { thinking: leak.thinking, spoken: "" };
+ return { thinking: leak.thinking, spoken: spoken || (!leak.thinking ? answerRaw.trim() : "") };
}
diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts
index b50e4a6b..f30e0f34 100644
--- a/frontend/src/lib/stream-agent-response.ts
+++ b/frontend/src/lib/stream-agent-response.ts
@@ -11,8 +11,11 @@ import {
} from "./consultation-agent-events.ts";
import { toAgentModelFinishReason } from "./agent-observability.ts";
import { createVisibleTextTransformer } from "./stream-text-response.ts";
+import { consultationWriteLabel } from "./consultation-activity-labels.ts";
+import { sanitizePublicThinkingText } from "./public-thinking.ts";
import {
applyThinkingSectionProgress,
+ consultationComposeHeadingGroups,
generalConsultationThinkingPlan,
type PublicThinkingSection,
} from "./consultation-thinking-plan.ts";
@@ -248,6 +251,10 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable Promise;
retryForAnswer?: () => Promise;
continueAfterLength?: (output: string) => Promise;
+ composeSection?: (heading: string, priorOutput: string) => Promise;
continueAfterDisconnect?: boolean;
headers?: HeadersInit;
onFirstActivity?: () => void | Promise;
@@ -297,6 +305,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let firstActivity = false;
let firstOutput = false;
let fullOutput = "";
+ let thinkingText = "";
let planSent = false;
const startedAt = new Map();
// A retry reuses these counters so a failure in either attempt is recorded once.
@@ -310,6 +319,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
};
const flushThinkingPlan = (controller: ReadableStreamDefaultController | undefined) => {
if (planSent) return;
+ if (options.composeSection && (options.state.thinkingPlan?.length ?? 0) > 0) return;
if (!options.requireTool && !options.state.thinkingPlan?.length) {
options.state.thinkingPlan = generalConsultationThinkingPlan();
}
@@ -319,17 +329,22 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of planned) send(controller, event);
};
- async function consumeAttempt(controller: ReadableStreamDefaultController | undefined, stream: ChunkStream) {
+ async function consumeAttempt(
+ controller: ReadableStreamDefaultController | undefined,
+ stream: ChunkStream,
+ attempt: { drainSpoken?: boolean; suppressCompositionActivity?: boolean } = {},
+ ) {
const visible = createVisibleTextTransformer(options.transformText ?? ((value) => value));
let held = "";
- let composingSent = false;
+ let composingSent = Boolean(attempt.suppressCompositionActivity);
+ const drainingSpoken = () => Boolean(attempt.drainSpoken) && contractReady(options);
const outputText = async (text: string) => {
// Text the model writes before the contract is ready is not the answer: it
// is the model narrating its own in-progress or failed tool calls. Holding
// it meant a later successful call released that narration as the entire
// visible answer, so a run where the model recovered read as a run where it
// explained itself instead of answering. Drop it.
- if (!contractReady(options)) return;
+ if (!contractReady(options) || drainingSpoken()) return;
held += text;
if (!held) return;
if (!composingSent) {
@@ -358,6 +373,14 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
options.state.modelFinishReason = finish.reason;
if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount;
}
+ if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
+ if (drainingSpoken()) continue;
+ const thinking = sanitizePublicThinkingText(chunk.payload.text);
+ if (thinking) {
+ thinkingText += thinking;
+ send(controller, { type: "thinking.delta", text: thinking });
+ }
+ }
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
await outputText(visible.push(chunk.payload.text));
}
@@ -372,6 +395,58 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
}
}
+ async function continueCurrentAnswer(
+ controller: ReadableStreamDefaultController | undefined,
+ heading?: string,
+ ) {
+ if (options.state.modelFinishReason !== "length") return;
+ if (!options.continueAfterLength) throw new Error("answer_truncated");
+ const beforeContinue = fullOutput;
+ appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-continue", status: "completed" });
+ send(controller, {
+ type: "activity",
+ phase: "answer-composition",
+ label: heading ? consultationWriteLabel(heading, true) : "正在组织回答",
+ });
+ await consumeAttempt(controller, await options.continueAfterLength(fullOutput), {
+ suppressCompositionActivity: true,
+ });
+ if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
+ if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
+ throw new Error("answer_truncated");
+ }
+ }
+
+ async function composeByHeadings(controller: ReadableStreamDefaultController | undefined) {
+ const plan = options.state.thinkingPlan ?? [];
+ if (!options.composeSection || plan.length === 0) return false;
+ let lastSectionId: string | undefined;
+ for (const group of consultationComposeHeadingGroups(plan)) {
+ for (const heading of group.headings) {
+ if (lastSectionId !== group.section.id) {
+ lastSectionId = group.section.id;
+ planSent = true;
+ send(controller, consultationAgentPublicEventSchema.parse({
+ type: "thinking.section",
+ ...group.section,
+ }));
+ }
+ send(controller, {
+ type: "activity",
+ phase: "answer-composition",
+ label: consultationWriteLabel(heading, true),
+ });
+ await consumeAttempt(
+ controller,
+ await options.composeSection(heading, fullOutput),
+ { suppressCompositionActivity: true },
+ );
+ await continueCurrentAnswer(controller, heading);
+ }
+ }
+ return true;
+ }
+
const body = new ReadableStream({
start(controller) {
void (async () => {
@@ -379,13 +454,18 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
for (const event of skillBoundEvents) send(controller, event);
if (!options.requireTool) flushThinkingPlan(controller);
try {
- await consumeAttempt(controller, options.stream);
+ await consumeAttempt(controller, options.stream, {
+ drainSpoken: Boolean(options.composeSection),
+ });
if (!contractReady(options) && options.retry) {
appendConsultationRuntimeStep(options.state, { kind: "validation", name: "runtime-contract-retry", status: "completed" });
send(controller, { type: "activity", phase: "loading-method", label: "正在补齐方法与计算步骤" });
- await consumeAttempt(controller, await options.retry());
+ await consumeAttempt(controller, await options.retry(), {
+ drainSpoken: Boolean(options.composeSection),
+ });
}
if (!contractReady(options)) throw new Error("runtime_contract_incomplete");
+ const sliced = await composeByHeadings(controller);
// A run whose calculation succeeded and whose model then wrote nothing
// used to be answered with a fixed apology and billed as a completed
// consultation: the user paid for a sentence saying there was nothing
@@ -404,25 +484,14 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
// A spoken answer that stopped because the token budget ran out is
// not a completed consultation. Continue once from the last complete
// heading, still with thinking disabled, before treating it as a pinch.
- if (options.state.modelFinishReason === "length" && options.continueAfterLength) {
- const beforeContinue = fullOutput;
- appendConsultationRuntimeStep(options.state, { kind: "validation", name: "answer-continue", status: "completed" });
- send(controller, { type: "activity", phase: "answer-composition", label: "正在组织回答" });
- await consumeAttempt(controller, await options.continueAfterLength(fullOutput));
- if (!/\S/.test(fullOutput)) throw new Error("empty_answer");
- if (options.state.modelFinishReason === "length" && fullOutput === beforeContinue) {
- throw new Error("answer_truncated");
- }
- } else if (options.state.modelFinishReason === "length") {
- throw new Error("answer_truncated");
- }
+ if (!sliced) await continueCurrentAnswer(controller);
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
const thinkingSections = applyThinkingSectionProgress(options.state.thinkingPlan ?? [], fullOutput);
await options.onComplete?.(
fullOutput,
receipt,
- undefined,
+ thinkingText.trim() || undefined,
thinkingSections.length > 0 ? thinkingSections : undefined,
);
settled = true;
diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts
index 80c84f75..d59c3e73 100644
--- a/frontend/src/mastra/consultation-tools.ts
+++ b/frontend/src/mastra/consultation-tools.ts
@@ -14,7 +14,7 @@ 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 { agentGenerationSettings, AGENT_SLICE_ANSWER_OUTPUT_TOKENS, AGENT_SLICE_THINKING_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
import { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts";
import {
natalConsultationThinkingPlan,
@@ -52,6 +52,7 @@ export { AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS } from "../li
// 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 AGENT_SLICE_MAX_STEPS = 1;
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;
@@ -61,9 +62,22 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
);
export function consultationGenerationSettings(model?: unknown) {
+ return agentGenerationSettings(model, { thinking: "enabled" });
+}
+
+export function consultationContinueGenerationSettings(model?: unknown) {
return agentGenerationSettings(model, { thinking: "disabled" });
}
+export function consultationSliceGenerationSettings(model?: unknown) {
+ return agentGenerationSettings(model, {
+ thinking: "enabled",
+ answerTokens: AGENT_SLICE_ANSWER_OUTPUT_TOKENS,
+ thinkingTokens: AGENT_SLICE_THINKING_OUTPUT_TOKENS,
+ reasoningEffort: "low",
+ });
+}
+
// The raw plan bound stays at the registry default so a duplicate-heavy list
// canonicalizes instead of failing outright. The executable cap is enforced
// after canonicalization, where it can degrade and disclose rather than throw.
diff --git a/frontend/tests/agent-activity-progress.test.ts b/frontend/tests/agent-activity-progress.test.ts
index 53bbcf52..5fce86de 100644
--- a/frontend/tests/agent-activity-progress.test.ts
+++ b/frontend/tests/agent-activity-progress.test.ts
@@ -26,9 +26,9 @@ test("generation settings reserve spoken-answer tokens and disable thinking by d
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "disabled" } });
});
-test("callers can enable a separate thinking channel without changing the answer budget", () => {
+test("callers can enable a separate thinking channel without pinching the answer budget", () => {
const settings = agentGenerationSettings({ providerId: "deepseek" }, { thinking: "enabled" });
- assert.equal(settings.modelSettings.maxOutputTokens, 16_384);
+ assert.equal(settings.modelSettings.maxOutputTokens, 16_384 + 8_192);
assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "enabled" } });
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "enabled" } });
});
diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts
index 282dcf89..959290c2 100644
--- a/frontend/tests/chat-stream-layout.test.ts
+++ b/frontend/tests/chat-stream-layout.test.ts
@@ -9,6 +9,9 @@ const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.
const messageRowSource = readFileSync(new URL("../src/components/chat-message-row.tsx", import.meta.url), "utf8");
const activitySource = readFileSync(new URL("../src/components/agent-activity-status.tsx", import.meta.url), "utf8");
const reportSource = readFileSync(new URL("../src/components/consultation-thinking-report.tsx", import.meta.url), "utf8");
+const treeSource = readFileSync(new URL("../src/components/thinking-step-tree.tsx", import.meta.url), "utf8");
+const timelineSource = readFileSync(new URL("../src/components/consultation-run-timeline.tsx", import.meta.url), "utf8");
+const reducerSource = readFileSync(new URL("../src/lib/consultation-run-timeline.ts", import.meta.url), "utf8");
const previousMessages = [
{ role: "user", text: "问题" },
@@ -93,10 +96,17 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /application\/x-ndjson/);
assert.match(pageSource, /createNdjsonParser/);
assert.match(pageSource, /event.type === "thinking.section"/);
+ assert.match(pageSource, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingSections/);
+ assert.match(pageSource, /streamedThinking/);
+ assert.match(messageRowSource, /showLiveActivity/);
assert.match(messageRowSource, /ConsultationThinkingReport/);
+ assert.match(messageRowSource, /thinkingText=\{message\.thinkingText\}/);
+ assert.match(messageRowSource, /showLiveActivity \|\| \(!showReport && consultTimeline === undefined && Boolean\(message\.thinkingText/);
assert.match(reportSource, /caption="思考"/);
+ assert.match(reportSource, /reasoning=\{thinkingText\}/);
assert.match(reportSource, /aria-label="分析"/);
+ assert.match(treeSource, /consultation-step-tree__reasoning/);
assert.match(reportSource, /revealAll/);
assert.match(reportSource, /groups=\{progressed\.map/);
assert.doesNotMatch(reportSource, /analysisForSection|splitAnswerByHeadings/);
@@ -109,9 +119,29 @@ test("shows honest agent activity states before and during streamed text", () =>
assert.match(pageSource, /agentExecutionReceipt = event\.receipt/);
assert.match(pageSource, /const failedSession: ChatSession/);
assert.match(pageSource, /\.\.\.\(thinkingSections\.length \? \{ thinkingSections \} : \{\}\)/);
+ assert.match(pageSource, /reduceConsultationTimeline/);
+ assert.match(pageSource, /timeline: timelineState\.rows/);
+ assert.match(pageSource, /activeStreamingTimeline/);
+ assert.match(messageRowSource, /ConsultationRunTimeline/);
+ assert.match(messageRowSource, /message\.timeline/);
+ assert.match(timelineSource, /BookOpen/);
+ assert.match(timelineSource, /Layers/);
+ assert.match(timelineSource, /ListTodo/);
+ assert.match(timelineSource, /PenLine/);
+ assert.match(timelineSource, /LoaderCircle/);
+ assert.match(timelineSource, /data-kind=\{row\.kind\}/);
+ assert.doesNotMatch(timelineSource, /thinking-orbs|Globe|Cloud|favicon/i);
+ assert.match(globalStyles, /\.consultation-run-timeline/);
+ assert.match(reducerSource, /kind: "method"/);
+ assert.match(reducerSource, /kind: "calculate"/);
assert.match(globalStyles, /\.consultation-report-analysis/);
assert.match(globalStyles, /\.consultation-thinking-report/);
assert.match(globalStyles, /\.consultation-thinking-report \.message-thinking/);
+ assert.match(treeSource, /consultation-step-tree__stage/);
+ assert.match(treeSource, /阶段 \{index \+ 1\}/);
+ assert.doesNotMatch(treeSource, /consultation-step-tree__intent/);
+ assert.match(globalStyles, /\.consultation-step-tree__stage-index/);
+ assert.match(globalStyles, /\.consultation-step-tree__group \+ \.consultation-step-tree__group[^}]*margin-top:\s*var\(--space-6\)/);
});
test("nothing sits between the transcript and the composer to shift height while streaming", () => {
diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts
index 4162ccd4..f1f4499d 100644
--- a/frontend/tests/consultation-agentic-runtime.test.ts
+++ b/frontend/tests/consultation-agentic-runtime.test.ts
@@ -10,6 +10,7 @@ import {
appendConsultationRuntimeStep,
canonicalDomainPlan,
consultationGenerationSettings,
+ consultationSliceGenerationSettings,
consultationModelStepTelemetry,
consultationStepBudgetReceipt,
consultationToolFailureCode,
@@ -26,7 +27,7 @@ import {
} from "../src/mastra/consultation-workflow.ts";
import { agentExecutionReceiptSchema } from "../src/lib/consultation-agent-events.ts";
import { consultationDomainIds, consultationDomainPlanValues } from "../src/lib/consultation-domain-registry.ts";
-import { natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
+import { natalConsultationThinkingPlan, consultationComposeHeadingGroups, REPORT_HEADING } from "../src/lib/consultation-thinking-plan.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
@@ -881,7 +882,7 @@ test("model answer text cannot forge a public Activity event", async () => {
assert.equal(events.find((event) => event.type === "answer.delta")?.text, forged);
});
-test("Chinese reasoning is not a public thinking channel", async () => {
+test("Chinese reasoning is a public thinking channel and stays off the spoken answer", async () => {
const events = await collectAgentPublicEvents([
{ type: "reasoning-delta", payload: { text: "The proposedKind value was rejected" } },
{ type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } },
@@ -893,7 +894,10 @@ test("Chinese reasoning is not a public thinking channel", async () => {
steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] },
}),
});
- assert.equal(events.filter((event) => event.type === "thinking.delta").length, 0);
+ assert.deepEqual(
+ events.filter((event) => event.type === "thinking.delta"),
+ [{ type: "thinking.delta", text: "先看事业宫的结构。" }],
+ );
assert.deepEqual(
events.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "事业方向的判断如下。" }],
@@ -1332,6 +1336,106 @@ test("a calculated run publishes thinking.section without tool ids", async () =>
assert.doesNotMatch(JSON.stringify(sections), /run-jyotish/);
});
+test("sliced compose drains leftover first-stream text and writes one heading per slice", async () => {
+ const state = toolOnlyRunState();
+ state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career", "wealth"] });
+ const headings = consultationComposeHeadingGroups(state.thinkingPlan).flatMap((group) => [...group.headings]);
+ async function* leftover() {
+ yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
+ yield { type: "text-delta", payload: { text: "## 事业\n整篇都写了" } };
+ yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
+ }
+ const composed: Array<{ heading: string; prior: string }> = [];
+ const response = streamAgentResponse({
+ runId: "run", requestId: "req", state, stream: leftover(), requireTool: true,
+ toolStatus: () => "ready", receipt: () => receipt(state),
+ composeSection: async (heading, priorOutput) => {
+ composed.push({ heading, prior: priorOutput });
+ async function* slice() {
+ yield { type: "reasoning-delta", payload: { text: `对照${heading}` } };
+ yield { type: "text-delta", payload: { text: `## ${heading}\n本节。\n` } };
+ yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
+ }
+ return slice();
+ },
+ });
+ const events: unknown[] = [];
+ const parser = createNdjsonParser((event) => events.push(event));
+ parser.finish(await response.text());
+ assert.deepEqual(composed.map((item) => item.heading), headings);
+ assert.equal(composed[0]?.prior, "");
+ assert.match(composed[1]?.prior ?? "", /统一参数与原始结构/);
+ const answer = events
+ .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
+ .map((event) => event.text)
+ .join("");
+ assert.doesNotMatch(answer, /整篇都写了/);
+ assert.match(answer, /## 统一参数与原始结构/);
+ assert.match(answer, /## 事业\n本节/);
+ assert.match(answer, /## 财富/);
+ assert.match(answer, new RegExp(`## ${REPORT_HEADING.audit}`));
+ const sectionHeadings = events
+ .filter((event): event is { type: string; heading: string } => (event as { type?: string }).type === "thinking.section")
+ .map((event) => event.heading);
+ assert.deepEqual(sectionHeadings, [
+ REPORT_HEADING.foundation,
+ "事业",
+ "财富",
+ REPORT_HEADING.audit,
+ ]);
+});
+
+test("sliced compose length continue stays on the current section", async () => {
+ const state = toolOnlyRunState();
+ state.thinkingPlan = natalConsultationThinkingPlan({ domains: ["career"] });
+ const headings = consultationComposeHeadingGroups(state.thinkingPlan).flatMap((group) => [...group.headings]);
+ async function* first() {
+ yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
+ yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
+ }
+ let continues = 0;
+ const response = streamAgentResponse({
+ runId: "run", requestId: "req", state, stream: first(), requireTool: true,
+ toolStatus: () => "ready", receipt: () => receipt(state),
+ composeSection: async (heading, priorOutput) => {
+ if (heading === REPORT_HEADING.foundation) {
+ async function* pinched() {
+ yield { type: "text-delta", payload: { text: `## ${heading}\n岁差` } };
+ yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}] } } };
+ }
+ return pinched();
+ }
+ async function* slice() {
+ yield { type: "text-delta", payload: { text: `## ${heading}\n本节。\n` } };
+ yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
+ }
+ return slice();
+ },
+ continueAfterLength: async (output) => {
+ continues += 1;
+ assert.match(output, /## 统一参数与原始结构/);
+ assert.doesNotMatch(output, /## 事业/);
+ async function* rest() {
+ yield { type: "text-delta", payload: { text: " Lahiri。\n" } };
+ yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
+ }
+ return rest();
+ },
+ });
+ const events: unknown[] = [];
+ const parser = createNdjsonParser((event) => events.push(event));
+ parser.finish(await response.text());
+ assert.equal(continues, 1);
+ assert.equal(headings[0], REPORT_HEADING.foundation);
+ const answer = events
+ .filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
+ .map((event) => event.text)
+ .join("");
+ assert.match(answer, /岁差 Lahiri/);
+ assert.match(answer, /## 事业/);
+ assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
+});
+
test("natal tool success stores a Chinese thinking plan", async () => {
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
const encoded = JSON.stringify(state.thinkingPlan ?? []);
@@ -1369,16 +1473,19 @@ test("a timeout after partial visible text is the same truncation, not a success
assert.equal(failure.code, "answer_truncated");
});
-test("consult generation reserves spoken-answer tokens and keeps thinking disabled", () => {
+test("consult generation reserves spoken-answer tokens and enables a separate thinking channel", () => {
const settings = consultationGenerationSettings("deepseek");
assert.equal(CONSULTATION_MAX_OUTPUT_TOKENS, 16_384);
- assert.equal(settings.modelSettings.maxOutputTokens, CONSULTATION_MAX_OUTPUT_TOKENS);
- assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "disabled" } });
- assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "disabled" } });
+ assert.equal(settings.modelSettings.maxOutputTokens, 16_384 + 8_192);
+ assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "enabled" } });
+ assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "enabled" } });
+ const slice = consultationSliceGenerationSettings("deepseek");
+ assert.equal(slice.modelSettings.maxOutputTokens, 8_192 + 2_048);
+ assert.deepEqual(slice.providerOptions.deepseek, { thinking: { type: "enabled", reasoningEffort: "low" } });
assert.equal(AGENT_MAX_STEPS, 8);
});
-test("provider reasoning stays off the spoken answer and the public thinking stream", async () => {
+test("provider reasoning stays off the spoken answer and Chinese reasoning is public thinking", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
state.consultationToolCallCount = 1;
@@ -1399,13 +1506,16 @@ test("provider reasoning stays off the spoken answer and the public thinking str
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
- assert.equal(events.filter((event) => (event as { type?: string }).type === "thinking.delta").length, 0);
+ assert.deepEqual(
+ events.filter((event) => (event as { type?: string }).type === "thinking.delta"),
+ [{ type: "thinking.delta", text: "先看事业宫的结构。" }],
+ );
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.equal(answer, "事业方向的判断如下。");
- assert.equal(completedThinking, undefined);
+ assert.equal(completedThinking, "先看事业宫的结构。");
assert.doesNotMatch(JSON.stringify(events), /proposedKind/);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
diff --git a/frontend/tests/consultation-run-timeline.test.ts b/frontend/tests/consultation-run-timeline.test.ts
new file mode 100644
index 00000000..91d91e4d
--- /dev/null
+++ b/frontend/tests/consultation-run-timeline.test.ts
@@ -0,0 +1,87 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { chartCalculationProgressLabel } from "../src/lib/consultation-activity-labels.ts";
+import { generalConsultationThinkingPlan, natalConsultationThinkingPlan } from "../src/lib/consultation-thinking-plan.ts";
+import {
+ consultationTimelineFromSettled,
+ emptyConsultationTimeline,
+ reduceConsultationTimelineEvents,
+} from "../src/lib/consultation-run-timeline.ts";
+
+test("career and wealth events append method, calculate 1/2, think, then the first write", () => {
+ const [foundation] = natalConsultationThinkingPlan({ domains: ["career", "wealth"] });
+ assert.ok(foundation);
+ const afterProgress = reduceConsultationTimelineEvents([
+ { type: "skill.started", name: "jyotish-vedic-astrology" },
+ { type: "skill.completed", name: "jyotish-vedic-astrology" },
+ {
+ type: "tool.started",
+ callId: "tool-1",
+ tool: "run-jyotish-consultation",
+ label: "正在计算本命盘…",
+ },
+ {
+ type: "activity",
+ phase: "chart-calculation",
+ label: chartCalculationProgressLabel(1, 2),
+ },
+ ], emptyConsultationTimeline());
+ assert.deepEqual(afterProgress.rows.map((row) => row.kind), ["method", "calculate"]);
+ assert.equal(afterProgress.rows[1]?.status, "live");
+ assert.equal(afterProgress.rows[1]?.label, "正在计算本命盘(第 1/2 项)…");
+
+ const state = reduceConsultationTimelineEvents([
+ { type: "thinking.section", ...foundation },
+ { type: "answer.delta", text: "## 统一参数与原始结构\n岁差 Lahiri。\n" },
+ ], afterProgress);
+
+ assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "think", "write"]);
+ assert.equal(state.rows[1]?.status, "done");
+ assert.equal(state.rows[2]?.label, "先整理本盘的统一参数");
+ assert.equal(state.rows[2]?.status, "done");
+ assert.equal(state.rows[3]?.id, "write-统一参数与原始结构");
+ assert.equal(state.rows[3]?.status, "live");
+ assert.match(state.rows[3]?.label ?? "", /正在写统一参数与原始结构/);
+});
+
+test("idle chat without a natal calculation has no calculate row", () => {
+ const [section] = generalConsultationThinkingPlan();
+ assert.ok(section);
+ const state = reduceConsultationTimelineEvents([
+ { type: "skill.started", name: "jyotish-vedic-astrology" },
+ { type: "skill.completed", name: "jyotish-vedic-astrology" },
+ { type: "thinking.section", ...section },
+ { type: "thinking.delta", text: "先按问题组织回答。" },
+ { type: "answer.delta", text: "今日宜稳,不宜强推。" },
+ {
+ type: "run.completed",
+ receipt: {
+ runId: "settled",
+ runtime: "mastra-agentic",
+ skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
+ steps: [],
+ workflow: { route: "general-no-birth-time", status: "ready", preciseTiming: "blocked", missingLayers: ["birth-minute"] },
+ },
+ },
+ ]);
+
+ assert.equal(state.rows.some((row) => row.kind === "calculate"), false);
+ assert.deepEqual(state.rows.map((row) => row.kind), ["method", "think", "write"]);
+ assert.equal(state.rows[1]?.thinkingText, "先按问题组织回答。");
+});
+
+test("settled natal messages hydrate a calculate row from domain sections", () => {
+ const sections = natalConsultationThinkingPlan({ domains: ["career", "wealth"] });
+ const rows = consultationTimelineFromSettled({
+ text: "## 统一参数与原始结构\n岁差。\n## 事业\n方向稳定。\n",
+ thinkingSections: sections,
+ thinkingText: "先对照 D10。",
+ });
+ assert.ok(rows.some((row) => row.kind === "calculate"));
+ assert.ok(rows.some((row) => row.kind === "think" && row.label.includes("事业")));
+ const calculate = rows.find((row) => row.kind === "calculate");
+ assert.ok(calculate?.queries?.includes("事业"));
+ assert.ok(calculate?.queries?.includes("D10"));
+ assert.ok(calculate?.queries?.includes("财富"));
+});
diff --git a/frontend/tests/consultation-thinking-plan.test.ts b/frontend/tests/consultation-thinking-plan.test.ts
index d8d0902a..3554487d 100644
--- a/frontend/tests/consultation-thinking-plan.test.ts
+++ b/frontend/tests/consultation-thinking-plan.test.ts
@@ -3,8 +3,10 @@ import test from "node:test";
import {
applyThinkingSectionProgress,
+ consultationComposeHeadingGroups,
consultationContinuePrompt,
consultationReportHeadings,
+ consultationSectionPrompt,
consultationSpokenHeadingRule,
natalConsultationThinkingPlan,
REPORT_HEADING,
@@ -106,3 +108,22 @@ test("continue prompt asks to resume after the last complete heading", () => {
test("natal spoken answers must use markdown lists for parallel points", () => {
assert.match(consultationSpokenHeadingRule("natal"), /Markdown bullet lists/);
});
+
+test("close heading group splits audit and modern-life into two compose slices", () => {
+ const groups = consultationComposeHeadingGroups(natalConsultationThinkingPlan({
+ domains: ["career", "wealth"],
+ }));
+ assert.deepEqual(groups.map((group) => [...group.headings]), [
+ [REPORT_HEADING.foundation],
+ ["事业"],
+ ["财富"],
+ [REPORT_HEADING.audit, REPORT_HEADING.wrap],
+ ]);
+});
+
+test("section prompt asks for one heading and forbids another calculation", () => {
+ const prompt = consultationSectionPrompt("事业", "## 统一参数与原始结构\n岁差。\n");
+ assert.match(prompt, /只写这一个二级标题及其正文:## 事业/);
+ assert.match(prompt, /不要再调用排盘工具/);
+ assert.match(prompt, /统一参数与原始结构/);
+});
diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts
index 03b80277..ae63d6e9 100644
--- a/frontend/tests/consultation-workflow-contract.test.ts
+++ b/frontend/tests/consultation-workflow-contract.test.ts
@@ -57,31 +57,44 @@ test("the model step budget and the wall-clock budget are declared as one pair",
// The pair now lives beside the domain cap it funds: the cap is derived from
// the wall clock, so a change to one that forgets the other is impossible.
assert.match(tools, /export const AGENT_MAX_STEPS = 8;\nexport const AGENT_TIMEOUT_MS = 110_000;/);
+ assert.match(tools, /export const AGENT_SLICE_MAX_STEPS = 1;/);
assert.match(tools, /MAX_CONSULTATION_DOMAINS = Math\.max\(\s*1,\s*Math\.floor\(CONSULTATION_DOMAIN_WALL_CLOCK_MS \/ CONSULTATION_DOMAIN_DURATION_MS\),\s*\)/);
assert.doesNotMatch(route, /const AGENT_(MAX_STEPS|TIMEOUT_MS) =/);
assert.match(route, /maxSteps: AGENT_MAX_STEPS,/);
+ assert.match(route, /maxSteps: AGENT_SLICE_MAX_STEPS,/);
assert.match(route, /AbortSignal\.timeout\(AGENT_TIMEOUT_MS\)/);
assert.match(route, /createConsultationRuntimeState\(\{ plannedSteps: AGENT_MAX_STEPS \}\)/);
assert.doesNotMatch(route, /maxSteps: \d/);
assert.doesNotMatch(route, /AbortSignal\.timeout\(\d/);
});
-test("consult streams reserve an answer budget and keep provider thinking off", () => {
+test("consult streams reserve an answer budget and keep provider thinking on a separate channel", () => {
const settings = readFileSync(new URL("../src/lib/agent-generation-settings.ts", import.meta.url), "utf8");
assert.match(settings, /export const AGENT_ANSWER_OUTPUT_TOKENS = 16_384;/);
+ assert.match(settings, /export const AGENT_THINKING_OUTPUT_TOKENS = 8_192;/);
assert.match(settings, /export const AGENT_MAX_OUTPUT_TOKENS = AGENT_ANSWER_OUTPUT_TOKENS;/);
assert.match(settings, /options\.thinking \?\? "disabled"/);
- assert.match(settings, /maxOutputTokens: AGENT_ANSWER_OUTPUT_TOKENS/);
+ assert.match(settings, /agentOutputTokenBudget\(thinkingMode,/);
assert.match(tools, /function consultationGenerationSettings/);
+ assert.match(tools, /return agentGenerationSettings\(model, \{ thinking: "enabled" \}\)/);
+ assert.match(tools, /function consultationContinueGenerationSettings/);
assert.match(tools, /return agentGenerationSettings\(model, \{ thinking: "disabled" \}\)/);
+ assert.match(tools, /function consultationSliceGenerationSettings/);
+ assert.match(tools, /reasoningEffort: "low"/);
assert.match(tools, /AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS/);
assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/);
+ assert.match(route, /consultationContinueGenerationSettings\(selectedModel\.model\)/);
+ assert.match(route, /consultationSliceGenerationSettings\(selectedModel\.model\)/);
+ assert.match(route, /composeSection,/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
assert.equal(route.match(/const continueAfterLength = async \(output: string\) => \{/g)?.length, 3);
+ assert.equal(route.match(/const composeSection = async \(heading: string, priorOutput: string\) => \{/g)?.length, 1);
assert.match(stream, /thinking\.section/);
+ assert.match(stream, /thinking\.delta/);
assert.match(stream, /continueAfterLength/);
+ assert.match(stream, /composeSection/);
+ assert.match(stream, /drainSpoken/);
assert.match(stream, /answer-continue/);
- assert.doesNotMatch(stream, /thinking\.delta/);
});
test("uses one runtime step append entry and no scattered hard-coded step cap", () => {
diff --git a/frontend/tests/rectification-activity-receipt.test.ts b/frontend/tests/rectification-activity-receipt.test.ts
index 8b0a8e99..9ae1c0df 100644
--- a/frontend/tests/rectification-activity-receipt.test.ts
+++ b/frontend/tests/rectification-activity-receipt.test.ts
@@ -99,7 +99,7 @@ test("answer deltas preserve a still-running server activity", () => {
chatSource.indexOf('event.type === "answer.delta"'),
chatSource.indexOf('event.type === "run.failed"'),
);
- assert.match(deltaBranch, /state: "streaming"/);
+ assert.match(deltaBranch, /state: parsed\.text \? "streaming" : "thinking"/);
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 69702469..ac5e1d82 100644
--- a/frontend/tests/rectification-agentic-entry.test.ts
+++ b/frontend/tests/rectification-agentic-entry.test.ts
@@ -84,6 +84,9 @@ test("persisted rectification turns hydrate after the async Case refresh", () =>
assert.match(chat, /function messagesFromTurns\(initialTurns:/);
assert.match(chat, /useState\(\(\) => messagesFromTurns\(initialTurns\)\)/);
assert.match(chat, /finalizeRectificationSpokenAndThinking/);
+ assert.match(chat, /settleRectificationSpokenAndThinking/);
+ assert.match(chat, /text: split\.spoken,/);
+ assert.doesNotMatch(chat, /split\.spoken \|\| raw/);
assert.match(page, /key=\{`\$\{rectificationSessionId\}-\$\{rectificationCaseId\}-\$\{rectificationTurns\.length > 0 \? "ready" : "loading"\}`\}/);
assert.doesNotMatch(page, /rectificationTurns\.at\(-1\)\?\.id/);
assert.match(page, /methods: Array\.isArray\(\(turn\.receipt as \{ methods\?: unknown \}\)\.methods\)/);
@@ -234,11 +237,11 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /billing\.complete\(/);
assert.match(run, /billing\.release\(/);
assert.match(run, /usage_settlement_failed/);
- assert.match(run, /thinking: "disabled"/);
+ assert.match(run, /thinking: "enabled"/);
assert.match(run, /toPublicThinkingDelta/);
assert.match(run, /splitRectificationSpokenAndThinking/);
assert.match(run, /emit\(event: PublicStreamEvent\): Promise \| void;/);
- assert.match(run, /emit: \(event: PublicStreamEvent\) => Promise \| void,/);
+ assert.doesNotMatch(run, /thinking: "disabled"/);
assert.match(route, /featureKey: "rectification"/);
assert.match(route, /rectification:case:\$\{caseId\}/);
});
diff --git a/frontend/tests/rectification-spoken-answer.test.ts b/frontend/tests/rectification-spoken-answer.test.ts
index 0c4ae570..ed5cdff9 100644
--- a/frontend/tests/rectification-spoken-answer.test.ts
+++ b/frontend/tests/rectification-spoken-answer.test.ts
@@ -3,7 +3,9 @@ import test from "node:test";
import {
finalizeRectificationSpokenAndThinking,
+ isRectificationProcessNarration,
nextStableChannelDelta,
+ settleRectificationSpokenAndThinking,
splitRectificationSpokenAndThinking,
} from "../src/lib/rectification-agentic/v9/spoken-answer.ts";
@@ -64,11 +66,42 @@ test("a lone process paragraph stays in thinking until a spoken conclusion arriv
});
});
-test("all-process text keeps a spoken fallback only when the turn is finalized", () => {
- const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
- const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
- assert.deepEqual(finalizeRectificationSpokenAndThinking(`${first}\n\n${last}`), {
- thinking: first,
- spoken: last,
+test("truncated third-person batch-tool narration stays in thinking", () => {
+ const truncated = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
+ assert.equal(isRectificationProcessNarration(truncated), true);
+ assert.deepEqual(splitRectificationSpokenAndThinking(truncated), {
+ thinking: truncated,
+ spoken: "",
+ });
+ assert.deepEqual(finalizeRectificationSpokenAndThinking(truncated), {
+ thinking: truncated,
+ spoken: "",
+ });
+});
+
+test("finalizing does not promote process-only self-talk into the spoken answer", () => {
+ const first = "用户提到先给了一个很晚的年份。这里有个明显的内部矛盾。";
+ const last = "但按 skill 规则,日期精度真实保留,不得猜补。";
+ const processTalk = `${first}\n\n${last}`;
+ assert.deepEqual(finalizeRectificationSpokenAndThinking(processTalk), {
+ thinking: processTalk,
+ spoken: "",
+ });
+});
+
+test("leaked process text on the answer channel is not mixed into native thinking", () => {
+ const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
+ const spoken = "记下了升学这两件。接下来有没有一件带大概年份的工作变化?";
+ assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, ""), {
+ thinking: processTalk,
+ spoken: "",
+ });
+ assert.deepEqual(settleRectificationSpokenAndThinking(spoken, processTalk), {
+ thinking: processTalk,
+ spoken,
+ });
+ assert.deepEqual(settleRectificationSpokenAndThinking(processTalk, processTalk), {
+ thinking: processTalk,
+ spoken: "",
});
});
diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts
index 0705f7f9..f15b173e 100644
--- a/frontend/tests/rectification-v9-agent.test.ts
+++ b/frontend/tests/rectification-v9-agent.test.ts
@@ -301,8 +301,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, 16384);
- assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } });
+ assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 24576);
+ assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "enabled" } });
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 2e13b33e..4461e53b 100644
--- a/frontend/tests/rectification-v9-stream.test.ts
+++ b/frontend/tests/rectification-v9-stream.test.ts
@@ -543,10 +543,10 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
- chunk("text-delta", {
+ chunk("reasoning-delta", {
text: "用户提到先给了一个很晚的年份,后又改口说六岁入学。这里有个明显的内部矛盾。\n\n",
}),
- chunk("text-delta", {
+ chunk("reasoning-delta", {
text: "但按 skill 规则,日期精度真实保留,不得猜补。datePrecision 用 year。\n\n",
}),
chunk("text-delta", {
@@ -571,6 +571,64 @@ test("Chinese process self-talk after tools is thinking, not the spoken answer",
assert.equal(result.answerText, "记下了,大约六岁入学小学。接下来你大概哪一年上的初中?");
});
+test("process-only self-talk after tools is not persisted as a completed spoken answer", async () => {
+ let buildCount = 0;
+ const processTalk = "用户在上一轮里提供了两件带日期的经历。我需要用批量工具写入这些证据。用户";
+ 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: "completed", idempotent: false }),
+ });
+ const { options, emitted, billing } = runOptions({
+ accounting: accounting.client,
+ buildAgent: async () => {
+ buildCount += 1;
+ return buildCount === 1
+ ? attemptStream([
+ chunk("start"),
+ chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
+ chunk("tool-result", { toolName: "skill" }),
+ chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
+ chunk("tool-result", { toolName: "rectification-read-case" }),
+ chunk("tool-call", {
+ toolName: "rectification-record-evidence-batch",
+ args: { caseId: CASE_ID, proposedKind: "education_start" },
+ }),
+ chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
+ chunk("reasoning-delta", { text: processTalk }),
+ chunk("finish"),
+ ], { inputTokens: 11, outputTokens: 12 }) as never
+ : attemptStream(successfulAttemptChunks(), { inputTokens: 31, outputTokens: 17 }) as never;
+ },
+ });
+
+ const result = await runV9AgentTurn(options);
+
+ assert.equal(buildCount, 2);
+ assert.equal(result.ok, true);
+ assert.equal(result.answerText, "第二次 attempt 成功");
+ const resetAt = emitted.findIndex((event) => event.type === "attempt.reset");
+ assert.ok(resetAt >= 0);
+ const firstAttemptAnswers = emitted.slice(0, resetAt).filter((event) => event.type === "answer.delta");
+ assert.deepEqual(firstAttemptAnswers, []);
+ const firstAttemptThinking = emitted.slice(0, resetAt)
+ .filter((event) => event.type === "thinking.delta")
+ .map((event) => event.text)
+ .join("");
+ assert.match(firstAttemptThinking, /批量工具/);
+ assert.doesNotMatch(firstAttemptThinking, /第二次 attempt/);
+ const finalizedTurn = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
+ assert.equal(finalizedTurn?.args.p_assistant_message, "第二次 attempt 成功");
+ assert.equal(finalizedTurn?.args.p_status, "completed");
+ assert.deepEqual(billing, { reserved: 1, completed: 1, released: 0 });
+ const firstAttempt = accounting.calls.find((call) =>
+ call.fn === "finalize_agentic_rectification_run_attempt"
+ && call.args.p_attempt_id === "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa");
+ assert.equal(firstAttempt?.args.p_status, "retryable");
+ assert.equal(firstAttempt?.args.p_error_code, "empty_stream");
+});
+
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(本会话以代表性时间收口";
const accounting = fakeAccounting({