feat(consult): open the response stream before preparation, reservation and classification; rejections ride the stream (T2, BUG-1074)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
Jesse_Chen
2026-09-28 09:20:23 +08:00
co-authored by Claude Fable 5.1
parent 3f8b817261
commit 0bc6659052
8 changed files with 458 additions and 5 deletions
+33
View File
@@ -40,6 +40,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { classifyConsultationTurn, type SmalltalkUsage } from "@/lib/consultation-smalltalk";
import { streamSmalltalkResponse } from "@/lib/stream-smalltalk-response";
import { streamFirstResponse } from "@/lib/stream-first-response";
import { consultationPublicActivityEvent, streamAgentResponse } from "@/lib/stream-agent-response";
import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events";
import {
@@ -283,6 +284,7 @@ function workflowStatus(status: string | undefined): "ready" | "degraded" | "blo
}
export async function POST(request: Request) {
const requestStartedAt = Date.now();
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
@@ -406,6 +408,16 @@ export async function POST(request: Request) {
type SelectedModel = NonNullable<Awaited<ReturnType<typeof resolveSessionLanguageModel>>>;
type ReservationResult = { success: boolean; credits: number | null; error_code: string | null };
type ModelSelection = Awaited<ReturnType<typeof reserveConsultationModel<SelectedModel, ReservationResult>>>;
// Everything above answered with a real HTTP status: authentication, the
// request body, the session and its model. Everything below — subject and
// profile reads, the credit reservation, appending the question, the model
// classification, the agent itself — runs inside the response stream in the
// agentic runtime (BUG-1074): `runTurn` still returns the same Responses it
// always did, and `streamFirstResponse` relays them after a first frame that
// goes out before any of this work starts. The legacy runtime keeps the
// pre-stream order because its text/plain replies cannot ride an NDJSON stream.
const runTurn = async (): Promise<Response> => {
let prepared: PreparedConsultationRoute<ModelSelection, ConsultationPlan>;
try {
prepared = await prepareConsultationRoute<ModelSelection, ConsultationPlan>({
@@ -1625,4 +1637,25 @@ export async function POST(request: Request) {
{ status: 503 },
);
}
};
if (!shouldUseAgenticRuntime(user)) return runTurn();
return streamFirstResponse({
requestId,
run: runTurn,
startedAt: requestStartedAt,
onFirstFrame: (elapsedMs) => {
logAgentObservability({
requestId,
sessionId,
agentVersion: "consultation-stream-first-v1",
contractPhases: [{ phase: "first_byte", durationMs: elapsedMs, status: "completed" }],
billingSettlementResult: "not_applicable",
});
},
onUnhandled: (error) => {
const reason = error instanceof Error ? error.name : "UnknownError";
console.error(`[consult] turn failed before a response request=${requestId} reason=${reason}`);
},
});
}
@@ -66,6 +66,7 @@ export const ChatMessageRow = memo(function ChatMessageRow({
: "Jyotisha";
const activityState = message.activity
? ({
received: "working",
"loading-method": "searching",
"chart-calculation": "solving",
"evidence-validation": "working",
+15 -1
View File
@@ -863,7 +863,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
if (!response.body) {
throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流");
}
responseKind = response.headers.get("x-jyotish-response-kind") === "smalltalk" ? "smalltalk" : undefined;
// Whether the reply was smalltalk is only known once the server has
// classified the turn, which now happens inside the stream (BUG-1074):
// the run.completed event says so; there is no response header for it.
let techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown";
let workflowReceipt: AgentExecutionReceipt["workflow"] = {
route: response.headers.get("x-jyotish-workflow-route") ?? "unknown",
@@ -891,6 +893,9 @@ export function useConsultationRun(params: ConsultationRunParams) {
label: CONSULTATION_CHART_CALCULATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "activity" && event.phase === "received") {
// The stream's first byte, before anything has been done: no trail.
activity = { phase: event.phase, label: event.label };
} else if (event.type === "activity") {
activity = {
phase: event.phase,
@@ -957,6 +962,15 @@ export function useConsultationRun(params: ConsultationRunParams) {
techniqueTruth = event.receipt.techniqueTruth ?? "unknown";
}
}
if (event.type === "run.failed" && event.code === "request_rejected") {
// A rejection the route used to send as a JSON body with an HTTP
// status before the stream opened (BUG-1074): same status, same
// sentence, same code, so the handling below does not change.
const status = event.status ?? 503;
if (status === 401) window.location.assign("/login");
if (status === 402) openAccountDialog("billing", { source: "insufficient-credits" });
throw new ConsultationResponseError(status, payloadMessage({ message: event.message }, "服务暂时不可用"), event.reason);
}
if (event.type === "run.failed") {
if (event.code === "answer_truncated") {
truncatedFailure = event;
+21 -1
View File
@@ -3,6 +3,10 @@ import { consultationDomainSchema, type ConsultationDomain } from "./consultatio
import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts";
export const publicActivityPhaseSchema = z.enum([
// The first byte of a consultation stream, before the turn is classified or
// the chart is read (BUG-1074). The client already shows the same queued
// row from the send frame, so this frame changes nothing on screen.
"received",
"loading-method",
"chart-calculation",
"evidence-validation",
@@ -142,10 +146,18 @@ const runCompletedSchema = z.object({
// allowlisted receipt a completed run does. It stays optional because the
// receipt is built from live state that a hard failure may leave unparseable,
// and losing the whole failure event would be worse than losing its receipt.
// `request_rejected` is a failure the route used to answer with a JSON body
// and an HTTP status before the stream existed (credits, plan, subject,
// session full…). Since the stream opens first (BUG-1074) it travels here
// instead: `status` keeps the HTTP semantics the client acts on (402 opens
// billing, 4xx means the reservation never committed), `reason` is the JSON
// body's `code` when it had one (session_full, pricing_configuration_unavailable).
const runFailedSchema = z.object({
type: z.literal("run.failed"),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "answer_truncated", "cancelled"]),
code: z.enum(["runtime_contract_incomplete", "calculation_failed", "empty_answer", "answer_truncated", "cancelled", "request_rejected"]),
message: z.string().max(200),
status: z.number().int().min(400).max(599).optional(),
reason: z.string().max(80).optional(),
receipt: agentExecutionReceiptSchema.optional(),
}).strict();
@@ -159,6 +171,14 @@ export const consultationAgentPublicEventSchema = z.discriminatedUnion("type", [
&& (event.responseKind === "smalltalk" ? event.receipt !== undefined : event.receipt === undefined)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "completion_requires_receipt_or_smalltalk" });
}
if (event.type === "run.failed") {
if ((event.status !== undefined || event.reason !== undefined) && event.code !== "request_rejected") {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "status_and_reason_only_on_request_rejected" });
}
if (event.code === "request_rejected" && event.status === undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "request_rejected_requires_status" });
}
}
});
export type ConsultationAgentPublicEvent = z.infer<typeof consultationAgentPublicEventSchema>;
+154
View File
@@ -0,0 +1,154 @@
import {
consultationAgentPublicEventSchema,
type ConsultationAgentPublicEvent,
} from "./consultation-agent-events.ts";
import { CONSULTATION_RECEIVED_LABEL } from "./consultation-activity-labels.ts";
/**
* Stream-first consultation response (TASK-consult-first-frame-and-pacing-20260928
* D2, BUG-1074; the same shape BUG-1047 gave the rectification turn).
*
* The route used to read the session, prepare the chart, reserve credits,
* append the question and classify the turn with a model call before it built
* a Response, so the browser received nothing for all of that. Here the
* Response exists first: its first byte is a deterministic `activity` frame,
* and the whole turn runs inside the stream. The inner work still produces the
* same Responses it always did — an NDJSON stream (consultation or smalltalk)
* or a JSON rejection with an HTTP status — and they are relayed as they are:
* NDJSON bytes pass through unchanged after the first frame; a JSON rejection
* becomes one `run.failed` event that carries the status, message and code the
* client used to read from the HTTP response.
*
* Only what has to answer with a real HTTP status stays in front of the
* stream: authentication, request-body validation and the session lookup.
*/
/** The first byte of every consultation stream: the same sentence the client's queued row shows from the send frame. */
export const CONSULTATION_RECEIVED_EVENT: ConsultationAgentPublicEvent = {
type: "activity",
phase: "received",
label: CONSULTATION_RECEIVED_LABEL,
};
export const STREAM_FIRST_FALLBACK_MESSAGE = "咨询服务暂时不可用,请稍后再试。";
export type StreamFirstOptions = Readonly<{
requestId: string;
/** Written before anything else happens; defaults to the received frame. */
firstFrame?: ConsultationAgentPublicEvent;
/** The turn. Its Response is relayed: NDJSON as bytes, JSON as one run.failed event. */
run: () => Promise<Response>;
/** Called once the first frame has been written, with the milliseconds since `startedAt`. */
onFirstFrame?: (elapsedMs: number) => void;
/** Called when `run` throws instead of returning a Response; the stream then ends with a generic failure. */
onUnhandled?: (error: unknown) => void | Promise<void>;
startedAt?: number;
now?: () => number;
}>;
/**
* The JSON body the route answered with, as the failure event the stream
* carries instead. Message precedence is the one the client applied to the
* HTTP body (`payloadMessage`: recovery, then message, then error), so the
* sentence the user sees does not change; `code` rides along as `reason`.
*/
export function consultationRejectionEvent(status: number, payload: unknown): ConsultationAgentPublicEvent {
const body = payload && typeof payload === "object" ? payload as Record<string, unknown> : {};
const message = [body.recovery, body.message, body.error]
.find((value): value is string => typeof value === "string" && value.trim().length > 0)
?? STREAM_FIRST_FALLBACK_MESSAGE;
const reason = typeof body.code === "string" && body.code ? body.code.slice(0, 80) : undefined;
const safeStatus = Number.isInteger(status) && status >= 400 && status <= 599 ? status : 503;
return {
type: "run.failed",
code: "request_rejected",
message: message.slice(0, 200),
status: safeStatus,
...(reason ? { reason } : {}),
};
}
function isNdjson(response: Response): boolean {
return (response.headers.get("content-type") ?? "").includes("application/x-ndjson");
}
export function streamFirstResponse(options: StreamFirstOptions): Response {
const encoder = new TextEncoder();
const now = options.now ?? (() => Date.now());
const startedAt = options.startedAt ?? now();
let disconnected = false;
let innerReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
const body = new ReadableStream<Uint8Array>({
start(controller) {
const send = (event: ConsultationAgentPublicEvent) => {
if (disconnected) return;
const safe = consultationAgentPublicEventSchema.parse(event);
controller.enqueue(encoder.encode(`${JSON.stringify(safe)}\n`));
};
// The first byte goes out before the turn starts, synchronously.
send(options.firstFrame ?? CONSULTATION_RECEIVED_EVENT);
options.onFirstFrame?.(Math.max(0, now() - startedAt));
void (async () => {
try {
const inner = await options.run();
if (isNdjson(inner) && inner.body) {
innerReader = inner.body.getReader();
if (disconnected) {
// The client left while the turn was being prepared: hand the
// disconnect to the inner stream, which keeps its own settlement.
await innerReader.cancel().catch(() => {});
} else {
for (;;) {
const { done, value } = await innerReader.read();
if (done) break;
if (!disconnected && value) controller.enqueue(value);
}
}
} else {
const text = await inner.text().catch(() => "");
let payload: unknown = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { message: text };
}
send(consultationRejectionEvent(inner.status, payload));
}
} catch (error) {
await Promise.resolve(options.onUnhandled?.(error)).catch(() => {});
try {
send({ type: "run.failed", code: "calculation_failed", message: STREAM_FIRST_FALLBACK_MESSAGE });
} catch {
// The stream is already gone; nothing to tell.
}
} finally {
if (!disconnected) {
try {
controller.close();
} catch {
// Closed by the consumer first.
}
}
}
})();
},
async cancel() {
disconnected = true;
// The inner stream owns settlement and continues after a disconnect,
// exactly as it did when the client held it directly.
await innerReader?.cancel().catch(() => {});
},
});
return new Response(body, {
headers: {
"cache-control": "no-cache, no-transform",
"content-type": "application/x-ndjson; charset=utf-8",
"x-accel-buffering": "no",
"x-ayanam-mode": "mastra-agentic",
"x-ayanam-request-id": options.requestId,
},
});
}
@@ -74,3 +74,228 @@ test("the consultation hook paces the write-out only for a finished run; stop, f
// No second animation system: the write-out is the same frame buffer the live stream uses.
assert.equal((hook.match(/createStreamFrameBuffer</g) ?? []).length, 1);
});
// ---------------------------------------------------------------------------
// T2 / BUG-1074: the response stream exists before the turn does its work.
// ---------------------------------------------------------------------------
import { consultationAgentPublicEventSchema, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
import { CONSULTATION_RECEIVED_LABEL } from "../src/lib/consultation-activity-labels.ts";
import {
CONSULTATION_RECEIVED_EVENT,
STREAM_FIRST_FALLBACK_MESSAGE,
consultationRejectionEvent,
streamFirstResponse,
} from "../src/lib/stream-first-response.ts";
import { streamSmalltalkResponse } from "../src/lib/stream-smalltalk-response.ts";
const decoder = new TextDecoder();
async function readLines(response: Response, limit = Infinity): Promise<string[]> {
const reader = response.body!.getReader();
let text = "";
const lines: string[] = [];
for (;;) {
const { done, value } = await reader.read();
if (value) text += decoder.decode(value, { stream: true });
const parts = text.split("\n");
text = parts.pop() ?? "";
lines.push(...parts.filter(Boolean));
if (done || lines.length >= limit) break;
}
if (lines.length >= limit) await reader.cancel().catch(() => {});
return lines;
}
function ndjson(events: ConsultationAgentPublicEvent[], headers: Record<string, string> = {}) {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const event of events) controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
controller.close();
},
});
return new Response(body, { headers: { "content-type": "application/x-ndjson; charset=utf-8", ...headers } });
}
test("① the first frame is written before the turn has done anything, even while classification hangs", async () => {
let firstFrameMs = -1;
let ran = false;
const response = streamFirstResponse({
requestId: "fake-request",
startedAt: 1_000,
now: () => 1_040,
onFirstFrame: (elapsed) => { firstFrameMs = elapsed; },
run: () => new Promise<Response>(() => { ran = true; }), // never resolves: a hanging classification
});
assert.equal(response.headers.get("content-type"), "application/x-ndjson; charset=utf-8");
assert.equal(response.headers.get("x-ayanam-request-id"), "fake-request");
const [first] = await readLines(response, 1);
const event = consultationAgentPublicEventSchema.parse(JSON.parse(first!));
assert.deepEqual(event, CONSULTATION_RECEIVED_EVENT);
assert.equal(event.type === "activity" && event.label, CONSULTATION_RECEIVED_LABEL);
assert.equal(firstFrameMs, 40);
assert.equal(ran, true);
});
test("② a smalltalk turn completes inside the stream: free settlement once, answer, receipt-free completion", async () => {
let persisted = 0;
const response = streamFirstResponse({
requestId: "fake-request",
run: async () => streamSmalltalkResponse({
requestId: "fake-request",
reply: "你好,想聊什么都可以",
complete: async () => { persisted += 1; },
onError: async () => assert.fail("no error path"),
}),
});
const events = (await readLines(response)).map((line) => consultationAgentPublicEventSchema.parse(JSON.parse(line)));
assert.deepEqual(events.map((event) => event.type), ["activity", "answer.delta", "run.completed"]);
assert.equal(persisted, 1);
assert.deepEqual(events[2], { type: "run.completed", responseKind: "smalltalk" });
// The header the client used to read is gone with the stream-first order; the event carries the kind.
assert.equal(response.headers.get("x-jyotish-response-kind"), null);
});
test("③ a JSON rejection becomes one run.failed request_rejected with the same status, sentence and code", async () => {
const insufficient = consultationRejectionEvent(402, { error: "咨询点数不足", message: "请先兑换咨询点数后再继续。" });
assert.deepEqual(insufficient, { type: "run.failed", code: "request_rejected", status: 402, message: "请先兑换咨询点数后再继续。" });
const full = consultationRejectionEvent(409, { error: "这段对话已写满", message: "这段对话已写满,开个新对话继续吧", code: "session_full" });
assert.deepEqual(full, { type: "run.failed", code: "request_rejected", status: 409, message: "这段对话已写满,开个新对话继续吧", reason: "session_full" });
// Same precedence the client applied to the HTTP body: recovery, then message, then error.
const generation = consultationRejectionEvent(503, { error: "暂时无法生成解读", message: "咨询服务暂时不可用,请稍后再试。", recovery: "稍后重试,或换一个模型继续。" });
assert.equal(generation.type === "run.failed" && generation.message, "稍后重试,或换一个模型继续。");
assert.equal(consultationRejectionEvent(200, {}).type === "run.failed" && consultationRejectionEvent(200, {}).status, 503);
assert.equal(consultationRejectionEvent(503, null).type === "run.failed" && consultationRejectionEvent(503, null).message, STREAM_FIRST_FALLBACK_MESSAGE);
for (const event of [insufficient, full, generation]) consultationAgentPublicEventSchema.parse(event);
const response = streamFirstResponse({
requestId: "fake-request",
run: async () => new Response(JSON.stringify({ error: "咨询点数不足", message: "请先兑换咨询点数后再继续。" }), {
status: 402,
headers: { "content-type": "application/json" },
}),
});
const events = (await readLines(response)).map((line) => consultationAgentPublicEventSchema.parse(JSON.parse(line)));
assert.deepEqual(events.map((event) => event.type), ["activity", "run.failed"]);
assert.deepEqual(events[1], insufficient);
});
test("the failure schema keeps status and reason to request_rejected, which must carry a status", () => {
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "run.failed", code: "request_rejected", message: "x" }).success, false);
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "run.failed", code: "calculation_failed", message: "x", status: 503 }).success, false);
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "run.failed", code: "empty_answer", message: "x", reason: "why" }).success, false);
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "run.failed", code: "request_rejected", message: "x", status: 409, reason: "session_full" }).success, true);
assert.equal(consultationAgentPublicEventSchema.safeParse({ type: "activity", phase: "received", label: CONSULTATION_RECEIVED_LABEL }).success, true);
});
test("④ a consultation stream passes through byte for byte after the first frame; the inner headers are not needed", async () => {
const inner: ConsultationAgentPublicEvent[] = [
{ type: "run.started", runId: "fake-request", requestId: "fake-request" },
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "answer.delta", text: "不是。" },
{ type: "run.failed", code: "answer_truncated", message: "回答没写完" },
];
const response = streamFirstResponse({
requestId: "fake-request",
run: async () => ndjson(inner, { "x-jyotish-birth-time-mode": "verified_chart" }),
});
const lines = await readLines(response);
assert.equal(lines.length, inner.length + 1);
assert.deepEqual(JSON.parse(lines[0]!), CONSULTATION_RECEIVED_EVENT);
assert.deepEqual(lines.slice(1), inner.map((event) => JSON.stringify(event)));
assert.equal(response.headers.get("x-jyotish-birth-time-mode"), null);
});
test("a client disconnect is handed to the inner stream, which keeps its own settlement", async () => {
let innerCancelled = false;
let release: (() => void) | undefined;
const innerBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(`${JSON.stringify({ type: "run.started", runId: "r", requestId: "r" })}\n`));
// Then hang until the test releases it.
void new Promise<void>((resolve) => { release = resolve; }).then(() => {
try {
controller.close();
} catch {
// Cancelled by the consumer first: the disconnect path under test.
}
});
},
cancel() { innerCancelled = true; },
});
const response = streamFirstResponse({
requestId: "r",
run: async () => new Response(innerBody, { headers: { "content-type": "application/x-ndjson; charset=utf-8" } }),
});
const reader = response.body!.getReader();
await reader.read(); // the first frame
await reader.read(); // run.started, relayed
await reader.cancel();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(innerCancelled, true);
release?.();
});
test("a turn that throws before producing any response ends the stream with a generic failure and is logged", async () => {
const seen: unknown[] = [];
const response = streamFirstResponse({
requestId: "fake-request",
onUnhandled: (error) => { seen.push(error); },
run: async () => { throw new Error("session_full"); },
});
const events = (await readLines(response)).map((line) => consultationAgentPublicEventSchema.parse(JSON.parse(line)));
assert.deepEqual(events.map((event) => event.type), ["activity", "run.failed"]);
assert.deepEqual(events[1], { type: "run.failed", code: "calculation_failed", message: STREAM_FIRST_FALLBACK_MESSAGE });
assert.equal(seen.length, 1);
});
test("the consult route opens the stream before subject reads, reservation, question append and classification", () => {
const route = source("../src/app/api/consult/route.ts");
const openIndex = route.indexOf("const runTurn = async (): Promise<Response> =>");
assert.ok(openIndex > 0);
// Stays in front of the stream: auth, body, session existence and type, the session model.
for (const marker of [
"status: 401",
'{ error: "出生资料或问题格式不正确", details: parsed.error.flatten() }',
'error: "咨询会话不存在"',
'code: "session_not_consultation"',
'error: "会话模型已经变化"',
"if (blocksPromptExtraction(userControlledPrompt))",
]) {
assert.ok(route.indexOf(marker) < openIndex, `${marker} must answer with an HTTP status`);
}
// Inside the stream: the slow part, and every rejection it can raise.
for (const marker of [
"prepareConsultationRoute<ModelSelection, ConsultationPlan>({",
'accounting.rpc("reserve_consultation_usage"',
'accounting.rpc("append_consultation_question"',
"await classifyConsultationTurn({",
'code: "session_full"',
"insufficient ? 402 : 503",
'error: "暂时无法生成解读"',
]) {
assert.ok(route.indexOf(marker) > openIndex, `${marker} must run inside the stream`);
}
assert.match(route, /if \(!shouldUseAgenticRuntime\(user\)\) return runTurn\(\);/);
assert.match(route, /return streamFirstResponse\(\{\s*requestId,\s*run: runTurn,\s*startedAt: requestStartedAt,/);
assert.match(route, /phase: "first_byte", durationMs: elapsedMs, status: "completed"/);
// The smalltalk branch is untouched: it still settles for free and never runs the agent.
const smalltalk = route.slice(route.indexOf('if (turn.kind === "smalltalk")'), route.indexOf("const expectedTitle"));
assert.match(smalltalk, /complete_consultation_free/);
assert.doesNotMatch(smalltalk, /streamAgentResponse\(/);
});
test("the client reads smalltalk and rejections from events, with the same status semantics as before", () => {
const hook = source("../src/hooks/use-consultation-run.ts");
assert.doesNotMatch(hook, /x-jyotish-response-kind/);
assert.match(hook, /if \(event\.responseKind === "smalltalk"\) \{\s*responseKind = "smalltalk";/);
assert.match(hook, /event\.type === "run\.failed" && event\.code === "request_rejected"/);
assert.match(hook, /if \(status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\);/);
assert.match(hook, /throw new ConsultationResponseError\(status, payloadMessage\(\{ message: event\.message \}, "服务暂时不可用"\), event\.reason\);/);
// The rollback / notice handling keyed on status and code is unchanged.
assert.match(hook, /caught\.code === "session_full"/);
assert.match(hook, /const reserveDidNotCommit = caught\.status === 400\s*\|\| caught\.status === 401\s*\|\| caught\.status === 402\s*\|\| caught\.status === 409;/);
// The first frame carries no completed trail.
assert.match(hook, /event\.type === "activity" && event\.phase === "received"/);
});
@@ -32,7 +32,7 @@ import {
ConsultationWorkflowError,
consultationWorkflowFailureCode,
} from "../src/mastra/consultation-workflow.ts";
import { agentExecutionReceiptSchema } from "../src/lib/consultation-agent-events.ts";
import { agentExecutionReceiptSchema, type PublicActivityPhase } from "../src/lib/consultation-agent-events.ts";
import { consultationDomainIds, consultationDomainPlanValues } from "../src/lib/consultation-domain-registry.ts";
import {
OPENER_THINKING_TITLE,
@@ -1582,7 +1582,9 @@ function makeWindowCtx(options: { fetchWindowChart?: () => Promise<ReturnType<ty
return { ctx, state, tools: createWindowConsultationTools(ctx) };
}
async function writerToSend(send: (event: { type: "activity"; phase: "chart-calculation" | "evidence-validation" | "loading-method" | "answer-composition"; label: string }) => void) {
// 原值: phase 手写四值联合 新值: 用 schema 导出的 PublicActivityPhase(多了 "received")
// 原因: TASK-consult-first-frame-and-pacing-20260928 D2 / BUG-1074:流的首帧是 phase "received" 的 activity 事件
async function writerToSend(send: (event: { type: "activity"; phase: PublicActivityPhase; label: string }) => void) {
return {
custom: async (chunk: unknown) => {
if (!chunk || typeof chunk !== "object") return;
@@ -213,7 +213,11 @@ test("disconnect does not cancel the server-owned free completion", async () =>
test("smalltalk views never reconstruct an invented settled execution timeline", () => {
assert.equal(settledChatMessageViews([{ role: "assistant", text: "你好", responseKind: "smalltalk" }])[0]?.timeline, undefined);
assert.equal(streamingChatMessageView([{ role: "user", text: "你好" }], true, "你好", undefined, undefined, undefined, [], "smalltalk")?.responseKind, "smalltalk");
assert.match(source("../src/components/chat-message-row.tsx"), /const quiet = smalltalk \|\| awaitingClassification/);
// 原值: /const quiet = smalltalk \|\| awaitingClassification/(分类回来前整行静默)
// 新值: /const quiet = smalltalk;/(只有寒暄回复静默;分类前显示排队行)
// 原因: TASK-consult-first-frame-and-pacing-20260928 D1 / BUG-1074
assert.match(source("../src/components/chat-message-row.tsx"), /const quiet = smalltalk;/);
assert.doesNotMatch(source("../src/components/chat-message-row.tsx"), /awaitingClassification/);
assert.match(source("../src/lib/home-cloud-sync.ts"), /stored.responseKind === "smalltalk"/);
});
test("route gates all special entries before a single selected-model classifier and keeps tool contracts", () => {