Compare commits
2 Commits
036a756be3
...
e7f4030e3f
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f4030e3f | |||
| 6a44c778c3 |
+17
-1
@@ -4583,7 +4583,7 @@
|
||||
- 修复:可见正文在 `length` 结束,或超时/中止时已有输出,改为 `run.failed` / `answer_truncated`,保留已流出文本,账务走 `cancel`。咨询流设置 `maxOutputTokens = 8192`,并对当前供应商与 `openai` 兼容键关闭 thinking。客户端保存半截助手消息并提示未完成、不会扣点,不再要求 `run.completed` 才落盘。
|
||||
- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 覆盖 `length` 与超时半截不得 `run.completed`、不得调用 `onComplete`;`consultation-workflow-contract.test.ts` 锁定输出预算与 thinking disabled;`consultation-recovery.test.ts` 与 `chat-stream-layout.test.ts` 锁定半截落盘与提示;`agent-observability.test.ts` 将 `answer_truncated` 纳入已知错误码。
|
||||
- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留可见 token 预算;不得依赖 Flash 默认 thinking 与提供方默认 `max_tokens`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。
|
||||
- 相关记录:BUG-280、BUG-277
|
||||
- 相关记录:BUG-280、BUG-277、BUG-340
|
||||
- 复发自:无
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -5115,6 +5115,22 @@
|
||||
- 复发自:BUG-335(把入口 portal 到页头时用了 effect 同步 setState)
|
||||
- 修复版本:f4619df2
|
||||
|
||||
## BUG-340 | 生时纠正未关闭 thinking,半截回答仍可能被当成完成
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-08-21
|
||||
- 最近更新:2026-08-21
|
||||
- 影响面:`POST /api/rectification/agent`、`runV9AgentTurn`、生时纠正与普通咨询等待态
|
||||
- 用户现象:Agent 写到一半停止,界面却像已经答完;等待期间只有「正在处理…」,看不出在做什么。
|
||||
- 触发条件:当前会话模型默认开启 thinking / reasoning;可见正文与隐藏推理共用输出预算,或墙钟超时后仍发出 `finish`。
|
||||
- 根因:BUG-305 只修了咨询路径。纠正 `agent.stream` 没有 `maxOutputTokens`、没有关闭 thinking,并把任意 `finish` 当成成功。进度事件 `tool.activity started` 被客户端丢掉,所以长计算期间用户只能干等。
|
||||
- 修复:咨询与纠正共用 `agentGenerationSettings`(8192 可见 token,thinking disabled)。纠正在 `finish_reason=length` 时走 `answer_truncated`、不扣点、不把半截写入成功 Turn;客户端保留已流出正文并提示未完成。等待态改为公开工具进度(正在比较候选时间等)和 8 秒后的已用时,不展示模型思维链。
|
||||
- 验证:`frontend/tests/rectification-v9-stream.test.ts` 的 length 夹断不得 `run.completed`;`frontend/tests/rectification-v9-agent.test.ts` 锁定纠正流的输出预算与 thinking disabled;`frontend/tests/agent-activity-progress.test.ts`、`frontend/tests/rectification-agentic-entry.test.ts`、`frontend/tests/consultation-workflow-contract.test.ts`。
|
||||
- 防复发:纠正与咨询必须走同一套 generation settings。不得为了等待体验打开 provider thinking。`tool.activity started` 必须驱动 Orb 文案。`finish_reason=length` 不得映射为 `run.completed`。
|
||||
- 相关记录:BUG-305、BUG-282、BUG-329
|
||||
- 复发自:BUG-305(咨询已修,纠正仍用默认 thinking 与任意 finish)
|
||||
- 修复版本:`6a44c778`
|
||||
|
||||
## BUG-329 | 生时纠正 Agent 回答在结算后一次性出现,推理中无法停止
|
||||
|
||||
- 状态:resolved
|
||||
|
||||
@@ -290,6 +290,7 @@ export async function POST(request: Request) {
|
||||
emit: (event) => send(event),
|
||||
signal: request.signal,
|
||||
timeContext,
|
||||
generationModel: selectedModel.model,
|
||||
buildAgent: (turnId, skillPackage, attemptId) => Promise.resolve(
|
||||
getRectificationV9Agent(selectedModel, {
|
||||
userId,
|
||||
|
||||
@@ -241,8 +241,29 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
}
|
||||
.markdown-table tr:last-child th,
|
||||
.markdown-table tr:last-child td { border-bottom: 0; }
|
||||
.agent-activity-status { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); line-height: 1.5; }
|
||||
.agent-activity-status {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.agent-activity-status__row { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; width: 100%; }
|
||||
.agent-activity-status__live { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); min-width: 0; }
|
||||
.agent-activity-status canvas { flex: 0 0 auto; }
|
||||
.agent-activity-status__elapsed {
|
||||
flex: 0 0 auto;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.agent-activity-status__trail {
|
||||
margin: 0;
|
||||
padding-left: 28px;
|
||||
color: var(--color-ink-tertiary);
|
||||
}
|
||||
.agent-activity-status__text {
|
||||
color: var(--color-ink-tertiary);
|
||||
background: linear-gradient(
|
||||
|
||||
@@ -92,12 +92,20 @@ import {
|
||||
BALANCE_CHANGED_EVENT,
|
||||
membershipHref,
|
||||
} from "@/lib/membership";
|
||||
import { chatMessageViews, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import { chatMessageViews, nextActivityView, activityCompletedTrail, type AgentActivityView, type ChatMessage } from "@/lib/chat-message-view";
|
||||
import {
|
||||
createNdjsonParser,
|
||||
type AgentExecutionReceipt,
|
||||
type ConsultationAgentPublicEvent,
|
||||
} from "@/lib/consultation-agent-events";
|
||||
import {
|
||||
CONSULTATION_CHART_CALCULATION_LABEL,
|
||||
CONSULTATION_COMPOSING_LABEL,
|
||||
CONSULTATION_DONE_CHART_LABEL,
|
||||
CONSULTATION_DONE_SKILL_LABEL,
|
||||
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
|
||||
CONSULTATION_LOADING_METHOD_LABEL,
|
||||
} from "@/lib/consultation-activity-labels";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
|
||||
import {
|
||||
@@ -3200,7 +3208,13 @@ export default function Home() {
|
||||
const updateStreamingAnswer = (activity?: AgentActivityView) => {
|
||||
const partialReply = parseAgentReply(answer).text;
|
||||
latestPartialReply = partialReply;
|
||||
setStreamingReply({ sessionId, text: partialReply, activity });
|
||||
setStreamingReply((current) => ({
|
||||
sessionId,
|
||||
text: partialReply,
|
||||
activity: activity
|
||||
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
|
||||
: current?.sessionId === sessionId ? current.activity : undefined,
|
||||
}));
|
||||
if (partialReply && pendingConsultation.current?.requestId === requestId) {
|
||||
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
|
||||
}
|
||||
@@ -3208,15 +3222,29 @@ export default function Home() {
|
||||
const updateActivity = (event: ConsultationAgentPublicEvent) => {
|
||||
let activity: AgentActivityView | undefined;
|
||||
if (event.type === "skill.started") {
|
||||
activity = { phase: "loading-method", label: "正在读取印度占星分析规则…" };
|
||||
} else if (event.type === "tool.started") {
|
||||
activity = { phase: "chart-calculation", label: "正在计算本命盘…" };
|
||||
activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL };
|
||||
} else if (event.type === "skill.completed" || event.type === "tool.started") {
|
||||
activity = {
|
||||
phase: "chart-calculation",
|
||||
label: CONSULTATION_CHART_CALCULATION_LABEL,
|
||||
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
|
||||
};
|
||||
} else if (event.type === "activity") {
|
||||
activity = { phase: event.phase, label: event.label };
|
||||
activity = {
|
||||
phase: event.phase,
|
||||
label: event.label,
|
||||
completedTrail: event.phase === "evidence-validation"
|
||||
? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL])
|
||||
: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
|
||||
};
|
||||
} else if (event.type === "tool.completed") {
|
||||
activity = { phase: "evidence-validation", label: "正在核对可用证据…" };
|
||||
activity = {
|
||||
phase: "evidence-validation",
|
||||
label: CONSULTATION_EVIDENCE_VALIDATION_LABEL,
|
||||
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]),
|
||||
};
|
||||
} else if (event.type === "answer.delta") {
|
||||
activity = { phase: "answer-composition", label: "正在组织回答…" };
|
||||
activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL };
|
||||
}
|
||||
if (activity) updateStreamingAnswer(activity);
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { OrbState } from "thinking-orbs";
|
||||
|
||||
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
|
||||
import { activityElapsedLabel } from "@/lib/chat-message-view";
|
||||
|
||||
const labels = {
|
||||
working: "正在处理任务…",
|
||||
@@ -27,17 +29,40 @@ prefetchOnIdle(importThinkingOrb);
|
||||
|
||||
export type AgentActivityState = OrbState;
|
||||
|
||||
function ActivityElapsed({ startedAt }: Readonly<{ startedAt: number }>) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
const label = activityElapsedLabel(startedAt, now);
|
||||
if (!label) return null;
|
||||
return <span className="agent-activity-status__elapsed" aria-hidden="true">{label}</span>;
|
||||
}
|
||||
|
||||
export function AgentActivityStatus({
|
||||
state,
|
||||
label = labels[state],
|
||||
startedAt,
|
||||
completedTrail,
|
||||
}: Readonly<{
|
||||
state: AgentActivityState;
|
||||
label?: string;
|
||||
startedAt?: number;
|
||||
completedTrail?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="agent-activity-status" role="status">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<span key={label} className="agent-activity-status__text">{label}</span>
|
||||
<div className="agent-activity-status">
|
||||
<div className="agent-activity-status__row">
|
||||
<div className="agent-activity-status__live" role="status">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<span key={label} className="agent-activity-status__text">{label}</span>
|
||||
</div>
|
||||
{startedAt ? <ActivityElapsed key={startedAt} startedAt={startedAt} /> : null}
|
||||
</div>
|
||||
{completedTrail ? (
|
||||
<p className="agent-activity-status__trail" aria-hidden="true">{completedTrail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,12 @@ export function ChatMessageRow({
|
||||
{message.role === "assistant" ? (
|
||||
<>
|
||||
{showActivity && (
|
||||
<AgentActivityStatus state={activityState} label={activityLabel} />
|
||||
<AgentActivityStatus
|
||||
state={activityState}
|
||||
label={activityLabel}
|
||||
startedAt={message.activity?.startedAt}
|
||||
completedTrail={message.activity?.completedTrail}
|
||||
/>
|
||||
)}
|
||||
{message.text && (
|
||||
<ChatMessageContent
|
||||
|
||||
@@ -2,27 +2,14 @@
|
||||
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
|
||||
import { RECTIFICATION_TOOL_DONE_LABELS } from "@/lib/rectification-activity-labels";
|
||||
import type {
|
||||
PublicRectificationMethod,
|
||||
PublicRectificationTool,
|
||||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import { PUBLIC_RECTIFICATION_METHOD_LABELS } from "@/lib/rectification-varga-sentence";
|
||||
|
||||
const TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
||||
"rectification-read-case": "读取校正记录",
|
||||
"rectification-set-focus": "设置对话焦点",
|
||||
"rectification-resolve-focus": "处理当前焦点",
|
||||
"rectification-record-evidence-batch": "整理多条事件证据",
|
||||
"rectification-propose-evidence": "整理事件证据",
|
||||
"rectification-confirm-evidence": "确认事件证据",
|
||||
"rectification-revise-evidence": "修订事件证据",
|
||||
"rectification-compare-candidates": "比较候选时间",
|
||||
"rectification-read-diagnostics": "检查候选稳健性",
|
||||
"rectification-offer-candidates": "生成候选建议",
|
||||
"rectification-accept-candidate": "采用候选时间",
|
||||
"rectification-confirm-birth-time": "确认校正时间",
|
||||
"rectification-close-case": "完成校正记录",
|
||||
};
|
||||
const TOOL_LABELS = RECTIFICATION_TOOL_DONE_LABELS;
|
||||
|
||||
const METHOD_LABELS = PUBLIC_RECTIFICATION_METHOD_LABELS;
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { ArrowUp, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { nextActivityView, type ChatMessage, type ChatMessageView } from "@/lib/chat-message-view";
|
||||
import {
|
||||
RECTIFICATION_TOOL_PROGRESS_LABELS,
|
||||
rectificationCompletedTrail,
|
||||
rectificationToolActivityPhase,
|
||||
} from "@/lib/rectification-activity-labels";
|
||||
import {
|
||||
createRectificationActivityReceiptState,
|
||||
receiptFromRectificationActivityState,
|
||||
@@ -348,7 +353,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
startedAt: Date.now(),
|
||||
} },
|
||||
]);
|
||||
setDraft("");
|
||||
|
||||
@@ -426,7 +435,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
raw += event.text;
|
||||
const parsed = parseAgentReply(raw);
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "streaming" }
|
||||
? {
|
||||
...message,
|
||||
text: parsed.text,
|
||||
state: "streaming",
|
||||
activity: nextActivityView(message.activity, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
}),
|
||||
}
|
||||
: message));
|
||||
} else if (event.type === "attempt.reset") {
|
||||
raw = "";
|
||||
@@ -434,7 +451,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
|
||||
completedTurnId = undefined;
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: "", state: "thinking", completedReceipt: undefined, failed: false, turnId: undefined }
|
||||
? {
|
||||
...message,
|
||||
text: "",
|
||||
state: "thinking",
|
||||
completedReceipt: undefined,
|
||||
failed: false,
|
||||
turnId: undefined,
|
||||
activity: nextActivityView(undefined, {
|
||||
phase: "evidence-validation",
|
||||
label: "正在处理…",
|
||||
}),
|
||||
}
|
||||
: message));
|
||||
} else if (event.type === "run.failed") {
|
||||
streamFailed = true;
|
||||
@@ -447,7 +475,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
} else if (event.type === "tool.activity") {
|
||||
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
|
||||
if (!tool) continue;
|
||||
if (event.status === "started") continue;
|
||||
if (event.status === "started") {
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
activity: nextActivityView(message.activity, {
|
||||
phase: rectificationToolActivityPhase(tool),
|
||||
label: RECTIFICATION_TOOL_PROGRESS_LABELS[tool],
|
||||
completedTrail: rectificationCompletedTrail(activityReceiptState.completedSteps),
|
||||
}),
|
||||
}
|
||||
: message));
|
||||
continue;
|
||||
}
|
||||
if (event.status !== "completed" && event.status !== "failed") continue;
|
||||
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
|
||||
tool,
|
||||
@@ -473,20 +513,24 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
completedReceipt,
|
||||
failed: false,
|
||||
turnId: completedTurnId,
|
||||
activity: undefined,
|
||||
}];
|
||||
}
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt)) {
|
||||
if (streamFailed || hasActivityReceipt(completedReceipt) || parsed.text) {
|
||||
return [{
|
||||
...message,
|
||||
text: "",
|
||||
text: parsed.text,
|
||||
state: "settled",
|
||||
completedReceipt,
|
||||
failed: true,
|
||||
activity: undefined,
|
||||
}];
|
||||
}
|
||||
return [];
|
||||
}));
|
||||
if (!succeeded && completedReceipt.failedTool) {
|
||||
if (!succeeded && parsed.text) {
|
||||
setError((current) => current || "回答未完成,已保留现有内容;本次不会扣点。");
|
||||
} else if (!succeeded && completedReceipt.failedTool) {
|
||||
setError(completedReceipt.failedTool === "rectification-compare-candidates"
|
||||
? "候选比较未完成,当前进度已保留。"
|
||||
: "本轮处理未完成,当前进度已保留。请稍后再试。");
|
||||
@@ -719,7 +763,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
&& !readonly
|
||||
&& regeneratingMessageKey === null;
|
||||
const displayedMessage = regenerating
|
||||
? { ...message, text: "", state: "thinking" as const }
|
||||
? {
|
||||
...message,
|
||||
text: "",
|
||||
state: "thinking" as const,
|
||||
activity: nextActivityView(undefined, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
}),
|
||||
}
|
||||
: message;
|
||||
const vargaSentence = message.state === "settled" && !message.failed
|
||||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||||
@@ -728,13 +780,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
|
||||
{message.state === "settled" && message.failed && (
|
||||
<p className="rectification-activity-failure" role="status">
|
||||
本轮处理未完成,已保留服务端记录的执行进度。
|
||||
{message.text
|
||||
? "回答未完成,已保留现有内容;本次不会扣点。"
|
||||
: "本轮处理未完成,已保留服务端记录的执行进度。"}
|
||||
</p>
|
||||
)}
|
||||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||||
<ChatMessageRow
|
||||
message={displayedMessage}
|
||||
showActivity={displayedMessage.state === "thinking"}
|
||||
showActivity={displayedMessage.state !== "settled"}
|
||||
vargaSentence={vargaSentence}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Visible-answer generation settings shared by consultation and rectification.
|
||||
*
|
||||
* DeepSeek V4 Flash thinks by default, and those hidden tokens share
|
||||
* `max_tokens` with the spoken answer. Without an explicit visible budget and
|
||||
* thinking turned off, a finished-looking stream can stop mid-heading with
|
||||
* `finish_reason=length`. The provider id is repeated under `openai` because
|
||||
* OpenAI-compatible adapters often look there first.
|
||||
*/
|
||||
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
|
||||
|
||||
const thinkingDisabled = { thinking: { type: "disabled" as const } };
|
||||
|
||||
export function agentGenerationSettings(model?: unknown) {
|
||||
const providerId = typeof model === "string"
|
||||
? model
|
||||
: model && typeof model === "object" && "providerId" in model && typeof model.providerId === "string"
|
||||
? model.providerId
|
||||
: undefined;
|
||||
const providerOptions: Record<string, typeof thinkingDisabled> = {
|
||||
openai: thinkingDisabled,
|
||||
};
|
||||
if (providerId) providerOptions[providerId] = thinkingDisabled;
|
||||
return {
|
||||
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
|
||||
providerOptions,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,45 @@
|
||||
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
|
||||
|
||||
export const ACTIVITY_ELAPSED_VISIBLE_AFTER_MS = 8_000;
|
||||
export const ACTIVITY_COMPLETED_TRAIL_LIMIT = 3;
|
||||
|
||||
export type AgentActivityView = Readonly<{
|
||||
phase: PublicActivityPhase;
|
||||
label: string;
|
||||
startedAt?: number;
|
||||
completedTrail?: string;
|
||||
}>;
|
||||
|
||||
export function activityCompletedTrail(steps: readonly string[]): string | undefined {
|
||||
if (steps.length === 0) return undefined;
|
||||
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
|
||||
}
|
||||
|
||||
export function nextActivityView(
|
||||
previous: AgentActivityView | undefined,
|
||||
next: Omit<AgentActivityView, "startedAt">,
|
||||
now = Date.now(),
|
||||
): AgentActivityView {
|
||||
const startedAt = previous?.label === next.label && previous.startedAt ? previous.startedAt : now;
|
||||
const completedTrail = next.phase === "answer-composition"
|
||||
? undefined
|
||||
: next.completedTrail !== undefined
|
||||
? next.completedTrail || undefined
|
||||
: previous?.completedTrail;
|
||||
return {
|
||||
phase: next.phase,
|
||||
label: next.label,
|
||||
startedAt,
|
||||
...(completedTrail ? { completedTrail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function activityElapsedLabel(startedAt: number, now: number): string | null {
|
||||
const elapsedMs = now - startedAt;
|
||||
if (elapsedMs < ACTIVITY_ELAPSED_VISIBLE_AFTER_MS) return null;
|
||||
return `已用时 ${Math.floor(elapsedMs / 1000)} 秒`;
|
||||
}
|
||||
|
||||
export type ChatMessage = {
|
||||
readonly role: "user" | "assistant";
|
||||
readonly text: string;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const CONSULTATION_LOADING_METHOD_LABEL = "正在读取印度占星分析规则…";
|
||||
export const CONSULTATION_CHART_CALCULATION_LABEL = "正在计算本命盘…";
|
||||
export const CONSULTATION_EVIDENCE_VALIDATION_LABEL = "正在核对可用证据…";
|
||||
export const CONSULTATION_COMPOSING_LABEL = "正在组织回答…";
|
||||
export const CONSULTATION_DONE_SKILL_LABEL = "读取分析方法";
|
||||
export const CONSULTATION_DONE_CHART_LABEL = "计算本命盘";
|
||||
|
||||
export function chartCalculationProgressLabel(current: number, total: number): string {
|
||||
if (total <= 1) return CONSULTATION_CHART_CALCULATION_LABEL;
|
||||
return `正在计算本命盘(第 ${current}/${total} 项)…`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { activityCompletedTrail } from "./chat-message-view.ts";
|
||||
import type { PublicActivityPhase } from "./consultation-agent-events.ts";
|
||||
import type { PublicRectificationTool } from "./rectification-agentic/v9/public-receipt.ts";
|
||||
|
||||
export const RECTIFICATION_TOOL_DONE_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
||||
"rectification-read-case": "读取校正记录",
|
||||
"rectification-set-focus": "设置对话焦点",
|
||||
"rectification-resolve-focus": "处理当前焦点",
|
||||
"rectification-record-evidence-batch": "整理多条事件证据",
|
||||
"rectification-propose-evidence": "整理事件证据",
|
||||
"rectification-confirm-evidence": "确认事件证据",
|
||||
"rectification-revise-evidence": "修订事件证据",
|
||||
"rectification-compare-candidates": "比较候选时间",
|
||||
"rectification-read-diagnostics": "检查候选稳健性",
|
||||
"rectification-offer-candidates": "生成候选建议",
|
||||
"rectification-accept-candidate": "采用候选时间",
|
||||
"rectification-confirm-birth-time": "确认校正时间",
|
||||
"rectification-close-case": "完成校正记录",
|
||||
};
|
||||
|
||||
export const RECTIFICATION_TOOL_PROGRESS_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
|
||||
"rectification-read-case": "正在读取校正记录…",
|
||||
"rectification-set-focus": "正在设置对话焦点…",
|
||||
"rectification-resolve-focus": "正在处理当前焦点…",
|
||||
"rectification-record-evidence-batch": "正在整理多条事件证据…",
|
||||
"rectification-propose-evidence": "正在整理事件证据…",
|
||||
"rectification-confirm-evidence": "正在确认事件证据…",
|
||||
"rectification-revise-evidence": "正在修订事件证据…",
|
||||
"rectification-compare-candidates": "正在比较候选时间…",
|
||||
"rectification-read-diagnostics": "正在检查候选稳健性…",
|
||||
"rectification-offer-candidates": "正在生成候选建议…",
|
||||
"rectification-accept-candidate": "正在采用候选时间…",
|
||||
"rectification-confirm-birth-time": "正在确认校正时间…",
|
||||
"rectification-close-case": "正在完成校正记录…",
|
||||
};
|
||||
|
||||
const COMPARE_TOOLS = new Set<PublicRectificationTool>([
|
||||
"rectification-compare-candidates",
|
||||
"rectification-read-diagnostics",
|
||||
"rectification-offer-candidates",
|
||||
]);
|
||||
|
||||
const LOAD_TOOLS = new Set<PublicRectificationTool>([
|
||||
"rectification-read-case",
|
||||
"rectification-set-focus",
|
||||
"rectification-resolve-focus",
|
||||
]);
|
||||
|
||||
export function rectificationCompletedTrail(steps: readonly PublicRectificationTool[]): string | undefined {
|
||||
return activityCompletedTrail(steps.map((tool) => RECTIFICATION_TOOL_DONE_LABELS[tool]));
|
||||
}
|
||||
|
||||
export function rectificationToolActivityPhase(tool: PublicRectificationTool): PublicActivityPhase {
|
||||
if (LOAD_TOOLS.has(tool)) return "loading-method";
|
||||
if (COMPARE_TOOLS.has(tool)) return "chart-calculation";
|
||||
if (
|
||||
tool === "rectification-accept-candidate"
|
||||
|| tool === "rectification-confirm-birth-time"
|
||||
|| tool === "rectification-close-case"
|
||||
) {
|
||||
return "answer-composition";
|
||||
}
|
||||
return "evidence-validation";
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type V9CaseDossier,
|
||||
} from "./tool-service";
|
||||
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "./case-status";
|
||||
import { agentGenerationSettings } from "../../agent-generation-settings.ts";
|
||||
import { toAgentModelFinishReason } from "../../agent-observability.ts";
|
||||
import {
|
||||
resolveExactSkillPackage,
|
||||
type ResolvedSkillPackageIdentity,
|
||||
@@ -58,6 +60,7 @@ export type V9AgentRunOptions = Readonly<{
|
||||
emit(event: PublicStreamEvent): Promise<void> | void;
|
||||
signal?: AbortSignal;
|
||||
timeContext?: string;
|
||||
generationModel?: unknown;
|
||||
}>;
|
||||
|
||||
export type V9AgentRunResult = Readonly<{
|
||||
@@ -100,6 +103,14 @@ const RETRYABLE_ERROR_CODES = new Set([
|
||||
"focus_persistence_failed",
|
||||
]);
|
||||
|
||||
function streamFinishReason(chunk: {
|
||||
type: string;
|
||||
payload?: { stepResult?: { reason?: unknown }; reason?: unknown };
|
||||
}): ReturnType<typeof toAgentModelFinishReason> | null {
|
||||
if (chunk.type !== "finish") return null;
|
||||
return toAgentModelFinishReason(chunk.payload?.stepResult?.reason ?? chunk.payload?.reason);
|
||||
}
|
||||
|
||||
function first(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value[0] ?? null;
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
@@ -129,6 +140,7 @@ function safeErrorCode(error: unknown): string {
|
||||
"case_not_loaded",
|
||||
"repeated_tool_call",
|
||||
"focus_persistence_failed",
|
||||
"answer_truncated",
|
||||
]) {
|
||||
if (message.includes(code)) return code;
|
||||
}
|
||||
@@ -489,12 +501,15 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
await publish({ type: "skill.bound" });
|
||||
emittedKeys.add("event:skill.bound::");
|
||||
|
||||
const generation = agentGenerationSettings(options.generationModel);
|
||||
const result = await (agent as unknown as {
|
||||
stream(
|
||||
messages: unknown[],
|
||||
streamOptions: {
|
||||
maxSteps: number;
|
||||
abortSignal: AbortSignal;
|
||||
modelSettings?: { maxOutputTokens?: number };
|
||||
providerOptions?: Record<string, { thinking: { type: "disabled" } }>;
|
||||
prepareStep: (input: { stepNumber: number }) => {
|
||||
activeTools: string[];
|
||||
toolChoice: "auto";
|
||||
@@ -503,7 +518,14 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
): Promise<{
|
||||
fullStream: AsyncIterable<{
|
||||
type: string;
|
||||
payload?: { toolName?: unknown; text?: unknown; args?: unknown; error?: unknown };
|
||||
payload?: {
|
||||
toolName?: unknown;
|
||||
text?: unknown;
|
||||
args?: unknown;
|
||||
error?: unknown;
|
||||
stepResult?: { reason?: unknown };
|
||||
reason?: unknown;
|
||||
};
|
||||
object?: unknown;
|
||||
}>;
|
||||
totalUsage?: Promise<{ inputTokens?: number; outputTokens?: number }>;
|
||||
@@ -511,6 +533,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}).stream(messages, {
|
||||
maxSteps,
|
||||
abortSignal: abortController.signal,
|
||||
...generation,
|
||||
// Thinking-mode providers reject named/required tool_choice. Restrict
|
||||
// the first step to read-case and keep tool_choice auto; the runner
|
||||
// still refuses any other public tool before case.loaded.
|
||||
@@ -522,6 +545,8 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
: undefined,
|
||||
});
|
||||
|
||||
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
|
||||
|
||||
for await (const chunk of result.fullStream) {
|
||||
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
|
||||
if (chunk.type === "tool-call") {
|
||||
@@ -592,7 +617,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
}
|
||||
for (const toolName of streamToolNames(chunk as never)) toolsUsed.add(toolName);
|
||||
if (chunk.type === "error" || chunk.type === "abort") streamFailed = true;
|
||||
if (chunk.type === "finish") finished = true;
|
||||
if (chunk.type === "finish") {
|
||||
finished = true;
|
||||
finishReason = streamFinishReason(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
|
||||
@@ -600,6 +628,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
|
||||
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
|
||||
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
|
||||
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
|
||||
if (finishReason === "length") {
|
||||
return {
|
||||
ok: false,
|
||||
status: "failed",
|
||||
errorCode: "answer_truncated",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
answerText,
|
||||
answerDeltas,
|
||||
phases,
|
||||
toolsUsed: [...toolsUsed],
|
||||
events,
|
||||
skillBound,
|
||||
caseLoaded,
|
||||
attemptId,
|
||||
};
|
||||
}
|
||||
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -13,6 +13,8 @@ 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 { chartCalculationProgressLabel } from "../lib/consultation-activity-labels.ts";
|
||||
import {
|
||||
consultationEvidencePacketSchema,
|
||||
consultationInputSchema,
|
||||
@@ -24,6 +26,8 @@ import {
|
||||
type ConsultationEvidencePacket,
|
||||
} from "./consultation-workflow.ts";
|
||||
|
||||
export { AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS } from "../lib/agent-generation-settings.ts";
|
||||
|
||||
// The budgets that bound one consultation run. They all constrain the same
|
||||
// wall clock, so they are declared together and must be changed together.
|
||||
//
|
||||
@@ -42,7 +46,6 @@ 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;
|
||||
@@ -51,31 +54,8 @@ 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,
|
||||
};
|
||||
return agentGenerationSettings(model);
|
||||
}
|
||||
|
||||
// The raw plan bound stays at the registry default so a duplicate-heavy list
|
||||
@@ -562,16 +542,21 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
const currentCalculation = (async () => {
|
||||
try {
|
||||
const userIntent = ctx.plan?.userIntent ?? input.question;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: { phase: "chart-calculation", label: "正在计算本命盘" },
|
||||
});
|
||||
const executions: DomainExecution[] = [];
|
||||
for (const domain of domains) {
|
||||
for (let index = 0; index < domains.length; index += 1) {
|
||||
const domain = domains[index];
|
||||
if (!domain) continue;
|
||||
// Every domain shares the run's single abort deadline, so a plan
|
||||
// that runs long would abort mid-loop and lose the domains already
|
||||
// calculated. Stop while there is still time to answer instead.
|
||||
if (!domainFitsRunBudget(now() - startedAt, executions.length)) break;
|
||||
await context.writer?.custom({
|
||||
type: "data-jyotish-activity",
|
||||
data: {
|
||||
phase: "chart-calculation",
|
||||
label: chartCalculationProgressLabel(index + 1, domains.length),
|
||||
},
|
||||
});
|
||||
const domainPlan = ctx.plan
|
||||
&& domains.length === 1
|
||||
&& ctx.plan.requestedDomains.length === 1
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { agentGenerationSettings, AGENT_MAX_OUTPUT_TOKENS } from "../src/lib/agent-generation-settings.ts";
|
||||
import {
|
||||
activityCompletedTrail,
|
||||
activityElapsedLabel,
|
||||
nextActivityView,
|
||||
} from "../src/lib/chat-message-view.ts";
|
||||
import {
|
||||
chartCalculationProgressLabel,
|
||||
CONSULTATION_CHART_CALCULATION_LABEL,
|
||||
} from "../src/lib/consultation-activity-labels.ts";
|
||||
import {
|
||||
RECTIFICATION_TOOL_PROGRESS_LABELS,
|
||||
rectificationCompletedTrail,
|
||||
rectificationToolActivityPhase,
|
||||
} from "../src/lib/rectification-activity-labels.ts";
|
||||
|
||||
test("generation settings reserve visible tokens and disable thinking", () => {
|
||||
const settings = agentGenerationSettings({ providerId: "deepseek" });
|
||||
assert.equal(AGENT_MAX_OUTPUT_TOKENS, 8192);
|
||||
assert.equal(settings.modelSettings.maxOutputTokens, 8192);
|
||||
assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "disabled" } });
|
||||
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "disabled" } });
|
||||
});
|
||||
|
||||
test("activity elapsed copy stays hidden until eight seconds", () => {
|
||||
assert.equal(activityElapsedLabel(1_000, 8_999), null);
|
||||
assert.equal(activityElapsedLabel(1_000, 9_000), "已用时 8 秒");
|
||||
assert.equal(activityElapsedLabel(1_000, 25_000), "已用时 24 秒");
|
||||
});
|
||||
|
||||
test("the same activity label keeps its start time", () => {
|
||||
const first = nextActivityView(undefined, {
|
||||
phase: "chart-calculation",
|
||||
label: "正在比较候选时间…",
|
||||
}, 10);
|
||||
const same = nextActivityView(first, {
|
||||
phase: "chart-calculation",
|
||||
label: "正在比较候选时间…",
|
||||
}, 40);
|
||||
const next = nextActivityView(same, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
}, 50);
|
||||
assert.equal(first.startedAt, 10);
|
||||
assert.equal(same.startedAt, 10);
|
||||
assert.equal(next.startedAt, 50);
|
||||
});
|
||||
|
||||
test("completed-step trail stays short and drops while composing", () => {
|
||||
assert.equal(activityCompletedTrail([]), undefined);
|
||||
assert.equal(
|
||||
activityCompletedTrail(["读取校正记录", "整理事件证据"]),
|
||||
"已完成:读取校正记录 · 整理事件证据",
|
||||
);
|
||||
assert.equal(
|
||||
activityCompletedTrail(["一", "二", "三", "四"]),
|
||||
"已完成:二 · 三 · 四",
|
||||
);
|
||||
const waiting = nextActivityView(undefined, {
|
||||
phase: "chart-calculation",
|
||||
label: "正在比较候选时间…",
|
||||
completedTrail: activityCompletedTrail(["读取校正记录", "整理事件证据"]),
|
||||
}, 10);
|
||||
assert.equal(waiting.completedTrail, "已完成:读取校正记录 · 整理事件证据");
|
||||
const composing = nextActivityView(waiting, {
|
||||
phase: "answer-composition",
|
||||
label: "正在组织回答…",
|
||||
}, 20);
|
||||
assert.equal(composing.completedTrail, undefined);
|
||||
});
|
||||
|
||||
test("live rectification labels name the actual public tool", () => {
|
||||
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-read-case"], "正在读取校正记录…");
|
||||
assert.equal(RECTIFICATION_TOOL_PROGRESS_LABELS["rectification-compare-candidates"], "正在比较候选时间…");
|
||||
assert.equal(rectificationToolActivityPhase("rectification-read-case"), "loading-method");
|
||||
assert.equal(rectificationToolActivityPhase("rectification-compare-candidates"), "chart-calculation");
|
||||
assert.equal(rectificationToolActivityPhase("rectification-propose-evidence"), "evidence-validation");
|
||||
assert.equal(
|
||||
rectificationCompletedTrail(["rectification-read-case", "rectification-propose-evidence"]),
|
||||
"已完成:读取校正记录 · 整理事件证据",
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-domain chart calculation names the current item without domain ids", () => {
|
||||
assert.equal(chartCalculationProgressLabel(1, 1), CONSULTATION_CHART_CALCULATION_LABEL);
|
||||
assert.equal(chartCalculationProgressLabel(2, 3), "正在计算本命盘(第 2/3 项)…");
|
||||
assert.doesNotMatch(chartCalculationProgressLabel(2, 3), /career|wealth|timing|marriage/);
|
||||
});
|
||||
@@ -62,10 +62,19 @@ test("shows honest agent activity states before and during streamed text", () =>
|
||||
assert.match(globalStyles, /\.agent-activity-status \+ \.message-answer/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /className="agent-activity-status__text"/);
|
||||
assert.match(activitySource, /className="agent-activity-status__elapsed" aria-hidden="true"/);
|
||||
assert.match(activitySource, /className="agent-activity-status__trail" aria-hidden="true"/);
|
||||
assert.match(activitySource, /role="status"/);
|
||||
assert.match(globalStyles, /@keyframes agent-activity-shimmer/);
|
||||
assert.match(globalStyles, /agent-activity-status-in 160ms ease-out/);
|
||||
assert.match(globalStyles, /\.agent-activity-status__row/);
|
||||
assert.match(globalStyles, /\.agent-activity-status__trail/);
|
||||
assert.match(globalStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.agent-activity-status__text[\s\S]*animation: none/);
|
||||
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成|completed/);
|
||||
assert.match(pageSource, /nextActivityView/);
|
||||
assert.match(pageSource, /chartCalculationProgressLabel|CONSULTATION_CHART_CALCULATION_LABEL/);
|
||||
assert.match(pageSource, /activityCompletedTrail\(\[CONSULTATION_DONE_SKILL_LABEL\]\)/);
|
||||
assert.doesNotMatch(activitySource, /CircleCheck|回答已完成/);
|
||||
assert.doesNotMatch(activitySource, /state === "completed"|: "completed"/);
|
||||
assert.doesNotMatch(messageRowSource, /: "completed"/);
|
||||
assert.doesNotMatch(globalStyles, /\.thinking\b/);
|
||||
assert.match(pageSource, /application\/x-ndjson/);
|
||||
|
||||
@@ -23,7 +23,8 @@ test("consultation plans are server-owned and bounded", () => {
|
||||
assert.match(tools, /const userIntent = ctx\.plan\?\.userIntent \?\? input\.question/);
|
||||
assert.match(tools, /input\.domains === undefined && context\.plan && context\.theme/);
|
||||
assert.match(tools, /return \[context\.theme\]/);
|
||||
assert.match(tools, /const domainPlan = ctx\.plan[\s\S]*createConsultationPlan\(\{/);
|
||||
assert.match(tools, /chartCalculationProgressLabel\(index \+ 1, domains\.length\)/);
|
||||
assert.match(tools, /phase: "chart-calculation"/);
|
||||
assert.match(tools, /userIntent,[\s\S]*theme: domain/);
|
||||
assert.match(tools, /question: userIntent,[\s\S]*theme: domain/);
|
||||
assert.match(tools, /plan: domainPlan/);
|
||||
@@ -66,10 +67,13 @@ test("the model step budget and the wall-clock budget are declared as one pair",
|
||||
});
|
||||
|
||||
test("consult streams cap visible output and disable thinking instead of sharing the token budget with hidden reasoning", () => {
|
||||
assert.match(tools, /export const CONSULTATION_MAX_OUTPUT_TOKENS = 8192;/);
|
||||
const settings = readFileSync(new URL("../src/lib/agent-generation-settings.ts", import.meta.url), "utf8");
|
||||
assert.match(settings, /export const AGENT_MAX_OUTPUT_TOKENS = 8192;/);
|
||||
assert.match(settings, /thinking: \{ type: "disabled"/);
|
||||
assert.match(settings, /maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS/);
|
||||
assert.match(tools, /function consultationGenerationSettings/);
|
||||
assert.match(tools, /thinking: \{ type: "disabled"/);
|
||||
assert.match(tools, /maxOutputTokens: CONSULTATION_MAX_OUTPUT_TOKENS/);
|
||||
assert.match(tools, /return agentGenerationSettings\(model\)/);
|
||||
assert.match(tools, /AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS/);
|
||||
assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/);
|
||||
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,7 @@ test("answer deltas preserve a still-running server activity", () => {
|
||||
chatSource.indexOf('event.type === "run.failed"'),
|
||||
);
|
||||
assert.match(deltaBranch, /state: "streaming"/);
|
||||
assert.match(deltaBranch, /正在组织回答/);
|
||||
assert.doesNotMatch(deltaBranch, /activeActivity:\s*undefined/);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,10 @@ const houseTable = readFileSync(
|
||||
new URL("../src/components/rectification-house-table.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const progressLabels = readFileSync(
|
||||
new URL("../src/lib/rectification-activity-labels.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const completedActivityReceipt = readFileSync(
|
||||
new URL("../src/components/completed-activity-receipt.tsx", import.meta.url),
|
||||
"utf8",
|
||||
@@ -231,6 +235,7 @@ test("rectification uses one case-level entitlement and the session-pinned model
|
||||
assert.match(route, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
|
||||
assert.match(route, /requestId: billingRequestId/);
|
||||
assert.match(route, /modelConfigVersion: selectedModel\.configVersion/);
|
||||
assert.match(route, /generationModel: selectedModel\.model/);
|
||||
assert.doesNotMatch(route, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
|
||||
});
|
||||
|
||||
@@ -243,7 +248,7 @@ test("Agentic rectification scrolls the conversation container as streamed messa
|
||||
assert.doesNotMatch(chat, /conversationEnd|scrollIntoView/);
|
||||
});
|
||||
|
||||
test("rectification keeps receipts for the varga sentence and hides Activity from the user", () => {
|
||||
test("rectification keeps receipts for the varga sentence and shows live tool progress", () => {
|
||||
const activityHelper = chat.slice(
|
||||
chat.indexOf("function completedReceiptFromPersisted"),
|
||||
chat.indexOf("export function RectificationAgenticChat"),
|
||||
@@ -254,12 +259,15 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
|
||||
assert.match(activityHelper, /receipt\.methods/);
|
||||
assert.match(activityHelper, /filter\(isPublicRectificationMethod\)/);
|
||||
assert.match(chat, /completedReceipt\?: CompletedActivityReceiptView/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
assert.match(chat, /vargaSentenceFromMethods/);
|
||||
assert.match(chat, /vargaSentence=\{vargaSentence\}/);
|
||||
assert.match(board, /RectificationHouseTableView/);
|
||||
assert.doesNotMatch(chat, /activeActivity/);
|
||||
assert.doesNotMatch(chat, /正在读取校正记录/);
|
||||
assert.match(chat, /RECTIFICATION_TOOL_PROGRESS_LABELS/);
|
||||
assert.match(chat, /rectificationCompletedTrail\(activityReceiptState\.completedSteps\)/);
|
||||
assert.match(progressLabels, /正在读取校正记录/);
|
||||
assert.match(progressLabels, /正在比较候选时间/);
|
||||
assert.doesNotMatch(chat, /<CompletedActivityReceipt/);
|
||||
assert.match(chat, /event\.type === "tool\.activity"/);
|
||||
assert.match(chat, /event\.status === "started"/);
|
||||
@@ -279,6 +287,8 @@ test("rectification keeps receipts for the varga sentence and hides Activity fro
|
||||
for (const genericCopy of ["开始本轮执行", "正在加载专用方法", "专用方法已加载", "本轮做了什么"]) {
|
||||
assert.doesNotMatch(chat, new RegExp(genericCopy));
|
||||
}
|
||||
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
|
||||
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
|
||||
});
|
||||
|
||||
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
|
||||
@@ -431,7 +441,7 @@ test("rectification composer can stop a live agent run", () => {
|
||||
assert.match(chat, /aria-label="停止回答"/);
|
||||
assert.match(chat, /event\.type === "attempt\.reset"/);
|
||||
assert.match(chat, /caught\.name === "AbortError"/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state === "thinking"\}/);
|
||||
assert.match(chat, /showActivity=\{displayedMessage\.state !== "settled"\}/);
|
||||
});
|
||||
|
||||
test("conversation and house board reuse the quiet overlay scrollbar", () => {
|
||||
|
||||
@@ -239,6 +239,8 @@ test("server-loaded Skill is bound before the provider and the first model step
|
||||
let observedMessages: unknown[] = [];
|
||||
let observedStreamOptions: {
|
||||
prepareStep?: (input: { stepNumber: number }) => unknown;
|
||||
modelSettings?: { maxOutputTokens?: number };
|
||||
providerOptions?: Record<string, { thinking?: { type?: string } }>;
|
||||
} = {};
|
||||
const agent = fakeAgentStream([
|
||||
chunk("start"),
|
||||
@@ -285,6 +287,8 @@ test("server-loaded Skill is bound before the provider and the first model step
|
||||
});
|
||||
assert.equal(typeof firstStep?.toolChoice, "string");
|
||||
assert.equal(await observedStreamOptions.prepareStep?.({ stepNumber: 1 }), undefined);
|
||||
assert.equal(observedStreamOptions.modelSettings?.maxOutputTokens, 8192);
|
||||
assert.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "disabled" } });
|
||||
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
|
||||
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
|
||||
assert.equal(
|
||||
|
||||
@@ -449,6 +449,41 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
|
||||
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
|
||||
});
|
||||
|
||||
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
|
||||
const pinched = "**先看候选结构(还不能确认唯一分钟";
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
get_agentic_rectification_case_dossier: () => dossierFixture(),
|
||||
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID }),
|
||||
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "failed", idempotent: false }),
|
||||
});
|
||||
const { options, emitted, billing } = runOptions({
|
||||
accounting: accounting.client,
|
||||
buildAgent: async () => fakeAgentStream([
|
||||
chunk("start"),
|
||||
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
|
||||
chunk("tool-result", { toolName: "rectification-read-case" }),
|
||||
chunk("text-delta", { text: pinched }),
|
||||
chunk("finish", { stepResult: { reason: "length" } }),
|
||||
]) as never,
|
||||
});
|
||||
|
||||
const result = await runV9AgentTurn(options);
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.errorCode, "answer_truncated");
|
||||
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
|
||||
assert.equal(emitted.filter((event) => event.type === "run.completed").length, 0);
|
||||
assert.equal(emitted.some((event) => event.type === "run.failed"), true);
|
||||
assert.deepEqual(
|
||||
emitted.filter((event) => event.type === "answer.delta"),
|
||||
[{ type: "answer.delta", text: pinched }],
|
||||
);
|
||||
const turnFinalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn");
|
||||
assert.equal(turnFinalize?.args.p_assistant_message, null);
|
||||
assert.equal(turnFinalize?.args.p_successful_attempt_id, null);
|
||||
});
|
||||
|
||||
test("answer deltas and tool activity are published before billing settles", async () => {
|
||||
let billingStarted = false;
|
||||
const seenBeforeBilling: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user