From 7d667fecbf788077663fdffa006881fb4e152bd9 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 19 Aug 2026 19:37:02 +0800 Subject: [PATCH] fix(consult): treat pinched answers as failed and restore reply actions Incomplete Flash generations were billed as completed consultations. Fail those runs, keep the partial text, and reuse the rectification like/copy/rerun bar on ordinary chat replies. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 32 ++++ frontend/src/app/api/consult/route.ts | 2 + frontend/src/app/globals.css | 16 +- frontend/src/app/page.tsx | 150 ++++++++++++++++-- .../src/components/chat-message-actions.tsx | 65 ++++++++ .../components/rectification-agentic-chat.tsx | 61 ++----- frontend/src/lib/agent-observability.ts | 1 + frontend/src/lib/consultation-agent-events.ts | 2 +- frontend/src/lib/stream-agent-response.ts | 66 +++++--- frontend/src/mastra/consultation-tools.ts | 28 ++++ frontend/tests/agent-observability.test.ts | 4 + frontend/tests/chat-message-actions.test.ts | 11 ++ frontend/tests/chat-stream-layout.test.ts | 25 ++- .../consultation-agentic-runtime.test.ts | 84 ++++++++++ frontend/tests/consultation-recovery.test.ts | 11 ++ .../consultation-workflow-contract.test.ts | 9 ++ .../tests/rectification-agentic-entry.test.ts | 9 +- 17 files changed, 487 insertions(+), 89 deletions(-) create mode 100644 frontend/src/components/chat-message-actions.tsx create mode 100644 frontend/tests/chat-message-actions.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b6c01d4b..3b6538a1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4569,4 +4569,36 @@ - 防复发:停止收集不得只做口头确认;下一步必须来自 `next_user_action.on_user_stop`。 - 相关记录:BUG-179、BUG-292 - 复发自:BUG-179(禁止正文写时间后,若卡片未出现就没有任何排盘结果) +- 修复版本:40a59504 + +## BUG-305 | 咨询生成半截结束仍被当成成功回答并扣点 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:普通咨询 `POST /api/consult`、`streamAgentResponse`、咨询页 NDJSON 收口、DeepSeek V4 Flash 生成预算 +- 用户现象:staging 一次咨询回答停在半截标题,输入框随即恢复可输入,界面看起来像已经答完。服务端把该条助手消息按成功咨询写入会话。 +- 触发条件:网页个人咨询,模型为 DeepSeek V4 Flash。计算工具已成功,随后组织回答时输出在句中停止。观测到会话更新约 2.5 分钟后收口,公开回执 `stepBudget.truncated=false`,客户端收到 `run.completed`。 +- 根因:两层。其一,`finish_reason=length` 与超时中断后的半截正文仍走 `run.completed` → `complete_consultation_response`,公开回执故意不含 `modelFinishReason`,界面无法区分正常结束与夹断。其二,咨询 `agent.stream` 未设可见输出预算,也未关闭 Flash 默认 thinking;隐藏推理与可见正文共用 `max_tokens`,更容易在标题中途 `length` 停住。`AGENT_TIMEOUT_MS = 110_000` 与路由 `maxDuration = 120` 仍可能在组答阶段掐流,旧逻辑同样把已发出的半截当成功。 +- 修复:可见正文在 `length` 结束,或超时/中止时已有输出,改为 `run.failed` / `answer_truncated`,保留已流出文本,账务走 `cancel`。咨询流设置 `maxOutputTokens = 8192`,并对当前供应商与 `openai` 兼容键关闭 thinking。客户端保存半截助手消息并提示未完成、不会扣点,不再要求 `run.completed` 才落盘。 +- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 覆盖 `length` 与超时半截不得 `run.completed`、不得调用 `onComplete`;`consultation-workflow-contract.test.ts` 锁定输出预算与 thinking disabled;`consultation-recovery.test.ts` 与 `chat-stream-layout.test.ts` 锁定半截落盘与提示;`agent-observability.test.ts` 将 `answer_truncated` 纳入已知错误码。 +- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留可见 token 预算;不得依赖 Flash 默认 thinking 与提供方默认 `max_tokens`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。 +- 相关记录:BUG-280、BUG-277 +- 复发自:无 +- 修复版本:待提交 + +## BUG-306 | 普通咨询 Agent 回答下方缺少赞踩复制重跑操作栏 + +- 状态:resolved +- 首次发现:2026-08-19 +- 最近更新:2026-08-19 +- 影响面:普通咨询会话消息列表、`ChatMessageActions`、生时校正消息操作栏 +- 用户现象:生时纠正每条 Agent 回答下方有赞、踩、复制和重跑图标,普通咨询同一位置没有。 +- 触发条件:打开普通咨询会话,查看已完成的助手回答。 +- 根因:BUG-049 / BUG-185 只把操作栏接到生时校正消息列表。普通咨询 `page.tsx` 只渲染 `ChatMessageRow`,没有复用同一组操作。 +- 修复:抽出共用 `ChatMessageActions`。普通咨询与生时校正共用赞踩互斥、复制和仅最新回答可重跑。普通咨询重跑去掉最后一条助手消息后走现有 `/api/consult`(会扣点),失败则恢复原文;生时校正仍走免费原位 regenerate。 +- 验证:`frontend/tests/chat-message-actions.test.ts`、`chat-stream-layout.test.ts`、`rectification-agentic-entry.test.ts`。 +- 防复发:普通咨询与生时校正的可见操作必须共用同一组件;不得再复制一套图标按钮。 +- 相关记录:BUG-049、BUG-094、BUG-185 +- 复发自:无 - 修复版本:待提交 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 72f3d9e5..9c712d40 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -37,6 +37,7 @@ import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation- import { AGENT_MAX_STEPS, AGENT_TIMEOUT_MS, + consultationGenerationSettings, createConsultationAgentContext, consultationModelStepTelemetry, consultationStepBudgetReceipt, @@ -653,6 +654,7 @@ export async function POST(request: Request) { maxSteps: AGENT_MAX_STEPS, abortSignal: agentAbortSignal, hooks, + ...consultationGenerationSettings(selectedModel.model), }; const workflowReceipt: WorkflowReceipt = consultationMode === "general_no_birth_time" ? { diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index c285fcc1..333a4865 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -679,14 +679,14 @@ button:disabled { cursor: default; opacity: .45; } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } .rectification-message-entry { min-width: 0; } -.rectification-message-actions { +.message-actions { display: flex; align-items: center; gap: 1px; margin: -4px 0 8px 42px; color: var(--color-ink-tertiary); } -.rectification-message-actions button { +.message-actions button { display: inline-flex; width: 26px; height: 26px; @@ -702,17 +702,17 @@ button:disabled { cursor: default; opacity: .45; } opacity: .7; transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; } -.rectification-message-actions button:hover:not(:disabled) { +.message-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent); color: var(--color-ink-secondary); opacity: 1; transform: translateY(-1px); } -.rectification-message-actions button:active:not(:disabled) { transform: translateY(0); } -.rectification-message-actions button.is-active { color: var(--color-action); opacity: 1; } -.rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } -.rectification-message-actions button:disabled { cursor: default; opacity: 0.32; } -.rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; } +.message-actions button:active:not(:disabled) { transform: translateY(0); } +.message-actions button.is-active { color: var(--color-action); opacity: 1; } +.message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } +.message-actions button:disabled { cursor: default; opacity: 0.32; } +.message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; } .rectification-analysis { width: min(620px, calc(100% - 42px)); margin: -2px 0 var(--space-1) 42px; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 97f791de..fdd410de 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -26,6 +26,11 @@ import { import { ConversationalBirthTimeRectification, type PersistedRectificationTurn } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; +import { + ChatMessageActions, + toggleChatMessageFeedback, + type ChatMessageFeedback, +} from "@/components/chat-message-actions"; import { ModelSelector } from "@/components/model-selector"; import { OnboardingRedeemPaywall } from "@/components/onboarding-redeem-paywall"; import { BirthPlacePicker } from "@/components/birth-place-picker"; @@ -1059,6 +1064,8 @@ export default function Home() { const [pendingSessionId, setPendingSessionId] = useState(null); const [pendingRequestId, setPendingRequestId] = useState(null); const [streamingReply, setStreamingReply] = useState(null); + const [messageFeedback, setMessageFeedback] = useState>({}); + const [copiedMessageKey, setCopiedMessageKey] = useState(null); const [replyOutcome, setReplyOutcome] = useState(null); const [requestError, setRequestError] = useState(null); const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState( @@ -2907,16 +2914,22 @@ export default function Home() { entrypoint: ConsultationEntrypoint | null = null, consentGrantedForRequest: ConsultationBirthTimeMode | null = null, targetSessionId: string | null = null, - options: { resumeRequestId?: string } = {}, + options: { + resumeRequestId?: string; + sessionOverride?: ChatSession; + restoreOnFailure?: ChatSession; + } = {}, ): Promise { const originalQuestion = text; const question = text.trim(); const resumeRequestId = options.resumeRequestId; const resuming = Boolean(resumeRequestId); - const currentSession = targetSessionId + const liveSession = targetSessionId ? sessions.find((session) => session.id === targetSessionId) : activeSession; + const currentSession = options.sessionOverride ?? liveSession; if (!question || !currentSession || !modelCatalog || !account) return false; + const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession; if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false; if (resuming) { const pending = pendingConsultation.current; @@ -3003,7 +3016,7 @@ export default function Home() { question: originalQuestion, entrypoint, theme, - previousSession: currentSession, + previousSession: rollbackSession, optimisticSession: userSession, previousOnboardingState, controller, @@ -3077,9 +3090,9 @@ export default function Home() { await persistSession(userSession); } catch (caught) { if (controller.signal.aborted) return false; - updateSession(sessionId, () => currentSession); + updateSession(sessionId, () => rollbackSession); setOnboardingJustCompleted(previousOnboardingState); - if (activeSessionIdRef.current === sessionId) { + if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) { setDraft(originalQuestion); setDraftTheme(theme); setDraftEntrypoint(entrypoint); @@ -3088,7 +3101,9 @@ export default function Home() { sessionId, message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`, }); - setComposerNotice("问题保存失败,未开始生成;问题已放回输入框。"); + setComposerNotice(options.restoreOnFailure + ? "问题保存失败,未开始生成;已恢复原来的回答。" + : "问题保存失败,未开始生成;问题已放回输入框。"); completeConsultationInterface(requestId); window.requestAnimationFrame(() => composerInput.current?.focus()); return false; @@ -3164,6 +3179,7 @@ export default function Home() { }; let agentExecutionReceipt: AgentExecutionReceipt | undefined; let runCompleted = false; + let truncatedFailure: Extract | undefined; const reader = response.body.getReader(); const decoder = new TextDecoder(); let answer = ""; @@ -3200,7 +3216,18 @@ export default function Home() { workflowReceipt = event.receipt.workflow; techniqueTruth = event.receipt.techniqueTruth ?? "unknown"; } - if (event.type === "run.failed") throw new ConsultationResponseError(502, event.message); + if (event.type === "run.failed") { + if (event.code === "answer_truncated") { + truncatedFailure = event; + if (event.receipt) { + agentExecutionReceipt = event.receipt; + workflowReceipt = event.receipt.workflow; + techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth; + } + return; + } + throw new ConsultationResponseError(502, event.message); + } updateActivity(event); }); while (true) { @@ -3209,7 +3236,38 @@ export default function Home() { parser.push(decoder.decode(value, { stream: true })); } parser.finish(decoder.decode()); - if (!runCompleted) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。"); + if (truncatedFailure) { + const reply = parseAgentReply(answer); + if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message); + const truncatedSession: ChatSession = { + ...userSession, + title: userSession.title, + messages: [...userSession.messages, { + role: "assistant", + text: reply.text, + techniqueTruth, + workflowReceipt, + agentExecutionReceipt, + }], + updatedAt: timestamp(), + }; + updateSession(sessionId, () => truncatedSession); + setStreamingReply(null); + setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); + setComposerNotice(truncatedFailure.message); + try { + await persistSession(truncatedSession); + } catch (error) { + setRequestError({ + sessionId, + message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。", + }); + } + completeConsultationInterface(requestId); + void refreshAccount(); + return true; + } + if (!runCompleted && !truncatedFailure) throw new ConsultationResponseError(502, "Agent 回答未完成,本次不会保存为成功咨询。"); } else { while (true) { const { done, value } = await reader.read(); @@ -3264,6 +3322,11 @@ export default function Home() { setRequestError({ sessionId, message: caught.message }); setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 }); setComposerNotice(caught.message); + const restore = options.restoreOnFailure; + if (restore) { + updateSession(sessionId, () => restore); + void persistSession(restore).catch(() => {}); + } completeConsultationInterface(requestId); return false; } @@ -3299,6 +3362,42 @@ export default function Home() { } } + async function copyAssistantMessage(renderKey: string, text: string) { + try { + await navigator.clipboard.writeText(text); + setCopiedMessageKey(renderKey); + window.setTimeout(() => setCopiedMessageKey((current) => ( + current === renderKey ? null : current + )), 1_500); + } catch { + // Clipboard permission failures must not interrupt the conversation. + } + } + + function regenerateLatestAnswer(renderKey: string) { + const session = activeSession; + if (!session || isLoading || cancellationPending || pendingConsultation.current) return; + const last = session.messages.at(-1); + if (last?.role !== "assistant" || last.text.trim() === "") return; + const previous = session.messages.at(-2); + if (previous?.role !== "user" || previous.text.trim() === "") return; + if (`message-${session.messages.length - 1}` !== renderKey) return; + const sessionOverride: ChatSession = { + ...session, + messages: session.messages.slice(0, -1), + updatedAt: timestamp(), + }; + setMessageFeedback((current) => { + const next = { ...current }; + delete next[`${session.id}:${renderKey}`]; + return next; + }); + void send(previous.text, session.theme, null, null, session.id, { + sessionOverride, + restoreOnFailure: session, + }); + } + consultationReplay.current = () => { const pending = pendingConsultation.current; if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return; @@ -3636,9 +3735,38 @@ export default function Home() { ) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => ( - - ))} + {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => { + const showActions = message.role === "assistant" + && message.state === "settled" + && Boolean(message.text); + const feedbackKey = `${activeSession.id}:${message.renderKey}`; + const latestRegeneratableKey = !isLoading && !cancellationPending + ? [...views].reverse().find((item) => ( + item.role === "assistant" && item.state === "settled" && Boolean(item.text) + ))?.renderKey + : undefined; + return ( +
+ + {showActions && ( + setMessageFeedback((current) => { + const next = { ...current }; + const value = toggleChatMessageFeedback(current[feedbackKey], requested); + if (value) next[feedbackKey] = value; + else delete next[feedbackKey]; + return next; + })} + onCopy={() => void copyAssistantMessage(feedbackKey, message.text)} + onRegenerate={() => regenerateLatestAnswer(message.renderKey)} + /> + )} +
+ ); + })} {activeError &&

{activeError}

}
)} diff --git a/frontend/src/components/chat-message-actions.tsx b/frontend/src/components/chat-message-actions.tsx new file mode 100644 index 00000000..17b3772d --- /dev/null +++ b/frontend/src/components/chat-message-actions.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; + +export type ChatMessageFeedback = "up" | "down"; + +export function toggleChatMessageFeedback( + current: ChatMessageFeedback | undefined, + requested: ChatMessageFeedback, +): ChatMessageFeedback | undefined { + return current === requested ? undefined : requested; +} + +export function ChatMessageActions({ + feedback, + copied = false, + canRegenerate, + onFeedback, + onCopy, + onRegenerate, +}: Readonly<{ + feedback?: ChatMessageFeedback; + copied?: boolean; + canRegenerate: boolean; + onFeedback: (value: ChatMessageFeedback) => void; + onCopy: () => void; + onRegenerate: () => void; +}>) { + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 1ae251e4..29e245ef 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react"; +import { ArrowUp } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { parseAgentReply } from "@/lib/agent-reply"; import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view"; @@ -23,6 +23,10 @@ import { } from "@/lib/rectification-agentic/v9/public-receipt"; import type { PublicLanguageModel } from "@/lib/public-models"; import { ChatMessageRow } from "./chat-message-row"; +import { + ChatMessageActions, + toggleChatMessageFeedback, +} from "./chat-message-actions"; import { CompletedActivityReceipt } from "./completed-activity-receipt"; import { ModelSelector } from "./model-selector"; import { Button } from "./ui/button"; @@ -125,7 +129,7 @@ export function toggleRectificationFeedback( current: "up" | "down" | undefined, requested: "up" | "down", ): "up" | "down" | undefined { - return current === requested ? undefined : requested; + return toggleChatMessageFeedback(current, requested); } function activityPhase(tool: PublicRectificationTool): NonNullable["phase"] { @@ -584,48 +588,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { /> )} {showActions && !regenerating && ( -
- - - - -
+ setFeedback((current) => ({ + ...current, + [message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested), + }))} + onCopy={() => void copyMessage(message)} + onRegenerate={() => void regenerateMessage(message)} + /> )} ); diff --git a/frontend/src/lib/agent-observability.ts b/frontend/src/lib/agent-observability.ts index 0ebbb293..429ce871 100644 --- a/frontend/src/lib/agent-observability.ts +++ b/frontend/src/lib/agent-observability.ts @@ -180,6 +180,7 @@ export const logAgentObservability = createAgentObservabilityLogger(); const knownErrorCodes = new Set([ "runtime_contract_incomplete", "empty_answer", + "answer_truncated", "calculation_failed", "timeout", "cancelled", diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index ce5a52ee..f441a5bf 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -100,7 +100,7 @@ const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: // and losing the whole failure event would be worse than losing its receipt. const runFailedSchema = z.object({ type: z.literal("run.failed"), - code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "cancelled"]), + code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "answer_truncated", "cancelled"]), message: z.string().max(200), receipt: agentExecutionReceiptSchema.optional(), }).strict(); diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 7ce5a339..65af26ae 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -77,6 +77,27 @@ function safeToolError(error: unknown) { return "calculation_failed" as const; } +function isTimeoutOrAbort(error: unknown) { + return error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError"); +} + +type RunFailedCode = "runtime_contract_incomplete" | "empty_answer" | "answer_truncated" | "calculation_failed"; + +function runFailedCode(error: unknown, emitted: boolean): RunFailedCode { + if (error instanceof Error && error.message === "runtime_contract_incomplete") return "runtime_contract_incomplete"; + if (error instanceof Error && error.message === "empty_answer") return "empty_answer"; + if (error instanceof Error && error.message === "answer_truncated") return "answer_truncated"; + if (emitted && isTimeoutOrAbort(error)) return "answer_truncated"; + return "calculation_failed"; +} + +function runFailedMessage(code: RunFailedCode) { + if (code === "runtime_contract_incomplete") return "Agent 未完成必要的方法与计算步骤,本次不会扣点。"; + if (code === "empty_answer") return "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。"; + if (code === "answer_truncated") return "回答未完成,已保留现有内容;本次不会扣点。"; + return "咨询暂时无法完成,本次不会扣点。"; +} + /** * Whether a tool result is really an input rejection Mastra resolved with. * @@ -287,19 +308,26 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { // A retry runs a second model loop under the same step budget, so the run // total accumulates while the finish reason describes the latest attempt. const stepCountBeforeAttempt = options.state.modelStepCount; - for await (const chunk of readChunks(stream)) { - for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event); - if (chunk.type === "step-finish") options.state.modelStepCount += 1; - if (chunk.type === "finish") { - const finish = finishTelemetry(chunk); - options.state.modelFinishReason = finish.reason; - if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount; - } - if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") { - await outputText(visible.push(chunk.payload.text)); + try { + for await (const chunk of readChunks(stream)) { + for (const event of mapChunk(chunk, options, startedAt, toolErrors)) send(controller, event); + if (chunk.type === "step-finish") options.state.modelStepCount += 1; + if (chunk.type === "finish") { + const finish = finishTelemetry(chunk); + options.state.modelFinishReason = finish.reason; + if (finish.stepCount !== null) options.state.modelStepCount = stepCountBeforeAttempt + finish.stepCount; + } + if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") { + await outputText(visible.push(chunk.payload.text)); + } } + await outputText(visible.finish("")); + } catch (error) { + try { + await outputText(visible.finish("")); + } catch {} + throw error; } - await outputText(visible.finish("")); } const body = new ReadableStream({ @@ -330,6 +358,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { // Still nothing to show. Failing is the honest outcome and it is the // one that does not charge for the run. if (!/\S/.test(fullOutput)) throw new Error("empty_answer"); + // A spoken answer that stopped because the token budget ran out is + // not a completed consultation. The heading may already be on screen, + // so keep it and refuse to bill. + if (options.state.modelFinishReason === "length") throw new Error("answer_truncated"); settling = true; const receipt = agentExecutionReceiptSchema.parse(options.receipt()); await options.onComplete?.(fullOutput, receipt); @@ -344,11 +376,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { try { await options.onError?.(error, emitted, fullOutput); } catch {} - const code = error instanceof Error && error.message === "runtime_contract_incomplete" - ? "runtime_contract_incomplete" as const - : error instanceof Error && error.message === "empty_answer" - ? "empty_answer" as const - : "calculation_failed" as const; + const code = runFailedCode(error, emitted); // Step durations, the step budget and the workflow route are the only // evidence the caller has for why a run failed. Building the receipt // must not be able to replace the failure event with a silent close. @@ -359,11 +387,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { send(controller, { type: "run.failed", code, - message: code === "runtime_contract_incomplete" - ? "Agent 未完成必要的方法与计算步骤,本次不会扣点。" - : code === "empty_answer" - ? "计算已完成,但这次没有生成回答,本次不会扣点。请再发送一次。" - : "咨询暂时无法完成,本次不会扣点。", + message: runFailedMessage(code), ...(failureReceipt ? { receipt: failureReceipt } : {}), }); if (!disconnected) controller.close(); diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index ed5f35aa..dd765f45 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -42,6 +42,7 @@ import { // calculation inside the 110s budget—so it is measured, not guessed. export const AGENT_MAX_STEPS = 8; export const AGENT_TIMEOUT_MS = 110_000; +export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192; const CONSULTATION_DOMAIN_DURATION_MS = 21_000; const CONSULTATION_ANSWER_RESERVE_MS = 45_000; export const CONSULTATION_DOMAIN_WALL_CLOCK_MS = AGENT_TIMEOUT_MS - CONSULTATION_ANSWER_RESERVE_MS; @@ -50,6 +51,33 @@ export const MAX_CONSULTATION_DOMAINS = Math.max( Math.floor(CONSULTATION_DOMAIN_WALL_CLOCK_MS / CONSULTATION_DOMAIN_DURATION_MS), ); +const thinkingDisabled = { thinking: { type: "disabled" as const } }; + +/** + * Visible-answer generation settings for one consult stream. + * + * DeepSeek V4 Flash thinks by default, and those hidden tokens share + * `max_tokens` with the spoken answer. Without an explicit visible budget and + * thinking turned off, a finished-looking stream can stop mid-heading with + * `finish_reason=length`. The provider id is repeated under `openai` because + * OpenAI-compatible adapters often look there first. + */ +export function consultationGenerationSettings(model?: unknown) { + const providerId = typeof model === "string" + ? model + : model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string" + ? model.providerId + : undefined; + const providerOptions: Record = { + openai: thinkingDisabled, + }; + if (providerId) providerOptions[providerId] = thinkingDisabled; + return { + modelSettings: { maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS }, + providerOptions, + }; +} + // 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-observability.test.ts b/frontend/tests/agent-observability.test.ts index 0a0b737a..729b7caa 100644 --- a/frontend/tests/agent-observability.test.ts +++ b/frontend/tests/agent-observability.test.ts @@ -145,6 +145,10 @@ test("error normalization never records arbitrary exception messages", () => { toAgentObservabilityErrorCode(new Error("runtime_contract_incomplete")), "runtime_contract_incomplete", ); + assert.equal( + toAgentObservabilityErrorCode(new Error("answer_truncated")), + "answer_truncated", + ); assert.equal( toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")), "calculation_failed", diff --git a/frontend/tests/chat-message-actions.test.ts b/frontend/tests/chat-message-actions.test.ts new file mode 100644 index 00000000..2668db31 --- /dev/null +++ b/frontend/tests/chat-message-actions.test.ts @@ -0,0 +1,11 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { toggleChatMessageFeedback } from "../src/components/chat-message-actions.tsx"; + +test("message feedback is mutually exclusive and can be cleared", () => { + assert.equal(toggleChatMessageFeedback(undefined, "up"), "up"); + assert.equal(toggleChatMessageFeedback("up", "up"), undefined); + assert.equal(toggleChatMessageFeedback("up", "down"), "down"); + assert.equal(toggleChatMessageFeedback("down", "up"), "up"); +}); diff --git a/frontend/tests/chat-stream-layout.test.ts b/frontend/tests/chat-stream-layout.test.ts index 223c6f4d..59f8804a 100644 --- a/frontend/tests/chat-stream-layout.test.ts +++ b/frontend/tests/chat-stream-layout.test.ts @@ -70,8 +70,10 @@ test("shows honest agent activity states before and during streamed text", () => assert.doesNotMatch(globalStyles, /\.thinking\b/); assert.match(pageSource, /application\/x-ndjson/); assert.match(pageSource, /createNdjsonParser/); - assert.match(pageSource, /if \(event\.type === "run\.failed"\) throw new ConsultationResponseError/); - assert.match(pageSource, /if \(!runCompleted\) throw new ConsultationResponseError/); + assert.match(pageSource, /event\.type === "run\.failed"/); + assert.match(pageSource, /event\.code === "answer_truncated"/); + assert.match(pageSource, /throw new ConsultationResponseError/); + assert.match(pageSource, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/); assert.match(pageSource, /agentExecutionReceipt = event\.receipt/); }); @@ -95,3 +97,22 @@ test("docks the composer inside the chat panel instead of floating over content" assert.match(globalStyles, /\.composer-wrap[^}]*bottom:\s*0/); assert.doesNotMatch(globalStyles, /\.composer-wrap[^}]*position:\s*fixed/); }); + +test("ordinary consultation replies reuse the shared Agent action bar", () => { + const actionsSource = readFileSync(new URL("../src/components/chat-message-actions.tsx", import.meta.url), "utf8"); + const rectificationChat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", + ); + for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) { + assert.match(actionsSource, new RegExp(`aria-label="${label}"`)); + } + assert.match(pageSource, / { + // Staging persisted a 253-character pinch that ended mid-heading, then treated + // the run as completed. The model had finished with reason `length`; the public + // stream still emitted `run.completed`, so the composer unlocked as if the + // reading were done. + const state = toolOnlyRunState(); + const pinchedHeading = "**先看命盘结构(Lahiri岁差、均交点口径"; + let completed = 0; + let failed = 0; + async function* chunks() { + yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; + yield { type: "text-delta", payload: { text: pinchedHeading } }; + yield { type: "finish", payload: { stepResult: { reason: "length" }, output: { usage: {}, steps: [{}, {}] } } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + onComplete: () => { completed += 1; }, + onError: () => { failed += 1; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + + assert.equal(completed, 0); + assert.equal(failed, 1); + assert.equal(state.modelFinishReason, "length"); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 0); + 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, pinchedHeading); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { + code: string; + message: string; + }; + assert.equal(failure.code, "answer_truncated"); + assert.match(failure.message, /回答未完成/); + assert.match(failure.message, /不会扣点/); + assert.doesNotMatch(JSON.stringify(failure), /modelFinishReason|"length"/); +}); + +test("a timeout after partial visible text is the same truncation, not a successful answer", async () => { + const state = toolOnlyRunState(); + const pinchedHeading = "**先看命盘结构(Lahiri岁差、均交点口径"; + let completed = 0; + async function* chunks() { + yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; + yield { type: "text-delta", payload: { text: pinchedHeading } }; + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + onComplete: () => { completed += 1; }, + onError: () => {}, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + + assert.equal(completed, 0); + 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, pinchedHeading); + const failure = events.find((event) => (event as { type?: string }).type === "run.failed") as { code: string }; + assert.equal(failure.code, "answer_truncated"); +}); + +test("consult generation reserves visible output tokens and disables provider thinking", () => { + const settings = consultationGenerationSettings("deepseek"); + assert.equal(CONSULTATION_MAX_OUTPUT_TOKENS, 8192); + 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(AGENT_MAX_STEPS, 8); +}); + test("a completed run records the finish reason and the authoritative step count", async () => { const state = createConsultationRuntimeState(); diff --git a/frontend/tests/consultation-recovery.test.ts b/frontend/tests/consultation-recovery.test.ts index b078b5a2..f777f50d 100644 --- a/frontend/tests/consultation-recovery.test.ts +++ b/frontend/tests/consultation-recovery.test.ts @@ -133,3 +133,14 @@ test("the first default consultation title is persisted with the user question", assert.match(sendSource, /const completedSession: ChatSession = \{[\s\S]*title: userSession\.title/); assert.doesNotMatch(sendSource, /resolveSessionTitle\(question, reply\.title\)/); }); + +test("a truncated generation keeps the partial answer and does not wait for a successful run", () => { + const stream = sendSource.slice(sendSource.indexOf('fetch("/api/consult"')); + assert.match(stream, /event\.code === "answer_truncated"/); + assert.match(stream, /truncatedFailure = event/); + assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/); + assert.match(stream, /await persistSession\(truncatedSession\)/); + assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/); + assert.match(stream, /if \(!runCompleted && !truncatedFailure\) throw new ConsultationResponseError/); + assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/); +}); diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 8b114c3d..debc108e 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -65,6 +65,15 @@ test("the model step budget and the wall-clock budget are declared as one pair", assert.doesNotMatch(route, /AbortSignal\.timeout\(\d/); }); +test("consult streams cap visible output and disable thinking instead of sharing the token budget with hidden reasoning", () => { + assert.match(tools, /export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;/); + assert.match(tools, /function consultationGenerationSettings/); + assert.match(tools, /thinking: \{ type: "disabled"/); + assert.match(tools, /maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS/); + assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/); + assert.doesNotMatch(route, /maxOutputTokens:\s*\d/); +}); + test("uses one runtime step append entry and no scattered hard-coded step cap", () => { assert.match(tools, /export function appendConsultationRuntimeStep/); assert.doesNotMatch(tools, /steps\.length\s*>=\s*32/); diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 5c150c51..42281bea 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -10,6 +10,10 @@ const chat = readFileSync( new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8", ); +const messageActions = readFileSync( + new URL("../src/components/chat-message-actions.tsx", import.meta.url), + "utf8", +); const route = readFileSync( new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8", @@ -274,8 +278,9 @@ test("rectification activity separates live work from the receipt above the Agen test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => { for (const label of ["赞", "踩", "复制回答", "重新生成回答"]) { - assert.match(chat, new RegExp(`aria-label="${label}"`)); + assert.match(messageActions, new RegExp(`aria-label="${label}"`)); } + assert.match(chat, /= 0 && replyIndex >= 0 && actionsIndex >= 0); assert.ok(receiptIndex < replyIndex && replyIndex < actionsIndex);