Merge pull request #82 from jesse-ux/codex/rectification-tool-stream-followup

Fix rectification agent empty tool stream
This commit is contained in:
jesse-ux
2026-08-03 23:42:21 +08:00
committed by GitHub
3 changed files with 40 additions and 15 deletions
+7
View File
@@ -2023,3 +2023,10 @@
- 相关记录:BUG-016、BUG-114
- 复发自:BUG-114
- 修复版本:待本次 staging 修复提交与部署验收
## 2026-08-03 — Agentic rectification could finish after tool calls without a visible reply
- Symptom: `/api/rectification/agent` returned `{"type":"done","emitted":false}` after a user supplied another dated life event.
- Root cause: the Mastra stream used its default step limit. A turn that consumed all available steps on `rectification-*` tool calls ended before the model could produce the final user-facing Chinese response, while the route treated an empty `textStream` as a normal `done` event.
- Fix: allow eight Agent steps so the existing tool workflow can continue from tool results to a final response. Empty streams now return an explicit recoverable error event and remain on the existing refund path instead of silently reporting completion.
- Regression coverage: `frontend/tests/rectification-agentic-entry.test.ts` checks the multi-step stream boundary and rejects the former silent `done` contract.
@@ -43,6 +43,7 @@ const agenticRectificationRequestSchema = z.discriminatedUnion("action", [
]);
const openingContext = "The user opened birth-time rectification. Begin the session now: run the required gate, briefly explain the evidence-based process in Simplified Chinese, and ask exactly one natural question about the most useful dated life event. Do not mention this server event.";
const agenticRectificationMaxSteps = 8;
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
@@ -214,24 +215,26 @@ export async function POST(request: Request) {
controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
};
try {
const result = await agent.stream([
...parsed.data.history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
parsed.data.action === "opening" ? openingContext : parsed.data.message,
].filter(Boolean).join("\n"),
},
]);
const result = await agent.stream(
[
...parsed.data.history.map((message) => message.role === "user"
? { role: "user" as const, content: message.text }
: { role: "assistant" as const, content: message.text }),
{
role: "user",
content: [
currentTimeContext(requestTime),
parsed.data.name ? `用户称呼:${parsed.data.name}` : "",
parsed.data.action === "opening" ? openingContext : parsed.data.message,
].filter(Boolean).join("\n"),
},
],
{ maxSteps: agenticRectificationMaxSteps },
);
for await (const chunk of result.textStream) {
if (/\S/.test(chunk)) emitted = true;
send({ type: "delta", text: chunk });
}
send({ type: "done", emitted });
void recordModelUsage(
accounting,
userId,
@@ -239,7 +242,15 @@ export async function POST(request: Request) {
selectedModel.id,
result.totalUsage,
);
await settle(emitted);
if (!emitted) {
console.warn(`[agentic-rectification] empty response request=${requestId}`);
send({ type: "error", message: "生时校正没有生成有效回复,本次不会扣除点数,请重新发送。" });
await settle(false);
controller.close();
return;
}
send({ type: "done", emitted: true });
await settle(true);
controller.close();
} catch (error) {
const reason = error instanceof Error ? error.name : "UnknownError";
@@ -66,3 +66,10 @@ test("account rehydration normalizes persisted ISO birth dates before completene
);
assert.match(profileReader, /const date = normalizePersistedBirthDate\(/);
});
test("agent tool calls leave a final step for visible prose and never end silently", () => {
assert.match(route, /const agenticRectificationMaxSteps = 8/);
assert.match(route, /\{ maxSteps: agenticRectificationMaxSteps \}/);
assert.match(route, /if \(!emitted\) \{[\s\S]*type: "error"[\s\S]*await settle\(false\)[\s\S]*return;/);
assert.doesNotMatch(route, /send\(\{ type: "done", emitted \}\)/);
});