Rectification turns no longer spend a model call reading the case (issue #136, BUG-1491)

Step 0 of every turn could only call rectification-read-case with the
default projection, a result the server can compute. The runner now runs the
agent's own read-case tool after the Skill is bound (same tool, receipts and
projection), passes the call and result to the model as the turn's opening,
and gives step 0 the full tool set. Synthetic chunks go through the same loop
(case.loaded, activity and Skill receipt once each) but not into step timings.
The synthetic call carries a short reasoning line: thinking-mode DeepSeek
rejects a prior tool call without reasoning_content (checked live). read-case
stays callable for resume / full_diagnostics. Agents without listTools keep
the step-0 read. The Skill package has no "read first" text; prompts updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jesse_Chen
2026-10-28 14:27:27 +08:00
co-authored by Claude Opus 5.5
parent a614895a2b
commit 699e353887
9 changed files with 214 additions and 14 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 生时校正每一轮少等一次模型
- 生时校正每一轮不再先让模型花一次请求去读案例,由服务器直接读好交给模型,模型一开始就回应你。估计每轮快 3~8 秒;问答内容和流程不变。
- Skill 版本不变(BUG-1491,issue #136)。
## 2026-10-28 — 修正排盘服务同时算两张盘时偶发报错
- 上一版「同一份代码只留一份」在两张盘同时算(如合盘全档案)时,偶尔会拿到还没载入完的代码而报错;现在会等它载入完再用。结果不变。
+15
View File
@@ -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`。
+6
View File
@@ -46,3 +46,9 @@
22. 在档案页按刷新:列表和以前一样;改一个人的资料、添加一个人后,列表马上是新的。
23. 在合盘页按刷新:选人区、余额、历史报告照常;合盘服务关着时仍只显示「合盘服务暂未开放」,不闪选人区。
## #136 生时校正每一轮少等一次模型(BUG-1491)
24. 从首页进入生时校正,走完开场:开场的话和第一道题照常出来,比以前快一截(以前出字前会多停几秒)。
25. 说一件人生大事(带年月):照常回「记下了」并出下一题;进度、选项都正常。
26. 先说「先这样」暂停,再回来说「继续」:能接着校正,不会卡在「已暂停」。
@@ -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<Record<string, unknown>>;
}>;
}).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();
@@ -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,代表签名仅诊断,不能作实际采用盘。`,
@@ -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<unknown> };
/**
* 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<OpeningRead>) | null> {
const listTools = (agent as { listTools?: () => Promise<Record<string, unknown>> } | 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<T>(
opening: OpeningRead | null,
stream: AsyncIterable<T>,
): 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 };
}
+2 -2
View File
@@ -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}」,不得丢掉「不保证」和「不准」。搜索窗口只作次要信息。不说要把出生时间缩小到更准的范围,不写具体年份、不列例子、不要求准备材料;开场题干仍由服务器固定。
@@ -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(),
@@ -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<unknown>) {
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<typeof agent.stream>) => { 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/);
});