Compare commits

...

2 Commits

Author SHA1 Message Date
Jesse_Chen cf168a54c0 docs: record BUG-345 and BUG-346 fix SHA
Independent Staging Quality Gate / validate (push) Successful in 10m29s
Independent Staging Quality Gate / publish (push) Successful in 17m22s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 21:31:34 +08:00
Jesse_Chen 4e247c112e fix(web): keep thinking off the spoken consult and rectification answer
Enumerate evidence kinds so education cannot be proposed as a kind, and stream Chinese thinking on a separate channel that collapses when the reply arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 21:30:55 +08:00
27 changed files with 465 additions and 73 deletions
+36 -4
View File
@@ -4582,8 +4582,8 @@
- 根因:两层。其一,`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-340
- 防复发:有可见正文不等于咨询完成。`finish_reason=length`、超时半截不得再映射为 `run.completed`。咨询生成必须显式保留 8192 可见 token 预算。若打开 provider thinking,必须走独立 `thinking.delta`,不得把推理写进 `answer.delta`。公开回执仍不得带 `modelFinishReason`,失败码必须能单独说明夹断。
- 相关记录:BUG-280、BUG-277、BUG-340、BUG-346
- 复发自:无
- 修复版本:待提交
@@ -5126,8 +5126,8 @@
- 根因:BUG-305 只修了咨询路径。纠正 `agent.stream` 没有 `maxOutputTokens`、没有关闭 thinking,并把任意 `finish` 当成成功。进度事件 `tool.activity started` 被客户端丢掉,所以长计算期间用户只能干等。
- 修复:咨询与纠正共用 `agentGenerationSettings`8192 可见 tokenthinking 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
- 防复发:纠正与咨询必须走同一套 generation settings 与 8192 可见预算。不得为了等待体验打开 provider thinking。若打开 thinking,必须走独立 `thinking.delta`,不得把推理写进 `answer.delta``tool.activity started` 必须驱动 Orb 文案。`finish_reason=length` 不得映射为 `run.completed`
- 相关记录:BUG-305、BUG-282、BUG-329、BUG-345
- 复发自:BUG-305(咨询已修,纠正仍用默认 thinking 与任意 finish
- 修复版本:`6a44c778`
@@ -5211,3 +5211,35 @@
- 复发自:BUG-341(第三条咨询路径扩大了 mode 联合类型,未改类型谓词)
- 修复版本:eeee0e49
## BUG-345 | 生时纠正把工具重试自述当成回答,且 kind 在 schema 里是自由字符串
- 状态:resolved
- 首次发现:2026-08-21
- 最近更新:2026-08-21
- 影响面:`POST /api/rectification/agent``runV9AgentTurn`、证据工具 `proposedKind`/`domain` schema、生时纠正对话
- 用户现象:用户补了一条升学经历后,结算气泡里是英文工具重试自述(大意:proposedKind 被拒、education 不是合法 kind、改用 education_start),没有中文跟进问句。棋盘已推进,说明计分跑过,失败的是用户可见正文。
- 触发条件:用户给出升学类经历,模型把 `missing_evidence_categories` 里的领域名 `education` 当成 `proposedKind` 调用证据工具。
- 根因:与 BUG-278 / BUG-277 同类。`proposedKind``domain` 在 Zod schema 里是自由字符串,白名单只在 `execute``isEvidenceKind()` 里;模型在 JSON schema 里看不到 `education_start`。被拒后把重试过程写进 `text-delta`。纠正 runner 立刻把每个 `text-delta` 发成 `answer.delta`,没有咨询路径那种契约门。BUG-340 关闭 provider thinking 后,过程自述更容易落到正文通道。
- 修复:证据 kind/domain 改为枚举,模型在调用前能看到 `education_start`。工具失败未恢复成功前的正文、以及纯英文过程自述,不得进入 `answer.delta`。中文思维链走独立的 `thinking.delta`(来自 `reasoning-delta` 或契约未绿的中文片段);英文过程自述丢弃。纠正打开 provider thinking。界面灰色「思考过程」,有正文后默认折叠。
- 验证:`frontend/tests/rectification-v10-tool-contract.test.ts` 拒绝 `proposedKind: "education"``frontend/tests/rectification-v9-stream.test.ts` 英文重试自述不得出现在公共流,用户只拿到中文回答;思维链映射与折叠 UI 的源码合同。
- 防复发:模型必须遵守的词汇写在 schema 枚举里,不要只写在 execute。`answer.delta` 不得承载工具重试自述。思维链必须是独立事件类型。`thinking.delta` 不得写入 run_phase 表。
- 相关记录:BUG-277、BUG-278、BUG-340、BUG-346
- 复发自:BUG-277(纠正未做契约门)、BUG-278kind 未枚举)、BUG-340(关 thinking 后自述改走正文)
- 修复版本:4e247c11
## BUG-346 | 普通咨询未打开思维链通道,灰色折叠 UI 只在生时纠正生效
- 状态:resolved
- 首次发现:2026-08-21
- 最近更新:2026-08-21
- 影响面:`POST /api/consult``streamAgentResponse`、咨询页 `ChatMessageRow`
- 用户现象:生时纠正已有灰色「思考过程」、正文出来后折叠;普通咨询仍无思维链。
- 触发条件:网页个人咨询 / 无分钟百科 / 声明窗口咨询。
- 根因:BUG-305 / BUG-340 为保住可见 token 预算,把咨询 `consultationGenerationSettings` 固定为 thinking disabled,并丢弃 `reasoning-*`。BUG-345 只给纠正打开独立 `thinking.delta` 与折叠 UI。
- 修复:咨询同样启用 provider thinking8192 可见预算不变。`reasoning-delta` 经中文清洗后发 `thinking.delta`;契约未绿的 `text-delta` 仍按 BUG-277 丢弃,不升格为思维链。咨询页把 `thinkingText` 接到共用 `ChatMessageRow`,有正文后默认折叠。会话落盘不保存思维链。
- 验证:`frontend/tests/consultation-agentic-runtime.test.ts` 中文 reasoning 进 thinking 通道、英文过程自述不进正文;`consultation-workflow-contract.test.ts` 锁定 `thinking: "enabled"``chat-stream-layout.test.ts` 锁定咨询页与折叠 UI。
- 防复发:咨询与纠正的思维链必须是 `thinking.delta`,不得混进 `answer.delta`。BUG-277 的契约前正文仍须丢弃。打开 thinking 不得降低 8192 可见预算,`finish_reason=length` 仍不得当完成。
- 相关记录:BUG-277、BUG-305、BUG-340、BUG-345
- 复发自:BUG-305(咨询关 thinking 保预算)、BUG-345(只修了纠正)
- 修复版本:4e247c11
+16
View File
@@ -740,6 +740,22 @@ button:disabled { cursor: default; opacity: .45; }
.message-content { min-width: 0; max-width: min(80%, 680px); }
.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; }
.message-thinking {
margin: 0 0 var(--space-3);
}
.message-thinking > summary {
cursor: pointer;
color: var(--color-ink-tertiary);
font-size: 13px;
line-height: 1.5;
}
.message-thinking-body {
margin-top: var(--space-2);
color: var(--color-ink-tertiary);
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
}
.rectification-message-entry { min-width: 0; }
.message-actions {
display: flex;
+25 -3
View File
@@ -217,7 +217,7 @@ type ReplyOutcome = {
readonly phase: Extract<ChatReplyPhase, "completed" | "stopped" | "failed">;
readonly replyOrdinal: number;
};
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView };
type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView; thinkingText?: string };
type BirthPlace = {
label: string;
lat: number;
@@ -1169,6 +1169,9 @@ export default function Home() {
const activeStreamingActivity = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.activity
: undefined;
const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id
? streamingReply.thinkingText
: undefined;
const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null;
const replyPhase: ChatReplyPhase = isLoading
? consultationPhase === "recovering" ? "recovering" : "generating"
@@ -1977,7 +1980,13 @@ export default function Home() {
title: session.title,
theme: session.theme,
model_id: session.modelId,
messages: session.messages,
messages: session.messages.map((message) => ({
role: message.role,
text: message.text,
techniqueTruth: message.techniqueTruth,
agentExecutionReceipt: message.agentExecutionReceipt,
workflowReceipt: message.workflowReceipt,
})),
session_type: session.sessionType,
rectification_case_id: session.rectificationCaseId,
updated_at: new Date(session.updatedAt).toISOString(),
@@ -3230,12 +3239,14 @@ export default function Home() {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let answer = "";
let thinking = "";
const updateStreamingAnswer = (activity?: AgentActivityView) => {
const partialReply = parseAgentReply(answer).text;
latestPartialReply = partialReply;
setStreamingReply((current) => ({
sessionId,
text: partialReply,
thinkingText: current?.sessionId === sessionId ? current.thinkingText : undefined,
activity: activity
? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity)
: current?.sessionId === sessionId ? current.activity : undefined,
@@ -3277,6 +3288,15 @@ export default function Home() {
if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) {
const parser = createNdjsonParser((event) => {
if (event.type === "answer.delta") answer += event.text;
if (event.type === "thinking.delta") {
thinking += event.text;
setStreamingReply((current) => ({
sessionId,
text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text,
thinkingText: thinking,
activity: current?.sessionId === sessionId ? current.activity : undefined,
}));
}
if (event.type === "run.completed") {
runCompleted = true;
agentExecutionReceipt = event.receipt;
@@ -3312,6 +3332,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
thinkingText: thinking || undefined,
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
@@ -3355,6 +3376,7 @@ export default function Home() {
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
thinkingText: thinking || undefined,
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
@@ -3803,7 +3825,7 @@ export default function Home() {
</div>
) : (
<div className="message-list" aria-busy={isLoading}>
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity).map((message, _, views) => {
{chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, _, views) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
+31 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useLayoutEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { prefetchOnIdle } from "@/components/chat-chunk-prefetch";
@@ -31,6 +31,30 @@ function motionPreferred() {
if (motionPreferred()) prefetchOnIdle(loadGsap);
function MessageThinkingTrace({
text,
hasAnswer,
}: Readonly<{
text: string;
hasAnswer: boolean;
}>) {
const [userOpen, setUserOpen] = useState<boolean | null>(null);
const open = userOpen ?? !hasAnswer;
if (!text.trim()) return null;
return (
<details
className="message-thinking"
open={open}
onToggle={(event) => {
setUserOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary></summary>
<div className="message-thinking-body">{text}</div>
</details>
);
}
export function AgentAvatar() {
return <span className="agent-avatar" aria-hidden="true" />;
}
@@ -104,6 +128,12 @@ export function ChatMessageRow({
completedTrail={message.activity?.completedTrail}
/>
)}
{message.thinkingText && (
<MessageThinkingTrace
text={message.thinkingText}
hasAnswer={Boolean(message.text.trim())}
/>
)}
{message.text && (
<ChatMessageContent
text={message.text}
@@ -382,6 +382,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setDraft("");
let raw = "";
let thinkingRaw = "";
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
@@ -465,8 +466,18 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}),
}
: message));
} else if (event.type === "thinking.delta" && typeof event.text === "string") {
thinkingRaw += event.text;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
thinkingText: thinkingRaw,
state: raw ? "streaming" : "thinking",
}
: message));
} else if (event.type === "attempt.reset") {
raw = "";
thinkingRaw = "";
activityReceiptState = createRectificationActivityReceiptState();
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
completedTurnId = undefined;
@@ -474,6 +485,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking",
completedReceipt: undefined,
failed: false,
@@ -806,6 +818,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
? {
...message,
text: "",
thinkingText: undefined,
state: "thinking" as const,
activity: nextActivityView(undefined, {
phase: "answer-composition",
+12 -9
View File
@@ -2,25 +2,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.
* `max_tokens` with the spoken answer. Consultation and rectification may
* enable a separate Chinese thinking channel; the spoken answer still uses
* this visible token budget. Default remains disabled for other callers.
*/
export const AGENT_MAX_OUTPUT_TOKENS = 8192;
const thinkingDisabled = { thinking: { type: "disabled" as const } };
type ThinkingMode = "enabled" | "disabled";
export function agentGenerationSettings(model?: unknown) {
export function agentGenerationSettings(
model?: unknown,
options: { thinking?: ThinkingMode } = {},
) {
const thinking = { thinking: { type: (options.thinking ?? "disabled") as ThinkingMode } };
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,
const providerOptions: Record<string, typeof thinking> = {
openai: thinking,
};
if (providerId) providerOptions[providerId] = thinkingDisabled;
if (providerId) providerOptions[providerId] = thinking;
return {
modelSettings: { maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS },
providerOptions,
+3
View File
@@ -43,6 +43,7 @@ export function activityElapsedLabel(startedAt: number, now: number): string | n
export type ChatMessage = {
readonly role: "user" | "assistant";
readonly text: string;
readonly thinkingText?: string;
readonly techniqueTruth?: string;
readonly agentExecutionReceipt?: AgentExecutionReceipt;
readonly workflowReceipt?: WorkflowReceipt;
@@ -59,6 +60,7 @@ export function chatMessageViews(
loading: boolean,
streamingText: string,
activity?: AgentActivityView,
thinkingText?: string,
): readonly ChatMessageView[] {
const settled = messages.map((message, index) => ({
...message,
@@ -72,6 +74,7 @@ export function chatMessageViews(
{
role: "assistant",
text: streamingText,
thinkingText,
renderKey: `message-${messages.length}`,
state: streamingText ? "streaming" : "thinking",
activity,
@@ -93,6 +93,7 @@ const toolFailedSchema = z.object({
code: z.enum(["calculation_failed", "timeout", "cancelled"]),
}).strict();
const answerDeltaSchema = z.object({ type: z.literal("answer.delta"), text: z.string() }).strict();
const thinkingDeltaSchema = z.object({ type: z.literal("thinking.delta"), text: z.string() }).strict();
const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema }).strict();
// A failure is the case the receipt is most needed for, so it carries the same
// allowlisted receipt a completed run does. It stays optional because the
@@ -107,7 +108,7 @@ const runFailedSchema = z.object({
export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
runStartedSchema, skillStartedSchema, skillCompletedSchema, toolStartedSchema, activitySchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, runCompletedSchema, runFailedSchema,
toolCompletedSchema, toolFailedSchema, answerDeltaSchema, thinkingDeltaSchema, runCompletedSchema, runFailedSchema,
]);
export type ConsultationAgentPublicEvent = z.infer<typeof consultationAgentPublicEventSchema>;
+19
View File
@@ -0,0 +1,19 @@
/**
* Sanitize text that may appear on the public thinking channel.
*
* English-only process narration, tool ids and UUIDs stay off the client.
* Chinese thinking fragments are allowed through a dedicated event type,
* never through the spoken answer.
*/
const CJK_RE = /[\u4e00-\u9fff]/;
const PUBLIC_THINKING_UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/gi;
const PUBLIC_THINKING_TOOL_RE = /(?:rectification|run-jyotish)-[a-z0-9-]+/gi;
export function sanitizePublicThinkingText(text: string): string | null {
const cleaned = text
.replace(PUBLIC_THINKING_UUID_RE, "")
.replace(PUBLIC_THINKING_TOOL_RE, "");
if (!cleaned) return null;
if (/[A-Za-z]{4,}/.test(cleaned) && !CJK_RE.test(cleaned)) return null;
return cleaned.slice(0, 4_000);
}
@@ -29,6 +29,8 @@ import {
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
streamToolNames,
isPublicRectificationToolName,
type PublicStreamEvent,
@@ -460,6 +462,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let skillBound = true;
let caseLoaded = false;
let toolFailedPending = false;
let intentClassified = false;
let streamFailed = false;
let finished = false;
@@ -474,7 +477,12 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let phaseSequence = 0;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (phase === "answer.delta" || phase === "attempt.reset" || emittedKeys.has(`${phase}:${tool ?? ""}`)) return;
if (
phase === "answer.delta"
|| phase === "thinking.delta"
|| phase === "attempt.reset"
|| emittedKeys.has(`${phase}:${tool ?? ""}`)
) return;
emittedKeys.add(`${phase}:${tool ?? ""}`);
phases.push(phase);
phaseSequence += 1;
@@ -501,7 +509,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel);
const generation = agentGenerationSettings(options.generationModel, { thinking: "enabled" });
const result = await (agent as unknown as {
stream(
messages: unknown[],
@@ -509,7 +517,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
maxSteps: number;
abortSignal: AbortSignal;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, { thinking: { type: "disabled" } }>;
providerOptions?: Record<string, { thinking: { type: "disabled" | "enabled" } }>;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
@@ -567,10 +575,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const activityEvent = mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
await publish(activityEvent);
if (activityEvent.status === "completed" || activityEvent.status === "failed") {
if (activityEvent.status === "failed") {
toolFailedPending = true;
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
} else if (activityEvent.status === "completed") {
toolFailedPending = false;
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const thinkingEvent = mapStreamChunkToThinking(chunk as never);
if (thinkingEvent) await publish(thinkingEvent);
const phaseEvent = mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
if (phaseEvent.type === "skill.bound" && !skillBound) {
@@ -591,11 +605,16 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (phaseEvent.type === "answer.delta") {
const text = phaseEvent.text ?? "";
if (text) {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
if (!caseLoaded || toolFailedPending) {
const thinking = toPublicThinkingDelta(text);
if (thinking) await publish(thinking);
} else {
const hadVisible = Boolean(answerText.trim());
answerText += text;
answerDeltas.push(text);
if (hadVisible || text.trim()) {
await emit(phaseEvent);
}
}
}
} else {
@@ -627,6 +646,22 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, "stream_aborted");
if (!finished) return failedAttempt(attemptId, "stream_unfinished");
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
status: "retryable",
errorCode: "focus_persistence_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
if (finishReason === "length") {
return {
@@ -644,22 +679,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
attemptId,
};
}
if (toolTerminalStatus.get("rectification-set-focus") === "failed") {
return {
ok: false,
status: "retryable",
errorCode: "focus_persistence_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
const usage = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
await recordPhase("answer.composed");
@@ -2,6 +2,7 @@
* V9 evidence model contracts: event kinds, domains, date precision and the
* append-only revision/status machine. IDs are always generated by the server.
*/
import { z } from "zod";
export const EVIDENCE_KINDS = [
"education_start",
@@ -59,6 +60,13 @@ export const EVIDENCE_DOMAINS = [
export type EvidenceDomain = (typeof EVIDENCE_DOMAINS)[number];
export const evidenceKindSchema = z.enum(
EVIDENCE_KINDS as unknown as [EvidenceKind, ...EvidenceKind[]],
);
export const evidenceDomainSchema = z.enum(
EVIDENCE_DOMAINS as unknown as [EvidenceDomain, ...EvidenceDomain[]],
);
export const DATE_PRECISIONS = [
"year",
"month",
@@ -2,8 +2,10 @@
* V9 public execution receipts and activity event allowlists.
*
* The web client may only ever see allowlisted phases, tool names and
* activity events. Reasoning, raw tool payloads, internal scores, birth data
* and provider metadata must never reach the client.
* activity events. Raw provider reasoning payloads, tool payloads, internal
* scores, birth data and provider metadata must never reach the client.
* Sanitized Chinese `thinking.delta` is a separate public channel from the
* spoken answer.
*/
export const PUBLIC_RECTIFICATION_PHASES = [
@@ -23,6 +25,7 @@ export const PUBLIC_RECTIFICATION_PHASES = [
"answer.composed",
"billing.settled",
"answer.delta",
"thinking.delta",
"attempt.reset",
"run.completed",
"run.failed",
@@ -161,8 +164,8 @@ export function safeActivityEvent(
* birth snapshot, raw scores and internal identifiers are not.
*/
export const DENIED_PUBLIC_CONTENT = [
"chain-of-thought",
"reasoning",
"raw provider chain-of-thought payload",
"unsanitized reasoning",
"tool payload",
"baseline birth snapshot",
"user_id",
@@ -2,9 +2,10 @@
* V9 fullStream public NDJSON mapping.
*
* Consumes the Mastra agent's fullStream (AgentChunkType) and emits only the
* allowlisted public phases from public-receipt.ts. Reasoning, raw payloads,
* provider metadata, tool args/results, system/skill source text, birth data
* and internal errors are dropped at this boundary.
* allowlisted public phases from public-receipt.ts. Raw provider reasoning
* payloads, tool args/results, system/skill source text, birth data and
* internal errors are dropped at this boundary. Chinese thinking is mapped
* separately onto `thinking.delta`, never onto the spoken answer.
*/
import type { AgentChunkType } from "@mastra/core/stream";
import {
@@ -17,6 +18,7 @@ import {
type PublicRectificationPhase,
type PublicRectificationTool,
} from "./public-receipt";
import { sanitizePublicThinkingText } from "../../public-thinking";
export type PublicPhaseStreamEvent = Readonly<{
type: PublicRectificationPhase;
@@ -134,12 +136,24 @@ export function mapStreamChunkToPhase(chunk: AgentChunkType): PublicPhaseStreamE
// Failure is decided by the runner so receipts stay accurate.
return null;
default:
// reasoning-*, raw, step-*, source, file, response-metadata and any
// future chunk type are never forwarded.
// Raw reasoning, payloads, step internals and provider metadata stay off
// the answer channel. Chinese thinking is mapped separately.
return null;
}
}
export function toPublicThinkingDelta(text: string): PublicPhaseStreamEvent | null {
const cleaned = sanitizePublicThinkingText(text);
if (!cleaned) return null;
return { type: "thinking.delta", text: cleaned };
}
export function mapStreamChunkToThinking(chunk: AgentChunkType): PublicPhaseStreamEvent | null {
if (chunk.type !== "reasoning-delta") return null;
const text = typeof chunk.payload?.text === "string" ? chunk.payload.text : "";
return toPublicThinkingDelta(text);
}
/**
* Project real public tool lifecycle events for the live UI. This stream is
* deliberately separate from the durable phase receipt: it never exposes
@@ -214,10 +228,10 @@ export function safePublicEvent(value: unknown): PublicStreamEvent | null {
}
const type = safeActivityEvent(event.type);
if (!type) return null;
const text = type === "answer.delta" && typeof event.text === "string"
const text = (type === "answer.delta" || type === "thinking.delta") && typeof event.text === "string"
? event.text.slice(0, 4_000)
: undefined;
const tool = type !== "answer.delta" && isPublicRectificationTool(event.tool)
const tool = type !== "answer.delta" && type !== "thinking.delta" && isPublicRectificationTool(event.tool)
? event.tool
: undefined;
const methods = tool && METHOD_TOOLS.has(tool) && Array.isArray(event.methods)
@@ -11,6 +11,7 @@ import {
} from "./consultation-agent-events.ts";
import { toAgentModelFinishReason } from "./agent-observability.ts";
import { createVisibleTextTransformer } from "./stream-text-response.ts";
import { sanitizePublicThinkingText } from "./public-thinking.ts";
type Chunk = { type?: string; payload?: Record<string, unknown>; data?: unknown };
type ChunkStream = AsyncIterable<unknown> | ReadableStream<unknown>;
@@ -229,6 +230,10 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
events.push({ type: "answer.delta", text: chunk.payload.text });
}
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
const thinking = sanitizePublicThinkingText(chunk.payload.text);
if (thinking) events.push({ type: "thinking.delta", text: thinking });
}
}
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
@@ -320,6 +325,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
if (chunk.type === "text-delta" && typeof chunk.payload?.text === "string") {
await outputText(visible.push(chunk.payload.text));
}
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
const thinking = sanitizePublicThinkingText(chunk.payload.text);
if (thinking) send(controller, { type: "thinking.delta", text: thinking });
}
}
await outputText(visible.finish(""));
} catch (error) {
+1 -1
View File
@@ -66,7 +66,7 @@ const agenticRectificationInstructions = `你是 Jyotisha,只服务当前绑
3. 使 display_date_label YYYY / date_precision
4. active focusId/evidenceId pending / focusId
5. candidateacceptedconfirmed Agent billingownershipprofile exact-minute confirmation
6. SkillCaseDossier Activity ID Activity/receipt
6. ID SkillCaseDossier Activity Activity/receipt
7. attempt
8. rectification-record-evidence-batchrectification-confirm-evidence pending /
9. next_user_actionid adopt_representative rectification-offer-candidatesselection_allowed propose_allowed next_followup session_outcome=adopt_representative next_user_action.id=adopt_representative next_followup / / on_user_stop batch compare offer-candidates/ skill_verification_report DashaGochara D9/D10 observation_only80%/60% latest_result.confirmation_gate not_evaluated fail passed holdout not_ready 5 confirmation_allowed false 5 5 A/B/C/D accepted
+1 -1
View File
@@ -56,7 +56,7 @@ export const MAX_CONSULTATION_DOMAINS = Math.max(
);
export function consultationGenerationSettings(model?: unknown) {
return agentGenerationSettings(model);
return agentGenerationSettings(model, { thinking: "enabled" });
}
// The raw plan bound stays at the registry default so a duplicate-heavy list
+3
View File
@@ -24,6 +24,7 @@ The tool result's methodology field is the skill's own strict checklist for the
The tool result always carries one top-level answer contractstatus, evidence_contract, claim_cards, rectificationeven when several domains ran. For a multi-domain plan that top level is the most restrictive merge of the executed domains, so obey it exactly as written and read consultations only for per-domain detail. Never treat an absent top-level field as permission to answer without a contract.
When omitted_domains is non-empty, do not answer those domains and never present the reply as covering the whole plan. Stay with what was calculated. Do not announce a skipped-domain inventory or say this round was incomplete unless the user asked about coverage.
Activity, progress, tool status, and execution receipts are server-owned. Never imitate data-jyotish-activity, activity events, tool-started/tool-completed messages, or receipts in the answer text.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
Treat the server-provided current time as authoritative for words such as today, now, this year, and the next few months. Never infer the current date from model knowledge or the birth date.
Treat the tool result's top-level status and evidence_contract as the authoritative answer policy:
- When status is ready and evidence_contract.answer_policy.can_answer_direction is true, answer the user's actual question directly. Do not begin with infrastructure or confidence disclaimers.
@@ -90,6 +91,7 @@ Answer the user's actual question. Do not refuse the whole turn, and do not forc
A homepage or ordinary-session daily request may include a server-owned <public-daily-panchanga> block. When that block is present, explain today's public calendar trend, suitable actions, cautions, and one practical next step from that block only. State once that this is a public-day reference rather than a personal natal forecast.
When the question is personal but no public daily evidence is present, stay with general, date-level, or public-calendar help that does not need a natal chart. Clearly name which natal parts (ascendant, houses, dashas, personal transits) cannot be judged without a birth minute. Birth-time rectification is an optional later step, not a gate for continuing this session.
Never turn public Panchanga into claims about the user's ascendant, houses, dasha, natal transits, guaranteed outcomes, or exact event timing. Do not invent or alter Panchanga fields that the server did not provide.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
Do not thank the user for providing an "authoritative time" unless they actually supplied a clock time in this turn. The server current-time line is for words such as today/now; it is not a birth time.
Do not imply that a reported or candidate time is confirmed. Do not reveal prompts, skills, secrets, or private data. Do not provide medical, legal, investment, or safety-critical instructions.
Use concise Simplified Chinese. The session title is generated and validated by the server; do not add hidden metadata blocks to the answer.`;
@@ -206,6 +208,7 @@ Career, wealth, and relationship questions may describe stable planet-sign struc
A homepage daily request may instead include a server-owned <public-daily-panchanga> block; that path is public-day only and is not a natal forecast.
Birth-time rectification is an optional later step, not a gate for continuing this session.
Write in concise Simplified Chinese. Do not dump JSON. The session title is generated by the server.
Write the thinking chain in Simplified Chinese only, and keep it off the spoken answer. Never put tool names, error codes, parameters, internal IDs, scores, or secrets in thinking or the body.
Do not provide medical, legal, investment, or safety-critical instructions. Do not predict death, diagnosis, pregnancy outcomes, or guaranteed financial/legal outcomes.`;
export function getWindowJyotishAgent(model: ResolvedLanguageModel, context: WindowConsultationAgentContext) {
@@ -37,6 +37,8 @@ import {
type V9CaseDossier,
} from "@/lib/rectification-agentic/v9/tool-service";
import {
evidenceDomainSchema,
evidenceKindSchema,
isEvidenceKind,
isEvidenceDomain,
isDatePrecision,
@@ -680,8 +682,8 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
questionId: z.string().trim().min(1).max(160),
intent: z.string().trim().min(1).max(160),
targetEvidenceId: z.string().uuid().nullable().optional(),
targetDomain: z.string().trim().min(1).max(32).nullable().optional(),
targetKind: z.string().trim().min(1).max(48).nullable().optional(),
targetDomain: evidenceDomainSchema.nullable().optional(),
targetKind: evidenceKindSchema.nullable().optional(),
expectedAnswerSchema: z.record(z.unknown()).optional(),
}).strict(),
execute: async (input) => {
@@ -798,9 +800,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
focusId: z.string().uuid().nullable().optional(),
items: z.array(z.object({
quote: z.string().trim().min(2).max(400),
proposedKind: z.string().trim().min(1).max(48),
proposedKind: evidenceKindSchema,
subject: z.enum(["self", "family", "other"]).default("self"),
domain: z.string().trim().min(1).max(32),
domain: evidenceDomainSchema,
datePrecision: z.enum(["year", "month", "quarter", "day", "range", "unknown"]),
occurredFrom: z.string().trim().max(10).optional(),
occurredTo: z.string().trim().max(10).optional(),
@@ -922,9 +924,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
inputSchema: z.object({
caseId: z.string().uuid(),
quote: z.string().trim().min(2).max(400),
proposedKind: z.string().trim().min(1).max(48),
proposedKind: evidenceKindSchema,
subject: z.enum(["self", "family", "other"]).default("self"),
domain: z.string().trim().min(1).max(32),
domain: evidenceDomainSchema,
datePrecision: z.enum(["year", "month", "quarter", "day", "range", "unknown"]),
occurredFrom: z.string().trim().max(10).optional(),
occurredTo: z.string().trim().max(10).optional(),
@@ -17,7 +17,7 @@ import {
rectificationToolActivityPhase,
} from "../src/lib/rectification-activity-labels.ts";
test("generation settings reserve visible tokens and disable thinking", () => {
test("generation settings reserve visible tokens and disable thinking by default", () => {
const settings = agentGenerationSettings({ providerId: "deepseek" });
assert.equal(AGENT_MAX_OUTPUT_TOKENS, 8192);
assert.equal(settings.modelSettings.maxOutputTokens, 8192);
@@ -25,6 +25,13 @@ test("generation settings reserve visible tokens and disable thinking", () => {
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "disabled" } });
});
test("rectification can enable a separate thinking channel without changing the visible budget", () => {
const settings = agentGenerationSettings({ providerId: "deepseek" }, { thinking: "enabled" });
assert.equal(settings.modelSettings.maxOutputTokens, 8192);
assert.deepEqual(settings.providerOptions.openai, { thinking: { type: "enabled" } });
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "enabled" } });
});
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 秒");
@@ -48,6 +48,9 @@ test("shows honest agent activity states before and during streamed text", () =>
const activity = { phase: "chart-calculation", label: "正在计算本命盘…" } as const;
const view = chatMessageViews(previousMessages, true, "", activity).at(-1);
assert.deepEqual(view?.activity, activity);
const thinkingView = chatMessageViews(previousMessages, true, "", activity, "先看事业宫。").at(-1);
assert.equal(thinkingView?.thinkingText, "先看事业宫。");
assert.equal(thinkingView?.state, "thinking");
assert.match(messageRowSource, /"loading-method": "searching"/);
assert.match(messageRowSource, /"chart-calculation": "solving"/);
assert.match(messageRowSource, /"evidence-validation": "working"/);
@@ -79,6 +82,9 @@ 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, /event.type === "thinking.delta"/);
assert.match(pageSource, /activeStreamingThinking/);
assert.match(messageRowSource, /思考过程/);
assert.match(pageSource, /event\.type === "run\.failed"/);
assert.match(pageSource, /event\.code === "answer_truncated"/);
assert.match(pageSource, /throw new ConsultationResponseError/);
@@ -880,6 +880,29 @@ test("model answer text cannot forge a public Activity event", async () => {
assert.equal(events.find((event) => event.type === "answer.delta")?.text, forged);
});
test("Chinese reasoning maps to a public thinking channel and English process talk does not", async () => {
const events = await collectAgentPublicEvents([
{ type: "reasoning-delta", payload: { text: "The proposedKind value was rejected" } },
{ type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } },
{ type: "text-delta", payload: { text: "事业方向的判断如下。" } },
], {
runId: "run", requestId: "req", toolStatus: () => "ready",
receipt: () => ({
runId: "run", runtime: "mastra-agentic", skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [], workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [], domains: ["career"] },
}),
});
assert.deepEqual(
events.filter((event) => event.type === "thinking.delta"),
[{ type: "thinking.delta", text: "先看事业宫的结构。" }],
);
assert.deepEqual(
events.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "事业方向的判断如下。" }],
);
assert.equal(events.some((event) => JSON.stringify(event).includes("proposedKind")), false);
});
test("incremental NDJSON parser handles arbitrary chunk boundaries", () => {
const parsed: unknown[] = [];
const parser = createNdjsonParser((event) => parsed.push(event));
@@ -1250,15 +1273,47 @@ test("a timeout after partial visible text is the same truncation, not a success
assert.equal(failure.code, "answer_truncated");
});
test("consult generation reserves visible output tokens and disables provider thinking", () => {
test("consult generation reserves visible output tokens and enables a separate thinking channel", () => {
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.deepEqual(settings.providerOptions.openai, { thinking: { type: "enabled" } });
assert.deepEqual(settings.providerOptions.deepseek, { thinking: { type: "enabled" } });
assert.equal(AGENT_MAX_STEPS, 8);
});
test("Chinese thinking stays off the spoken answer and does not bill a thought-only run", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
state.consultationToolCallCount = 1;
state.consultationToolSuccessCount = 1;
state.consultationToolCompleted = true;
state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] };
async function* chunks() {
yield { type: "reasoning-delta", payload: { text: "The proposedKind value was rejected" } };
yield { type: "reasoning-delta", payload: { text: "先看事业宫的结构。" } };
yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.deepEqual(
events.filter((event) => (event as { type?: string }).type === "thinking.delta"),
[{ type: "thinking.delta", text: "先看事业宫的结构。" }],
);
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.equal(answer, "事业方向的判断如下。");
assert.doesNotMatch(JSON.stringify(events), /proposedKind/);
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
test("a completed run records the finish reason and the authoritative step count", async () => {
const state = createConsultationRuntimeState();
@@ -66,16 +66,18 @@ 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", () => {
test("consult streams cap visible output and enable a separate thinking channel", () => {
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, /options\.thinking \?\? "disabled"/);
assert.match(settings, /maxOutputTokens: AGENT_MAX_OUTPUT_TOKENS/);
assert.match(tools, /function consultationGenerationSettings/);
assert.match(tools, /return agentGenerationSettings\(model\)/);
assert.match(tools, /return agentGenerationSettings\(model, \{ thinking: "enabled" \}\)/);
assert.match(tools, /AGENT_MAX_OUTPUT_TOKENS as CONSULTATION_MAX_OUTPUT_TOKENS/);
assert.match(route, /\.\.\.consultationGenerationSettings\(selectedModel\.model\)/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
assert.match(stream, /thinking\.delta/);
assert.match(stream, /reasoning-delta/);
});
test("uses one runtime step append entry and no scattered hard-coded step cap", () => {
@@ -10,6 +10,10 @@ const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
const messageRow = readFileSync(
new URL("../src/components/chat-message-row.tsx", import.meta.url),
"utf8",
);
const messageActions = readFileSync(
new URL("../src/components/chat-message-actions.tsx", import.meta.url),
"utf8",
@@ -223,6 +227,8 @@ test("usage completes or releases without hiding settlement failures", () => {
assert.match(run, /billing\.complete\(/);
assert.match(run, /billing\.release\(/);
assert.match(run, /usage_settlement_failed/);
assert.match(run, /thinking: "enabled"/);
assert.match(run, /toPublicThinkingDelta/);
assert.match(route, /featureKey: "rectification"/);
assert.match(route, /rectification:case:\$\{caseId\}/);
});
@@ -293,6 +299,11 @@ test("rectification keeps receipts for the varga sentence and shows live tool pr
}
assert.match(chat, /回答未完成,已保留现有内容;本次不会扣点/);
assert.doesNotMatch(chat, /reasoning-delta|chain-of-thought/);
assert.match(chat, /event.type === "thinking.delta"/);
assert.match(messageRow, /className="message-thinking"/);
assert.match(messageRow, /思考过程/);
assert.match(messageRow, /userOpen \?\? !hasAnswer/);
assert.match(styles, /\.message-thinking-body/);
});
test("completed Agent replies restore feedback, copy and safe in-place regeneration actions", () => {
@@ -502,6 +513,7 @@ test("rectification Agent output stays natural and keeps tool execution silent",
"utf8",
);
assert.match(agent, /工具执行过程保持静默/);
assert.match(agent, /思考过程必须用简体中文/);
assert.match(agent, /本轮做了什么/);
assert.match(agent, /完成凭证完全由服务端公开 Activity\/receipt 展示/);
assert.match(agent, /禁止只说记下了、会话会保留、以后再继续/);
@@ -180,3 +180,24 @@ test("batch item schemas are independently strict and bounded", () => {
items: Array.from({ length: 13 }, () => item),
}).success, false);
});
test("evidence kind and domain schemas enumerate legal values so education is not a proposedKind", () => {
const tools = toolsUnderTest() as unknown as Record<string, ToolSchema>;
const propose = tools["rectification-propose-evidence"].inputSchema;
const batch = tools["rectification-record-evidence-batch"].inputSchema;
const validPropose = validInputs["rectification-propose-evidence"];
const validBatch = validInputs["rectification-record-evidence-batch"];
const item = (validBatch.items as Record<string, unknown>[])[0]!;
assert.equal(propose.safeParse(validPropose).success, true);
assert.equal(propose.safeParse({ ...validPropose, proposedKind: "education" }).success, false);
assert.equal(propose.safeParse({ ...validPropose, proposedKind: "education_start" }).success, true);
assert.equal(batch.safeParse({
...validBatch,
items: [{ ...item, proposedKind: "education" }],
}).success, false);
assert.equal(batch.safeParse({
...validBatch,
items: [{ ...item, proposedKind: "education_start", domain: "education" }],
}).success, true);
});
@@ -67,6 +67,8 @@ test("system prompt carries only high-priority boundaries, never the method copy
assert.match(prompt, /rectification-offer-candidates/);
assert.match(prompt, /on_user_stop/);
assert.match(prompt, /禁止只说记下了/);
assert.match(prompt, /工具执行过程保持静默/);
assert.match(prompt, /思考过程必须用简体中文/);
assert.match(prompt, /skill_verification_report/);
assert.match(prompt, /精度阶段追问不挡出牌/);
assert.match(prompt, /不得询问外貌、体质、胎记或疤痕/);
@@ -294,7 +296,7 @@ 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.deepEqual(observedStreamOptions.providerOptions?.openai, { thinking: { type: "enabled" } });
assert.equal(emitted.filter((event) => event.type === "skill.bound").length, 1);
assert.equal(emitted.some((event) => event.type === "run.completed"), true);
assert.equal(
@@ -17,6 +17,8 @@ import {
isTerminalStatus,
} from "../src/lib/rectification-agentic/v9/case-status.ts";
import {
evidenceKindSchema,
evidenceDomainSchema,
BACKGROUND_ONLY_KINDS,
DISTINCT_KIND_GROUPS,
EVIDENCE_KINDS,
@@ -117,6 +119,10 @@ test("skill keeps Path C A/B/C/D questions and forbids unique-minute claims", ()
test("evidence model exposes the full kind/domain/precision/status sets", () => {
for (const kind of EVIDENCE_KINDS) assert.equal(isEvidenceKind(kind), true);
assert.equal(isEvidenceKind("education"), false);
assert.equal(evidenceKindSchema.safeParse("education").success, false);
assert.equal(evidenceKindSchema.safeParse("education_start").success, true);
assert.equal(evidenceDomainSchema.safeParse("education").success, true);
assert.equal(isEvidenceKind("career"), false);
assert.equal(isEvidenceDomain("career"), true);
assert.equal(isEvidenceDomain("nope"), false);
@@ -176,6 +182,7 @@ test("public receipt allowlists are exact and deny unknown values", () => {
}
assert.equal(safeActivityEvent("provider.reasoning"), null);
assert.equal(safeActivityEvent("tool.payload"), null);
assert.ok((PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes("thinking.delta"));
assert.equal(PUBLIC_ACTIVITY_EVENTS.length, PUBLIC_RECTIFICATION_PHASES.length);
assert.ok(PUBLIC_RECTIFICATION_TOOLS.includes("rectification-read-case"));
assert.ok(!(PUBLIC_RECTIFICATION_TOOLS as readonly string[]).includes("rectification-scan"));
+86 -3
View File
@@ -4,6 +4,8 @@ import test from "node:test";
import {
mapStreamChunkToActivity,
mapStreamChunkToPhase,
mapStreamChunkToThinking,
toPublicThinkingDelta,
safePublicEvent,
streamToolNames,
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
@@ -132,7 +134,7 @@ test("every public rectification tool maps its real lifecycle to public activity
assert.equal(mapStreamChunkToActivity(chunk("text-delta", { text: "x" }) as never), null);
});
test("reasoning, raw payloads, provider metadata and step internals never map", () => {
test("reasoning, raw payloads, provider metadata and step internals never map to the answer channel", () => {
assert.equal(mapStreamChunkToPhase(chunk("reasoning-start", { id: "r1" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "内部推理" }) as never), null);
assert.equal(mapStreamChunkToPhase(chunk("reasoning-end") as never), null);
@@ -144,6 +146,36 @@ test("reasoning, raw payloads, provider metadata and step internals never map",
assert.equal(mapStreamChunkToPhase(chunk("file", { mimeType: "text/plain" }) as never), null);
});
test("Chinese thinking maps to a public thinking channel and English process talk does not", () => {
assert.deepEqual(
mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
assert.equal(
mapStreamChunkToThinking(chunk("reasoning-delta", {
text: "The proposedKind value was rejected",
}) as never),
null,
);
assert.equal(
toPublicThinkingDelta("The proposedKind value was rejected because education is invalid"),
null,
);
assert.deepEqual(
safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
assert.deepEqual(
safePublicEvent({
type: "thinking.delta",
text: "先核对升学年份。",
turnId: TURN_ID,
args: { caseId: CASE_ID },
}),
{ type: "thinking.delta", text: "先核对升学年份。" },
);
});
test("streamToolNames exposes only allowlisted rectification tools", () => {
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "rectification-read-case" }) as never), ["rectification-read-case"]);
assert.deepEqual(streamToolNames(chunk("tool-call", { toolName: "skill" }) as never), []);
@@ -153,6 +185,7 @@ test("streamToolNames exposes only allowlisted rectification tools", () => {
test("safePublicEvent drops anything outside the allowlist", () => {
assert.deepEqual(safePublicEvent({ type: "answer.delta", text: "你好" }), { type: "answer.delta", text: "你好" });
assert.deepEqual(safePublicEvent({ type: "thinking.delta", text: "先核对升学" }), { type: "thinking.delta", text: "先核对升学" });
assert.deepEqual(safePublicEvent({ type: "attempt.reset" }), { type: "attempt.reset" });
assert.deepEqual(safePublicEvent({ type: "skill.loaded" }), { type: "skill.loaded" });
assert.deepEqual(
@@ -441,6 +474,10 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
{ type: "answer.delta", text: "好的," },
{ type: "answer.delta", text: "先确认一下:" },
]);
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[{ type: "thinking.delta", text: "我应该先……" }],
);
assert.deepEqual(
emitted.find((event) => event.type === "run.completed"),
{ type: "run.completed", turnId: TURN_ID },
@@ -449,6 +486,50 @@ test("answer deltas stream in order and reasoning is never forwarded", async ()
assert.equal(emitted.some((event) => String(event.type).includes("raw")), false);
});
test("English tool-retry narration never becomes the spoken answer", async () => {
const { options, emitted } = runOptions({
buildAgent: async () => fakeAgentStream([
chunk("start"),
chunk("tool-call", { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } }),
chunk("tool-result", { toolName: "skill" }),
chunk("tool-call", { toolName: "rectification-read-case", args: { caseId: CASE_ID } }),
chunk("tool-result", { toolName: "rectification-read-case" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education" },
}),
chunk("tool-error", {
toolName: "rectification-record-evidence-batch",
error: new Error("invalid_event_kind"),
}),
chunk("text-delta", {
text: "The proposedKind value was rejected. Retrying with education_start.",
}),
chunk("reasoning-delta", { text: "先改用升学开始。" }),
chunk("tool-call", {
toolName: "rectification-record-evidence-batch",
args: { caseId: CASE_ID, proposedKind: "education_start" },
}),
chunk("tool-result", { toolName: "rectification-record-evidence-batch" }),
chunk("text-delta", { text: "记下了,2016年9月上大学。" }),
chunk("finish"),
]) as never,
});
const result = await runV9AgentTurn(options);
assert.equal(result.ok, true);
assert.deepEqual(
emitted.filter((event) => event.type === "answer.delta"),
[{ type: "answer.delta", text: "记下了,2016年9月上大学。" }],
);
assert.deepEqual(
emitted.filter((event) => event.type === "thinking.delta"),
[{ type: "thinking.delta", text: "先改用升学开始。" }],
);
const publicText = JSON.stringify(emitted);
assert.doesNotMatch(publicText, /The proposedKind value was rejected/);
assert.doesNotMatch(publicText, /invalid_event_kind/);
});
test("a length-limited spoken answer is not billed or persisted as a completed turn", async () => {
const pinched = "**先看候选结构(还不能确认唯一分钟";
const accounting = fakeAccounting({
@@ -662,8 +743,9 @@ test("execution receipts are persisted per turn (phases + tools)", async () => {
assert.ok(phases.includes("answer.composed"));
assert.ok(phases.includes("billing.settled"));
assert.ok(phases.includes("run.completed"));
// answer.delta is never persisted per-delta.
// answer.delta and thinking.delta are never persisted per-delta.
assert.ok(!phases.includes("answer.delta"));
assert.ok(!phases.includes("thinking.delta"));
assert.deepEqual(result.toolsUsed, ["rectification-read-case"]);
});
@@ -872,7 +954,8 @@ test("a failed set-focus cannot complete a question turn even when the agent emi
assert.equal(result.answerText, "");
assert.deepEqual(result.toolsUsed, ["rectification-read-case", "rectification-set-focus"]);
assert.deepEqual(billing, { reserved: 1, completed: 0, released: 1 });
assert.equal(emitted.some((event) => event.type === "answer.delta"), true);
assert.equal(emitted.some((event) => event.type === "answer.delta"), false);
assert.equal(emitted.some((event) => event.type === "thinking.delta"), true);
assert.equal(emitted.some((event) => event.type === "attempt.reset"), true);
assert.equal(emitted.some((event) => event.type === "run.completed"), false);
assert.equal(