diff --git a/CHANGELOG.md b/CHANGELOG.md index a4209ba9..d1df2a90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 生时校正每一轮少等一次模型 + +- 生时校正每一轮不再先让模型花一次请求去读案例,由服务器直接读好交给模型,模型一开始就回应你。估计每轮快 3~8 秒;问答内容和流程不变。 +- Skill 版本不变(BUG-1491,issue #136)。 + ## 2026-10-28 — 修正排盘服务同时算两张盘时偶发报错 - 上一版「同一份代码只留一份」在两张盘同时算(如合盘全档案)时,偶尔会拿到还没载入完的代码而报错;现在会等它载入完再用。结果不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b5638765..dbe27cf0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -20370,3 +20370,18 @@ - 修复版本:分支 `codex/upstream-sync9-20261028`。 > 第九轮同步(issue #141)原用 BUG-1472~1479,与同日另一会话合入 staging 的编号冲突,合并时整体顺延 11 号为 BUG-1483~1490;分支上的提交信息仍写旧号。 + +## BUG-1491 | 生时校正每一轮先白调一次模型,只为读一下案例 + +- 状态:resolved(代码 + 回归测试;待部署) +- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检第 7 条 → #136;09-26 记为 D6「另议」,产品 10-28 同意) +- 影响面:生时校正每一轮;`frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts`、新模块 `agent-run-opening-read.ts`、`agent-run-messages.ts`、`frontend/src/mastra/agentic-rectification.ts`、`frontend/src/mastra/rectification-v9-tools.ts`(read-case 的工具说明)。 +- 现象:每一轮都固定先发一次模型请求,第 0 步只允许调 `rectification-read-case`,模型唯一能做的就是读案例;读完再发下一次请求才开始真正的回应。估每轮多 3~8 秒(体检估计)。 +- 触发条件:任何一轮生时校正(开场、证据、选择题等)。 +- 根因:「第一步必须读案例」用模型的第 0 步来保证,而默认读取(`turn_decision` 投影、不恢复)的结果服务器自己就能算。 +- 修复:运行器在绑定 Skill 之后、调模型之前,直接执行同一个 read-case 工具(同一个工具对象、同一份回执、同一个投影),把这次调用和结果作为对话开头交给模型;模型从第 1 步起就是真正的回应,第 0 步也拿到全部工具。read-case 仍可再调:暂停的 Case 要按用户意思恢复(`resume=true`)、最终报告要 `full_diagnostics` 时由模型再调。服务器读取的 `tool-call` / `tool-result` 走原来同一条处理路径,所以 `case.loaded`、工具活动、Skill 回执各只出现一次;它们不计入模型步数和步耗时。思考模式下 DeepSeek 要求历史里的工具调用带回思考内容,空的会被丢掉、缺了会 HTTP 400,所以合成的那次调用带一句「服务器已读取本轮案例。」。服务器读取失败时本次 attempt 记为 `case_not_loaded`、不调模型(与以前模型第 0 步读失败的结果相同)。拿不到 read-case 工具的 agent(测试里的假 agent)保持原来的第 0 步读取。提示词三处同步:系统引导、两套 agent 指令的第 1 条、read-case 的工具说明。Skill 包本身没有「第一步读案例」的原文(只把 read-case 列为可用工具),版本不变。 +- 验证:`frontend/tests/rectification-v9-agent.test.ts` 新增 3 条:服务器只读一次、用默认投影;第 0 步已有全部工具且仍含 read-case;交给模型的历史里有带思考内容的合成调用和原样投影;引导语换成「本轮案例已由服务器读取」;read-case 的工具活动「started」与 `case.loaded` 各 1 次。服务器读取失败 → `case_not_loaded`、不调模型。没有 `listTools` 的 agent 仍走第 0 步读取、仍是原引导语。另用真实 Mastra `Agent` 验证 `listTools()` 拿到的工具能直接执行;用产品同一通道(Mastra OpenAI 兼容通道 + `deepseek-flash` 思考模式)实测带合成调用的请求被接受并按读取结果作答。全量前端测试与 staging 逐条比对(本机让 `python3` 指向项目 venv,见 ERR-117):只多出新增的 3 条,没有新失败(合盘全档案那条在本分支上失败,是 BUG-1482 的线程问题,staging `a614895a` 已修,合入后消失)。 +- 防复发:上述测试。服务器能确定的工具调用不要让模型花一步去做。 +- 关联:BUG-282 / BUG-937(思考模式与工具选择)、BUG-368(重复工具调用)。 +- 复发自:无 +- 修复版本:分支 `codex/rectification-preread-case-20261028`。 diff --git a/docs/testing/code-health-118-20261028.md b/docs/testing/code-health-118-20261028.md index 38cd3589..e2842cd5 100644 --- a/docs/testing/code-health-118-20261028.md +++ b/docs/testing/code-health-118-20261028.md @@ -46,3 +46,9 @@ 22. 在档案页按刷新:列表和以前一样;改一个人的资料、添加一个人后,列表马上是新的。 23. 在合盘页按刷新:选人区、余额、历史报告照常;合盘服务关着时仍只显示「合盘服务暂未开放」,不闪选人区。 +## #136 生时校正每一轮少等一次模型(BUG-1491) + +24. 从首页进入生时校正,走完开场:开场的话和第一道题照常出来,比以前快一截(以前出字前会多停几秒)。 +25. 说一件人生大事(带年月):照常回「记下了」并出下一题;进度、选项都正常。 +26. 先说「先这样」暂停,再回来说「继续」:能接着校正,不会卡在「已暂停」。 + diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts index 7f587c5f..73789f85 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-attempt.ts @@ -73,6 +73,7 @@ import { v9TurnReceipts, } from "./agent-run-support"; import { buildAgentMessages } from "./agent-run-messages"; +import { openingCaseReader, withOpeningRead, type OpeningRead } from "./agent-run-opening-read"; import { productDeliveryForDomain } from "./product-delivery.ts"; import type { V9TimedTurn } from "./agent-run-prepare"; @@ -133,6 +134,8 @@ export async function streamV9Attempt( loadCompute: () => loadV9CaseCompute(accounting, userId, caseId), }); } + // issue #136: the server reads the case itself instead of spending step 0 on it. + const readCase = await openingCaseReader(agent); const messages = buildAgentMessages( options, attemptNumber, @@ -141,6 +144,7 @@ export async function streamV9Attempt( birthTimeClue, previousErrorCode, openingBoundarySentence(openingFacts), + readCase !== null, ); const maxSteps = resolveRectificationStepBudget(action); const abortController = new AbortController(); @@ -215,6 +219,14 @@ export async function streamV9Attempt( await recordPhase("skill.bound"); await publish({ type: "skill.bound" }); emittedKeys.add("event:skill.bound::"); + let openingRead: OpeningRead | null = null; + if (readCase) { + try { + openingRead = await readCase(caseId); + } catch { + return failedAttempt(attemptId, "case_not_loaded"); + } + } const generation = agentGenerationSettings(options.generationModel, { thinking: "enabled", @@ -249,14 +261,15 @@ export async function streamV9Attempt( }>; totalUsage?: Promise>; }>; - }).stream(messages, { + }).stream(openingRead ? [...messages, ...openingRead.messages] : messages, { maxSteps, abortSignal: abortController.signal, ...generation, // Thinking-mode providers reject named/required tool_choice. Restrict // the first step to read-case and keep tool_choice auto; the runner - // still refuses any other public tool before case.loaded. - prepareStep: ({ stepNumber }) => stepNumber === 0 + // still refuses any other public tool before case.loaded. When the + // server already read the case, every step has the full tool set. + prepareStep: ({ stepNumber }) => stepNumber === 0 && !openingRead ? { activeTools: ["rectification-read-case"], toolChoice: "auto", @@ -329,8 +342,9 @@ export async function streamV9Attempt( }; try { - for await (const chunk of result.fullStream) { - recordStepChunk(stepTimings, chunk, Date.now() - attemptStartedAt, isPublicRectificationToolName); + for await (const { chunk, synthetic } of withOpeningRead(openingRead, result.fullStream)) { + // The server's read-case is not a model step: no step timing, no step answer. + if (!synthetic) recordStepChunk(stepTimings, chunk, Date.now() - attemptStartedAt, isPublicRectificationToolName); const progressStage = turnProgressForChunk(chunk as never); if (progressStage) reportTurnProgress(progressStage); const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : ""; @@ -355,11 +369,9 @@ export async function streamV9Attempt( } } - const stepEffect = applyStepAnswerChunk( - stepAnswer, - chunk, - isPublicRectificationToolName, - ); + const stepEffect = synthetic + ? { kind: "none" as const } + : applyStepAnswerChunk(stepAnswer, chunk, isPublicRectificationToolName); if (stepEffect.kind === "live") await publishSpokenStep([stepEffect.text], true); if (stepEffect.kind === "publish") await publishSpokenStep(stepEffect.pieces); if (stepEffect.kind === "retract") await retractSpoken(); diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts index e6cd25e0..9fb8f07a 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-messages.ts @@ -64,6 +64,7 @@ export function buildAgentMessages( birthTimeClue: string | null = null, previousErrorCode: string | null = null, boundarySentence: string | null = null, + caseReadByServer = false, ): unknown[] { const timeContext = options.timeContext ?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`; @@ -72,7 +73,10 @@ export function buildAgentMessages( // the Skill sections it uses; other actions keep the whole bound body. const boundSkill = sliceRectificationSkill(skillInstructions, options.action).text; const bootstrapContent = [ - "【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。第一步必须调用 rectification-read-case。", + `【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。${caseReadByServer + // issue #136: the turn opens with the server's read-case call and result. + ? "本轮案例已由服务器读取(rectification-read-case 默认投影,就在对话末尾),不要再读同一份;只有暂停的 Case 要按用户意思恢复(resume=true)或最终报告需要 full_diagnostics 时才再调用它。" + : "第一步必须调用 rectification-read-case。"}`, boundSkill, ...(productDeliveryForDomain(dossier.case.rectificationDomain) ? [ `【服务端持久产品身份】${JSON.stringify(productDeliveryForDomain(dossier.case.rectificationDomain))}。本轮按目标盘型交付,不能因摘要缺失退分钟优先;盘型事实只读工具返回的segment_summary,代表签名仅诊断,不能作实际采用盘。`, diff --git a/frontend/src/lib/rectification-agentic/v9/agent-run-opening-read.ts b/frontend/src/lib/rectification-agentic/v9/agent-run-opening-read.ts new file mode 100644 index 00000000..8929a5b2 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/agent-run-opening-read.ts @@ -0,0 +1,90 @@ +/** + * The server reads the case before the model's first step (issue #136). + * + * Every turn used to spend one model call whose only job was to call + * `rectification-read-case` with the default projection, a result the server + * can produce itself. The runner now calls the bound tool directly (same + * tool, same receipts, same projection) and hands the model the call and its + * result as the opening of the turn, so the model starts at the real work. + * The model can still call read-case again: `resume=true` for a paused case + * the user wants to continue, `projection=full_diagnostics` for the report. + */ + +export const OPENING_READ_TOOL = "rectification-read-case"; +export const OPENING_READ_TOOL_CALL_ID = "server-read-case-0"; +/** + * Thinking-mode providers reject a prior assistant tool call without + * reasoning_content (DeepSeek: HTTP 400), and an empty reasoning part is + * dropped on the way out, so the synthetic call carries one short line. + */ +const OPENING_READ_REASONING = "服务器已读取本轮案例。"; + +export type OpeningReadChunk = Readonly<{ + type: "tool-call" | "tool-result"; + payload: Readonly<{ toolCallId: string; toolName: string; args: { caseId: string }; result?: unknown }>; +}>; + +export type OpeningRead = Readonly<{ + chunks: readonly OpeningReadChunk[]; + messages: readonly unknown[]; +}>; + +type ToolLike = { execute?: (input: { caseId: string }, context?: unknown) => Promise }; + +/** + * The agent's own read-case tool, or null when it cannot hand one out; the + * model then reads on step 0 as before. Looked up before the turn messages + * are built, run after the Skill is bound so receipts keep their order. + */ +export async function openingCaseReader(agent: unknown): Promise<((caseId: string) => Promise) | null> { + const listTools = (agent as { listTools?: () => Promise> } | null)?.listTools; + if (typeof listTools !== "function") return null; + let tool: ToolLike | undefined; + try { + tool = (await listTools.call(agent))?.[OPENING_READ_TOOL] as ToolLike | undefined; + } catch { + return null; + } + const execute = tool?.execute; + if (typeof execute !== "function") return null; + return async (caseId) => { + const args = { caseId }; + // The tool writes its own started / completed (or failed) receipt; a throw + // here is the same failure the model's step-0 call would have hit. + const result = await execute.call(tool, args, {}); + const toolCall = { toolCallId: OPENING_READ_TOOL_CALL_ID, toolName: OPENING_READ_TOOL, args }; + return { + chunks: [ + { type: "tool-call", payload: toolCall }, + { type: "tool-result", payload: { ...toolCall, result } }, + ], + messages: [ + { + role: "assistant", + content: [ + { type: "reasoning", text: OPENING_READ_REASONING }, + { type: "tool-call", toolCallId: OPENING_READ_TOOL_CALL_ID, toolName: OPENING_READ_TOOL, input: args }, + ], + }, + { + role: "tool", + content: [{ + type: "tool-result", + toolCallId: OPENING_READ_TOOL_CALL_ID, + toolName: OPENING_READ_TOOL, + output: { type: "json", value: result }, + }], + }, + ], + }; + }; +} + +/** The server's chunks first, then the model's stream; each marked so steps are only the model's. */ +export async function* withOpeningRead( + opening: OpeningRead | null, + stream: AsyncIterable, +): AsyncGenerator<{ chunk: T; synthetic: boolean }> { + for (const chunk of opening?.chunks ?? []) yield { chunk: chunk as unknown as T, synthetic: true }; + for await (const chunk of stream) yield { chunk, synthetic: false }; +} diff --git a/frontend/src/mastra/agentic-rectification.ts b/frontend/src/mastra/agentic-rectification.ts index f673f699..60d8a961 100644 --- a/frontend/src/mastra/agentic-rectification.ts +++ b/frontend/src/mastra/agentic-rectification.ts @@ -45,7 +45,7 @@ const RECTIFICATION_VOICE_HEAD = `你是 Jyotisha,只服务当前绑定 jyotis 像一个懂行、说话温和的姐姐:先回应人,再展开方法;语气放软,但不含糊。用户想不起来、说不准时不催、不评判,「记不清也没关系」这类话最多一句;不说教,不写「呀」「哦」「抱抱」「宝」「亲爱的」,不叠字,不卖萌,不用表情。`; const agenticRectificationInstructions = `${RECTIFICATION_VOICE_HEAD}范围由系统说:范围变化、没重新比较、比较没跑成这几句由服务器接在你的正文后面;除出牌轮外你不写时刻(HH:MM)、时间区间和百分比;任何一句里的时刻或百分比与服务端当轮事实不符,整句不会落库。下一问是采集题时,先承接用户刚说的事;进度数字只用 collection_progress,没有该字段不说进度。不得写「范围在收窄」「继续收窄」这类没有数字的进度句。 -1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威。 +1. 每轮先要有 rectification-read-case 的结果:服务器已在对话里附上本轮的读取结果时直接用,不要再读;没有附上时第一步调用它(issue #136)。服务器是事实、焦点、权限与终态的唯一权威。 2. 事实只能来自用户原话;复述日期必须用 display_date_label。不得虚构事件、候选或出生分钟。 3. 新事件走 rectification-record-evidence-batch。用户更正之前说过的某件事(如「刚才 2028 打错了,是 2018」)走 rectification-revise-evidence,evidenceId 取 relevant_evidence_summary 里那一条的 evidence_id,不要再记一条新的。工具执行保持静默;思考用简体中文写在思维链;对用户说的话必须自己写在正文里,不叙述工具或内部状态。 4. 每轮在记录证据后,用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:用自己的话、结合用户刚说的事,问出同一个年份/期间和同一个事件家族;不得改年份、不得改选项含义、不得合并两道题。正文只做承接,不提问、不复述题干、不预告选项——题干会作为同一条消息的下一段自动出现。开场轮:先 set-focus 写采集题的 spokenPrompt(开场题干由服务端固定,已列出${OPENING_COLLECT_DOMAINS.join("、")}和一个回答示例),正文三层大白话:要把出生时间缩小到更准的范围、现在先在哪段时间里找;做法是用户说几件人生大事和大概年月,拿去和星盘对照;限度是「${OPENING_LIMIT_BODY}」,不得丢掉「不保证」和「不准」。正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不重复题干里的例子,不提问;不得写具体年份,不得要求先准备材料。没有下一问(服务端返回 next_followup=null)时不要自拟问题。证据轮的复述由服务器写:「记下了 N 件事」(两件以内直接列出),清单挂在同一条消息里;你不逐条复述用户说的经历,不得评价价值或写「很有帮助 / 很有价值 / 很有分量 / 特别有用」。case.accepted_time 非空时,正文第一句要说明已按该时间采用、现在在核对。正文不得断言界面当前状态,不要写「界面上有下一问」「界面上出现了…」。choice 选项由服务端写入同一条消息,collect_spoken 只承接用户刚说的事实,不输出输入提示。点选与「先这样」由服务器处理。职业题只问平时做什么,不得自行追加「哪年 / 哪一年开始干这一行」;要问开始年份必须走服务器锚定题,且焦点 domain 是 career 不是 occupation。 @@ -55,7 +55,7 @@ const agenticRectificationInstructions = `${RECTIFICATION_VOICE_HEAD}范围由 坏:「范围还在收。」好:「2016 年 9 月入学记下了。还有吗?比如第一份工作、搬到别的城市。」`; const segmentProductInstructions = `${RECTIFICATION_VOICE_HEAD}服务器持久产品身份是唯一交付口径;目标是判断用哪张盘解读,不承诺缩到某一分钟。没有摘要仍是新产品,不能退回分钟优先。 -1. 第一步调用 rectification-read-case。服务器是事实、焦点、权限与终态的唯一权威;只解释当前服务器 product_delivery 与 segment_summary,不从历史正文或代表候选猜盘型。 +1. 每轮先要有 rectification-read-case 的结果:服务器已在对话里附上本轮的读取结果时直接用,不要再读;没有附上时第一步调用它(issue #136)。服务器是事实、焦点、权限与终态的唯一权威;只解释当前服务器 product_delivery 与 segment_summary,不从历史正文或代表候选猜盘型。 2. 事实只能来自用户原话;日期只用 display_date_label。新事件走 rectification-record-evidence-batch;工具执行保持静默,对用户说的话必须自己写在正文里。 3. 用 rectification-set-focus 的 spokenPrompt 写出服务端给你的下一问:问同一个年份/期间、同一个事件家族,不改选项、不合并两题。正文只承接,不提问、不复述题干。没有下一问就不自拟问题。职业题只问平时做什么;开始年份只能走服务器 career 锚定题。 4. 开场正文三层大白话:先判断现在的出生时间能不能选定用来解读的星盘;你说几件人生大事和大概年月,拿去和星盘对照;限度是「${OPENING_LIMIT_BODY}」,不得丢掉「不保证」和「不准」。搜索窗口只作次要信息。不说要把出生时间缩小到更准的范围,不写具体年份、不列例子、不要求准备材料;开场题干仍由服务器固定。 diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 315c4aa9..9ab23ec7 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -1217,7 +1217,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const readCaseTool = createTool({ id: "rectification-read-case", description: - "读取服务端 Case 的对话决策投影。默认 turn_decision,只含当前问题、探针、候选摘要和近期对话,不得把完整诊断当每轮输入。最终报告或审计才用 projection=full_diagnostics。任何一轮开始必须先调用本工具。仅当 paused Case 的用户语义明确表示继续校正或提交新证据时传 resume=true;询问当前结果、重复停止或只看结果不得恢复。", + "读取服务端 Case 的对话决策投影。默认 turn_decision,只含当前问题、探针、候选摘要和近期对话,不得把完整诊断当每轮输入。最终报告或审计才用 projection=full_diagnostics。任何一轮开始都必须先有本工具的结果;服务器已在对话里附上本轮结果时不要重复调用。仅当 paused Case 的用户语义明确表示继续校正或提交新证据时传 resume=true;询问当前结果、重复停止或只看结果不得恢复。", inputSchema: z.object({ caseId: z.string().uuid(), projection: z.enum(["turn_decision", "full_diagnostics"]).optional(), diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index b8a30d73..3d5b0c33 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -1122,3 +1122,71 @@ test("an evidence turn that records twelve items is written 「记下了 12 件 const finalize = accounting.calls.find((call) => call.fn === "finalize_agentic_rectification_turn"); assert.equal(String(finalize?.args.p_assistant_message ?? ""), "记下了 12 件事。"); }); + +// issue #136: the server reads the case itself, so the model's first step is the real work. +function preReadAgent(chunks: StreamChunk[], execute: (input: unknown) => Promise) { + const seen: { messages: unknown[]; step0Tools: string[] | null } = { messages: [], step0Tools: null }; + const agent = { + ...fakeAgentStream(chunks), + listTools: async () => ({ "rectification-read-case": { execute } }), + stream: async (messages: unknown[], options: { prepareStep: (input: { stepNumber: number }) => { activeTools: string[] } }) => { + seen.messages = messages; + seen.step0Tools = options.prepareStep({ stepNumber: 0 }).activeTools; + return { + fullStream: (async function* () { for (const item of chunks) yield item; })(), + totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + }; + }, + }; + return { agent, seen }; +} + +test("the server reads the case before the model; the model starts at the real work (issue #136)", async () => { + const reads: unknown[] = []; + const projection = { case: { id: CASE_ID }, current_question: null }; + const { agent, seen } = preReadAgent([ + chunk("start"), + chunk("text-delta", { text: "已经记下。" }), + chunk("finish"), + ], async (input) => { reads.push(input); return projection; }); + const { options, emitted } = runOptions({ buildAgent: async () => agent as never }); + const result = await runV9AgentTurn(options); + + assert.equal(result.ok, true, String(result.errorCode)); + assert.deepEqual(reads, [{ caseId: CASE_ID }], "read once, with the default projection"); + assert.ok(seen.step0Tools && seen.step0Tools.length > 1 && seen.step0Tools.includes("rectification-read-case"), + "step 0 already has the full tool set; read-case stays available for resume / full diagnostics"); + const history = seen.messages as Array<{ role: string; content: unknown }>; + const call = history.find((message) => message.role === "assistant") as { content: Array<{ type: string; text?: string; toolName?: string }> }; + assert.ok(call.content.some((part) => part.type === "reasoning" && Boolean(part.text)), "thinking providers need reasoning_content"); + assert.ok(call.content.some((part) => part.type === "tool-call" && part.toolName === "rectification-read-case")); + const tool = history.find((message) => message.role === "tool") as { content: Array<{ output: { value: unknown } }> }; + assert.deepEqual(tool.content[0].output.value, projection, "the model sees the projection unchanged"); + assert.match(JSON.stringify(history[0]), /本轮案例已由服务器读取/); + const started = emitted.filter((event) => event.type === "tool.activity" + && (event as { tool?: string; status?: string }).tool === "rectification-read-case" + && (event as { status?: string }).status === "started"); + assert.equal(started.length, 1); + assert.equal(emitted.filter((event) => event.type === "case.loaded").length, 1); + assert.ok(result.toolsUsed.includes("rectification-read-case")); +}); + +test("a failed server read fails the attempt as case_not_loaded and never streams (issue #136)", async () => { + let streams = 0; + const { agent } = preReadAgent([chunk("start"), chunk("finish")], async () => { throw new Error("db down"); }); + const counting = { ...agent, stream: async (...args: Parameters) => { streams += 1; return agent.stream(...args); } }; + const { options } = runOptions({ buildAgent: async () => counting as never }); + const result = await runV9AgentTurn(options); + assert.equal(result.ok, false); + assert.equal(result.errorCode, "case_not_loaded"); + assert.equal(streams, 0); +}); + +test("an agent without listTools keeps the old step-0 read (issue #136)", async () => { + const { agent, seen } = preReadAgent([], async () => ({})); + const legacy = { ...agent, listTools: undefined }; + const { options } = runOptions({ buildAgent: async () => legacy as never }); + await runV9AgentTurn(options); + assert.deepEqual(seen.step0Tools, ["rectification-read-case"]); + assert.match(JSON.stringify((seen.messages as unknown[])[0]), /第一步必须调用 rectification-read-case/); +});