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>
This commit is contained in:
Jesse_Chen
2026-08-21 21:30:55 +08:00
co-authored by Cursor
parent 56577504d8
commit 4e247c112e
27 changed files with 465 additions and 73 deletions
@@ -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)