6b225a288c
BUG-360: stream thinking and tool activity as an ordered trace so later CoT opens under 正在整理 instead of filling the first 思考 block. Co-authored-by: Cursor <cursoragent@cursor.com>
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
import type { AgentExecutionReceipt, ConsultationAgentPublicEvent } from "./consultation-agent-events.ts";
|
|
import {
|
|
CONSULTATION_CHART_CALCULATION_LABEL,
|
|
CONSULTATION_COMPOSING_LABEL,
|
|
CONSULTATION_DONE_CHART_LABEL,
|
|
CONSULTATION_DONE_SKILL_LABEL,
|
|
CONSULTATION_LOADING_METHOD_LABEL,
|
|
consultationWriteLabel,
|
|
} from "./consultation-activity-labels.ts";
|
|
import {
|
|
consultationDomainDefinition,
|
|
normalizeConsultationDomain,
|
|
} from "./consultation-domain-registry.ts";
|
|
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
|
|
|
|
export const CONSULTATION_TIMELINE_KINDS = ["method", "calculate", "think", "write"] as const;
|
|
export type ConsultationTimelineKind = (typeof CONSULTATION_TIMELINE_KINDS)[number];
|
|
export type ConsultationTimelineStatus = "live" | "done";
|
|
|
|
export type ConsultationTimelineRow = Readonly<{
|
|
id: string;
|
|
kind: ConsultationTimelineKind;
|
|
status: ConsultationTimelineStatus;
|
|
label: string;
|
|
queries?: readonly string[];
|
|
sources?: readonly string[];
|
|
thinkingText?: string;
|
|
}>;
|
|
|
|
export type ConsultationTimelineState = Readonly<{
|
|
rows: readonly ConsultationTimelineRow[];
|
|
answer: string;
|
|
}>;
|
|
|
|
const METHOD_ID = "method";
|
|
const CALCULATE_ID = "calculate";
|
|
|
|
export function emptyConsultationTimeline(): ConsultationTimelineState {
|
|
return { rows: [], answer: "" };
|
|
}
|
|
|
|
export function reduceConsultationTimeline(
|
|
state: ConsultationTimelineState,
|
|
event: ConsultationAgentPublicEvent,
|
|
): ConsultationTimelineState {
|
|
if (event.type === "skill.started") {
|
|
return upsertRow(state, {
|
|
id: METHOD_ID,
|
|
kind: "method",
|
|
status: "live",
|
|
label: CONSULTATION_LOADING_METHOD_LABEL,
|
|
});
|
|
}
|
|
if (event.type === "skill.completed") {
|
|
return completeRow(state, METHOD_ID, CONSULTATION_DONE_SKILL_LABEL);
|
|
}
|
|
if (event.type === "tool.started") {
|
|
return upsertRow(completeLiveThink(state), {
|
|
id: CALCULATE_ID,
|
|
kind: "calculate",
|
|
status: "live",
|
|
label: event.label || CONSULTATION_CHART_CALCULATION_LABEL,
|
|
});
|
|
}
|
|
if (event.type === "activity") {
|
|
if (event.phase === "chart-calculation") {
|
|
return patchRow(state, CALCULATE_ID, {
|
|
status: "live",
|
|
label: event.label,
|
|
}, {
|
|
id: CALCULATE_ID,
|
|
kind: "calculate",
|
|
status: "live",
|
|
label: event.label,
|
|
});
|
|
}
|
|
if (event.phase === "evidence-validation") {
|
|
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
|
|
}
|
|
if (event.phase === "answer-composition") {
|
|
return upsertWrite(completeLiveThink(state), event.label);
|
|
}
|
|
return state;
|
|
}
|
|
if (event.type === "tool.completed") {
|
|
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
|
|
}
|
|
if (event.type === "tool.failed") {
|
|
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
|
|
}
|
|
if (event.type === "thinking.section") {
|
|
const section: PublicThinkingSection = {
|
|
id: event.id,
|
|
title: event.title,
|
|
heading: event.heading,
|
|
steps: event.steps,
|
|
};
|
|
const withCalc = enrichCalculate(completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL), section);
|
|
const withoutOpen = completeLiveThink(withCalc);
|
|
return upsertRow(withoutOpen, {
|
|
id: `think-${section.id}`,
|
|
kind: "think",
|
|
status: "live",
|
|
label: consultationThinkTitle(section.title),
|
|
});
|
|
}
|
|
if (event.type === "thinking.delta") {
|
|
const think = lastRow(state.rows, (row) => row.kind === "think" && row.status === "live");
|
|
if (think) {
|
|
const thinkingText = `${think.thinkingText ?? ""}${event.text}`.slice(0, 4_000);
|
|
return upsertRow(state, { ...think, status: "live", thinkingText });
|
|
}
|
|
const thinkCount = state.rows.filter((row) => row.kind === "think").length;
|
|
return upsertRow(state, {
|
|
id: `think-open-${thinkCount + 1}`,
|
|
kind: "think",
|
|
status: "live",
|
|
label: "正在分析…",
|
|
thinkingText: event.text.slice(0, 4_000),
|
|
});
|
|
}
|
|
if (event.type === "answer.delta") {
|
|
const next = { ...completeLiveThink(state), answer: `${state.answer}${event.text}` };
|
|
return syncWriteRows(next, false);
|
|
}
|
|
if (event.type === "run.completed" || event.type === "run.failed") {
|
|
const next = syncWriteRows(completeLiveThink(state), true);
|
|
return {
|
|
...next,
|
|
rows: next.rows.map((row) => (
|
|
row.status === "done" ? row : { ...row, status: "done" as const, label: doneLabel(row) }
|
|
)),
|
|
};
|
|
}
|
|
return state;
|
|
}
|
|
|
|
export function reduceConsultationTimelineEvents(
|
|
events: readonly ConsultationAgentPublicEvent[],
|
|
state = emptyConsultationTimeline(),
|
|
): ConsultationTimelineState {
|
|
return events.reduce(reduceConsultationTimeline, state);
|
|
}
|
|
|
|
export function consultationTimelineFromSettled(input: {
|
|
text?: string;
|
|
thinkingText?: string;
|
|
thinkingSections?: readonly PublicThinkingSection[];
|
|
agentExecutionReceipt?: AgentExecutionReceipt;
|
|
}): readonly ConsultationTimelineRow[] {
|
|
const text = input.text?.trim() ?? "";
|
|
const sections = input.thinkingSections ?? [];
|
|
const receipt = input.agentExecutionReceipt;
|
|
if (!text && !sections.length && !input.thinkingText?.trim() && !receipt) return [];
|
|
|
|
const events: ConsultationAgentPublicEvent[] = [
|
|
{ type: "skill.started", name: "jyotish-vedic-astrology" },
|
|
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
|
|
];
|
|
const calculated = receipt?.steps.some((step) => step.kind === "tool")
|
|
|| sections.some((section) => section.id.startsWith("domain-"));
|
|
if (calculated) {
|
|
events.push({
|
|
type: "tool.started",
|
|
callId: "settled",
|
|
tool: "run-jyotish-consultation",
|
|
label: CONSULTATION_CHART_CALCULATION_LABEL,
|
|
});
|
|
events.push({
|
|
type: "tool.completed",
|
|
callId: "settled",
|
|
tool: "run-jyotish-consultation",
|
|
status: "ready",
|
|
durationMs: 0,
|
|
});
|
|
}
|
|
for (const section of sections) {
|
|
events.push({ type: "thinking.section", ...section });
|
|
}
|
|
if (input.thinkingText?.trim() && sections.length === 0) {
|
|
events.push({ type: "thinking.delta", text: input.thinkingText });
|
|
}
|
|
if (text) events.push({ type: "answer.delta", text });
|
|
events.push({
|
|
type: "run.completed",
|
|
receipt: receipt ?? {
|
|
runId: "settled",
|
|
runtime: "mastra-agentic",
|
|
skill: {
|
|
name: "jyotish-vedic-astrology",
|
|
loaded: true,
|
|
referenceReads: 0,
|
|
methodologySections: 0,
|
|
},
|
|
steps: [],
|
|
workflow: { route: "settled", status: "ready", preciseTiming: "blocked", missingLayers: [] },
|
|
},
|
|
});
|
|
let state = reduceConsultationTimelineEvents(events);
|
|
if (input.thinkingText?.trim() && sections.length > 0) {
|
|
const lastThink = [...state.rows].reverse().find((row) => row.kind === "think");
|
|
if (lastThink) {
|
|
state = upsertRow(state, { ...lastThink, thinkingText: input.thinkingText.slice(0, 4_000) });
|
|
}
|
|
}
|
|
return state.rows;
|
|
}
|
|
|
|
export function consultationThinkTitle(title: string): string {
|
|
const cleaned = title.replace(/\s+/g, " ").trim();
|
|
if (cleaned.length <= 20) return cleaned || "正在分析";
|
|
return cleaned.slice(0, 20);
|
|
}
|
|
|
|
function answerHeadings(text: string): string[] {
|
|
return [...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((match) => match[1]?.trim() ?? "").filter(Boolean);
|
|
}
|
|
|
|
function queriesFromSection(section: PublicThinkingSection): string[] {
|
|
const domain = section.id.startsWith("domain-")
|
|
? normalizeConsultationDomain(section.id.slice("domain-".length))
|
|
: null;
|
|
if (!domain) return [];
|
|
const definition = consultationDomainDefinition(domain);
|
|
return uniqueLabels([definition.label, ...definition.evidencePreview]).slice(0, 8);
|
|
}
|
|
|
|
function sourcesFromSection(section: PublicThinkingSection): string[] {
|
|
return uniqueLabels(section.steps
|
|
.map((step) => step.label.replace(/^对照\s+/, "").trim())
|
|
.filter((label) => label.length > 0 && label.length <= 24)).slice(0, 8);
|
|
}
|
|
|
|
function uniqueLabels(values: readonly string[]): string[] {
|
|
const seen = new Set<string>();
|
|
const kept: string[] = [];
|
|
for (const value of values) {
|
|
const label = value.replace(/\s+/g, " ").trim();
|
|
if (!label || seen.has(label)) continue;
|
|
seen.add(label);
|
|
kept.push(label);
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
function lastRow(
|
|
rows: readonly ConsultationTimelineRow[],
|
|
match: (row: ConsultationTimelineRow) => boolean,
|
|
): ConsultationTimelineRow | undefined {
|
|
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
|
const row = rows[index];
|
|
if (row && match(row)) return row;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function upsertRow(state: ConsultationTimelineState, row: ConsultationTimelineRow): ConsultationTimelineState {
|
|
const index = state.rows.findIndex((item) => item.id === row.id);
|
|
if (index < 0) return { ...state, rows: [...state.rows, row] };
|
|
return {
|
|
...state,
|
|
rows: state.rows.map((item, current) => (current === index ? { ...item, ...row } : item)),
|
|
};
|
|
}
|
|
|
|
function patchRow(
|
|
state: ConsultationTimelineState,
|
|
id: string,
|
|
patch: Partial<ConsultationTimelineRow>,
|
|
create?: ConsultationTimelineRow,
|
|
): ConsultationTimelineState {
|
|
const existing = state.rows.find((row) => row.id === id);
|
|
if (!existing) return create ? upsertRow(state, create) : state;
|
|
return upsertRow(state, { ...existing, ...patch, id, kind: existing.kind });
|
|
}
|
|
|
|
function completeRow(
|
|
state: ConsultationTimelineState,
|
|
id: string,
|
|
label?: string,
|
|
): ConsultationTimelineState {
|
|
const existing = state.rows.find((row) => row.id === id);
|
|
if (!existing) return state;
|
|
return upsertRow(state, {
|
|
...existing,
|
|
status: "done",
|
|
label: label ?? doneLabel(existing),
|
|
});
|
|
}
|
|
|
|
function completeLiveThink(state: ConsultationTimelineState): ConsultationTimelineState {
|
|
return {
|
|
...state,
|
|
rows: state.rows.map((row) => (
|
|
row.kind === "think" && row.status === "live"
|
|
? { ...row, status: "done" as const, label: doneLabel(row) }
|
|
: row
|
|
)),
|
|
};
|
|
}
|
|
|
|
function doneLabel(row: ConsultationTimelineRow): string {
|
|
if (row.kind === "method") return CONSULTATION_DONE_SKILL_LABEL;
|
|
if (row.kind === "calculate") return CONSULTATION_DONE_CHART_LABEL;
|
|
if (row.kind === "write") {
|
|
const heading = row.id.startsWith("write-") ? row.id.slice("write-".length) : "";
|
|
return heading ? consultationWriteLabel(heading, false) : row.label.replace(/…$/, "").replace(/^正在/, "");
|
|
}
|
|
return row.label.replace(/…$/, "").replace(/^正在/, "") || row.label;
|
|
}
|
|
|
|
function upsertWrite(state: ConsultationTimelineState, liveLabel: string): ConsultationTimelineState {
|
|
const headings = answerHeadings(state.answer);
|
|
const heading = headings.at(-1);
|
|
if (!heading) {
|
|
return upsertRow(state, {
|
|
id: "write-open",
|
|
kind: "write",
|
|
status: "live",
|
|
label: liveLabel || CONSULTATION_COMPOSING_LABEL,
|
|
});
|
|
}
|
|
return syncWriteRows(state, false);
|
|
}
|
|
|
|
function syncWriteRows(state: ConsultationTimelineState, settled: boolean): ConsultationTimelineState {
|
|
const headings = answerHeadings(state.answer);
|
|
if (headings.length === 0) {
|
|
if (!state.answer.trim() && !settled) return state;
|
|
if (!state.answer.trim()) return state;
|
|
return upsertRow(completeRow(state, "write-open"), {
|
|
id: "write-open",
|
|
kind: "write",
|
|
status: settled ? "done" : "live",
|
|
label: settled ? "组织回答" : CONSULTATION_COMPOSING_LABEL,
|
|
});
|
|
}
|
|
let next = completeRow(state, "write-open");
|
|
next = {
|
|
...next,
|
|
rows: next.rows.filter((row) => row.id !== "write-open"),
|
|
};
|
|
headings.forEach((heading, index) => {
|
|
const last = index === headings.length - 1;
|
|
next = upsertRow(next, {
|
|
id: `write-${heading}`,
|
|
kind: "write",
|
|
status: settled || !last ? "done" : "live",
|
|
label: consultationWriteLabel(heading, !settled && last),
|
|
});
|
|
});
|
|
return next;
|
|
}
|
|
|
|
function enrichCalculate(
|
|
state: ConsultationTimelineState,
|
|
section: PublicThinkingSection,
|
|
): ConsultationTimelineState {
|
|
const calculate = state.rows.find((row) => row.id === CALCULATE_ID);
|
|
if (!calculate) return state;
|
|
const queries = uniqueLabels([...(calculate.queries ?? []), ...queriesFromSection(section)]).slice(0, 8);
|
|
const sources = uniqueLabels([...(calculate.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8);
|
|
return upsertRow(state, { ...calculate, queries, sources });
|
|
}
|