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 ? (
{spokenAnswer}
-
- ) : ( - <> - {thinkingPanel} - {spokenAnswer} - - )} - + ) : null} +
+ ) : ( + <> + {showReport && ( + + )} + {stackedThinkingAndAnswer ? ( +
+ {thinkingPanel} +
+ {spokenAnswer} +
+
+ ) : ( + <> + {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 = ( + + ); + const 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 ? ( + + ) : ( +

    + ) : 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({