diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 4e5d590f..3206dbc8 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -32,6 +32,14 @@ async function* readChunks(stream: ChunkStream): AsyncIterable { } type Status = "ready" | "degraded" | "blocked"; +export const ENSURE_FINAL_RESPONSE_FALLBACK = + "本次计算已完成,但暂时没有生成可展示的回答。请换一个角度提问,我会基于已完成的计算继续说明。"; + +export function ensureFinalResponseText(output: string, contractIsReady: boolean) { + if (!contractIsReady || /\S/.test(output)) return null; + return ENSURE_FINAL_RESPONSE_FALLBACK; +} + type EventOptions = { runId: string; requestId: string; @@ -202,6 +210,19 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { await consumeAttempt(controller, await options.retry()); } if (!contractReady(options)) throw new Error("runtime_contract_incomplete"); + const ensuredFinalResponse = ensureFinalResponseText(fullOutput, contractReady(options)); + if (ensuredFinalResponse) { + if (options.state.steps.length < 32) { + options.state.steps.push({ sequence: options.state.steps.length + 1, kind: "validation", name: "ensure-final-response", status: "completed" }); + } + if (!firstOutput) { + firstOutput = true; + await options.onFirstOutput?.(); + } + send(controller, { type: "answer.delta", text: ensuredFinalResponse }); + fullOutput = ensuredFinalResponse; + emitted = true; + } if (!/\S/.test(fullOutput)) { if (/\S/.test(first.held) || /\S/.test(first.attemptOutput)) throw new Error("runtime_contract_incomplete"); throw new Error("empty_answer"); diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index c46c1a57..ec8567b7 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { createConsultationTools, createConsultationRuntimeState } from "../src/mastra/consultation-tools.ts"; import { getJyotishAgent } from "../src/mastra/index.ts"; import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts"; -import { collectAgentPublicEvents, streamAgentResponse } from "../src/lib/stream-agent-response.ts"; +import { collectAgentPublicEvents, ensureFinalResponseText, streamAgentResponse } from "../src/lib/stream-agent-response.ts"; const serverChart = { name: "测试", @@ -166,6 +166,31 @@ test("incomplete runtime contract fails without saving a successful answer", asy assert.equal(events.filter((event) => (event as { type?: string }).type === "run.failed").length, 1); }); +test("ensures a controlled final response after a successful tool-only run", async () => { + const state = createConsultationRuntimeState(); + state.jyotishSkillLoaded = true; + state.consultationToolCallCount = 1; + state.consultationToolCompleted = true; + state.workflowReceipt = { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }; + let completedOutput = ""; + async function* chunks() { + yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } }; + } + const response = streamAgentResponse({ + runId: "run", requestId: "req", state, stream: chunks(), requireTool: true, + toolStatus: () => "ready", receipt: () => receipt(state), + onComplete: (output) => { completedOutput = output; }, + }); + const events: unknown[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + assert.equal(ensureFinalResponseText("", true), completedOutput); + assert.match(completedOutput, /计算已完成/); + assert.equal(events.filter((event) => (event as { type?: string }).type === "answer.delta").length, 1); + assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1); + assert.equal(state.steps.at(-1)?.name, "ensure-final-response"); +}); + test("persistence failure emits run.failed instead of run.completed", async () => { const state = createConsultationRuntimeState();