Compare commits
2 Commits
c38f11dbd3
...
4c33329b0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c33329b0c | |||
| 7d667fecbf |
@@ -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
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -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"
|
||||
? {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+139
-11
@@ -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<string | null>(null);
|
||||
const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
|
||||
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
|
||||
const [messageFeedback, setMessageFeedback] = useState<Record<string, ChatMessageFeedback>>({});
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [replyOutcome, setReplyOutcome] = useState<ReplyOutcome | null>(null);
|
||||
const [requestError, setRequestError] = useState<RequestError | null>(null);
|
||||
const [birthTimeConsultationConsent, setBirthTimeConsultationConsent] = useState<BirthTimeConsultationConsentState>(
|
||||
@@ -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<boolean> {
|
||||
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<ConsultationAgentPublicEvent, { type: "run.failed" }> | 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() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message) => (
|
||||
<ChatMessageRow key={message.renderKey} message={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 (
|
||||
<div className="message-entry" key={message.renderKey}>
|
||||
<ChatMessageRow message={message} />
|
||||
{showActions && (
|
||||
<ChatMessageActions
|
||||
feedback={messageFeedback[feedbackKey]}
|
||||
copied={copiedMessageKey === feedbackKey}
|
||||
canRegenerate={message.renderKey === latestRegeneratableKey}
|
||||
onFeedback={(requested) => 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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{activeError && <p className="error-message" role="alert">{activeError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="message-actions" aria-label="Agent 回答操作">
|
||||
<button
|
||||
aria-label="赞"
|
||||
aria-pressed={feedback === "up"}
|
||||
className={feedback === "up" ? "is-active" : ""}
|
||||
title="赞"
|
||||
type="button"
|
||||
onClick={() => onFeedback("up")}
|
||||
>
|
||||
<ThumbsUp aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="踩"
|
||||
aria-pressed={feedback === "down"}
|
||||
className={feedback === "down" ? "is-active" : ""}
|
||||
title="踩"
|
||||
type="button"
|
||||
onClick={() => onFeedback("down")}
|
||||
>
|
||||
<ThumbsDown aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="复制回答" title="复制" type="button" onClick={onCopy}>
|
||||
{copied ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="重新生成回答"
|
||||
disabled={!canRegenerate}
|
||||
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
|
||||
type="button"
|
||||
onClick={onRegenerate}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<ChatMessageView["activity"]>["phase"] {
|
||||
@@ -584,48 +588,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
/>
|
||||
)}
|
||||
{showActions && !regenerating && (
|
||||
<div className="rectification-message-actions" aria-label="Agent 回答操作">
|
||||
<button
|
||||
aria-label="赞"
|
||||
aria-pressed={feedback[message.renderKey] === "up"}
|
||||
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
|
||||
title="赞"
|
||||
type="button"
|
||||
onClick={() => setFeedback((current) => ({
|
||||
...current,
|
||||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
|
||||
}))}
|
||||
>
|
||||
<ThumbsUp aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="踩"
|
||||
aria-pressed={feedback[message.renderKey] === "down"}
|
||||
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
|
||||
title="踩"
|
||||
type="button"
|
||||
onClick={() => setFeedback((current) => ({
|
||||
...current,
|
||||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
|
||||
}))}
|
||||
>
|
||||
<ThumbsDown aria-hidden="true" />
|
||||
</button>
|
||||
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
|
||||
{copiedMessageKey === message.renderKey
|
||||
? <Check aria-hidden="true" />
|
||||
: <Copy aria-hidden="true" />}
|
||||
</button>
|
||||
<button
|
||||
aria-label="重新生成回答"
|
||||
disabled={!canRegenerate}
|
||||
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
|
||||
type="button"
|
||||
onClick={() => void regenerateMessage(message)}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<ChatMessageActions
|
||||
feedback={feedback[message.renderKey]}
|
||||
copied={copiedMessageKey === message.renderKey}
|
||||
canRegenerate={canRegenerate}
|
||||
onFeedback={(requested) => setFeedback((current) => ({
|
||||
...current,
|
||||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
|
||||
}))}
|
||||
onCopy={() => void copyMessage(message)}
|
||||
onRegenerate={() => void regenerateMessage(message)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -180,6 +180,7 @@ export const logAgentObservability = createAgentObservabilityLogger();
|
||||
const knownErrorCodes = new Set([
|
||||
"runtime_contract_incomplete",
|
||||
"empty_answer",
|
||||
"answer_truncated",
|
||||
"calculation_failed",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, typeof thinkingDisabled> = {
|
||||
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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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, /<ChatMessageActions/);
|
||||
assert.match(pageSource, /toggleChatMessageFeedback/);
|
||||
assert.match(pageSource, /function regenerateLatestAnswer\(renderKey: string\)/);
|
||||
assert.match(pageSource, /messages: session\.messages\.slice\(0, -1\)/);
|
||||
assert.match(pageSource, /restoreOnFailure: session/);
|
||||
assert.match(rectificationChat, /<ChatMessageActions/);
|
||||
assert.match(globalStyles, /\.message-actions \{/);
|
||||
assert.doesNotMatch(globalStyles, /\.rectification-message-actions \{/);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AGENT_MAX_STEPS,
|
||||
AGENT_TIMEOUT_MS,
|
||||
CONSULTATION_MAX_OUTPUT_TOKENS,
|
||||
mergeConsultationAnswerPolicies,
|
||||
CONSULTATION_DOMAIN_WALL_CLOCK_MS,
|
||||
MAX_CONSULTATION_DOMAINS,
|
||||
appendConsultationRuntimeStep,
|
||||
canonicalDomainPlan,
|
||||
consultationGenerationSettings,
|
||||
consultationModelStepTelemetry,
|
||||
consultationStepBudgetReceipt,
|
||||
consultationToolFailureCode,
|
||||
@@ -1175,6 +1178,87 @@ test("a calculation still unanswered after the retry fails the run rather than b
|
||||
assert.match(failure.message, /不会扣点/);
|
||||
});
|
||||
|
||||
test("a length-limited answer is not billed or delivered as a completed consultation", async () => {
|
||||
// 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();
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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, /<ChatMessageActions/);
|
||||
assert.match(chat, /toggleRectificationFeedback/);
|
||||
assert.match(chat, /navigator\.clipboard\.writeText\(message\.text\)/);
|
||||
assert.match(chat, /\/turns\/\$\{encodeURIComponent\(message\.turnId\)\}\/regenerate/);
|
||||
@@ -287,7 +292,7 @@ test("completed Agent replies restore feedback, copy and safe in-place regenerat
|
||||
);
|
||||
const receiptIndex = messageRender.indexOf("<CompletedActivityReceipt");
|
||||
const replyIndex = messageRender.indexOf("<ChatMessageRow");
|
||||
const actionsIndex = messageRender.indexOf('className="rectification-message-actions"');
|
||||
const actionsIndex = messageRender.indexOf("<ChatMessageActions");
|
||||
assert.ok(receiptIndex >= 0 && replyIndex >= 0 && actionsIndex >= 0);
|
||||
assert.ok(receiptIndex < replyIndex && replyIndex < actionsIndex);
|
||||
|
||||
@@ -337,8 +342,8 @@ test("rectification Agent output stays natural and keeps tool execution silent",
|
||||
assert.match(agent, /工具执行过程保持静默/);
|
||||
assert.match(agent, /本轮做了什么/);
|
||||
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
|
||||
assert.match(agent, /如果当前不需要追问,可以直接解释结果、说明边界或自然结束本轮/);
|
||||
assert.match(agent, /没有更多事件/);
|
||||
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
|
||||
assert.match(agent, /暂时想不到了 \/ 没有更多 \/ 先这样/);
|
||||
assert.match(skill, /不得叙述读取 Skill/);
|
||||
assert.match(skill, /完整回复可以(?:是)?零(?:个)?问题/);
|
||||
assert.match(skill, /同一用户可以保留多个可恢复 Case/);
|
||||
|
||||
Reference in New Issue
Block a user