fix(consult): progress timeline shows only real steps, not the answer outline (BUG-1145)
Independent Staging Quality Gate / validate (push) Successful in 14m43s
Independent Staging Quality Gate / publish (push) Successful in 3m46s

The answer outline (think.plan / thinking.section) was drawn as four think
rows and ticked done as soon as the chart calculation started. The reducer
now keeps the outline only as calculate-row detail; live and history views
share it. Product chose option B on 2026-10-01.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-10-01 16:40:27 +08:00
co-authored by Claude Opus 5.5
parent f1a169cb93
commit c0f059125b
9 changed files with 195 additions and 43 deletions
+5
View File
@@ -1,5 +1,10 @@
# 印度占星 Skill 更新日志
## 2026-10-01 — 普通对话进度栏不再提前打勾
- 发问后进度栏不再一下子出现四行「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」并全部打勾(那时盘还没算完)。现在只显示真实发生的步骤:读取分析方法 → 计算本命盘 → 正在写(按正文实际写到的标题)→ 完成;打开历史对话也一样(BUG-1145)。
- Skill 不 bump;不改数据库结构;服务端事件不变。
## 2026-10-01 — 生时校正不再答两三题就提前结束;默认按「哪道题最能分开盘型」出题
- 修复:轮到「健康」方面的选择题时,这道题一直写不进去,系统就直接给出结果,后面还能问的题都不问了。修好后平均每次多问约 2 题(前后 10 分钟:2.8 → 4.6 题;前后 30 分钟:3.6 → 5.4 题),D9 / D10 判对的例数明显上升,没有一例把真实时间排除在外(BUG-1143)。
+16
View File
@@ -15376,3 +15376,19 @@
- 相关记录:BUG-1116、BUG-1143、BUG-1105。
- 复发自:无
- 修复版本:`codex/rectification-segment-order-20261001`。
## BUG-1145 | 普通对话进度栏提前打勾:回答提纲在算盘前就显示为「已完成」
- 状态:resolved(2026-10-01 Claude 直接执行;单测复现修前失败、修后通过;未部署时以部署后 health 为准)
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
- 影响面:`frontend/src/lib/consultation-run-timeline.ts`(普通对话进度栏的实时与历史重建共用同一个 reducer);生时校正进度栏不用这个 reducer,不受影响。
- 用户现象:发问后,「先回答你问的这件事」「把判断钉在盘上」「时间窗口怎么看」「这周可以做什么」四行立刻全部打勾,而这时「正在计算本命盘…」还在转,正文一个字都没写。
- 触发条件:任何本命普通对话首轮(通用 / 每日 / 申报时段的计划节同理)。
- 根因:服务端在第一个流块就下发回答提纲(`think.plan` + 每节一条 `thinking.section`);reducer 把每节做成一行 live 的 think 行,而 `completeLiveThink` 在下一个事件(下一节、`tool.started`)到来时把所有 live think 行标成完成。提纲是「回答将怎么写」,不是已走过的步骤,却按步骤渲染,于是算盘一开始四行就假完成。自 09-18 进度栏设计(`94c1e81f`)起即存在;BUG-1132 之后回答按人分段、「盘上依据」「时间怎么看」可省,提纲与真实正文进一步对不上。
- 修复:产品 10-01 选 B——提纲不再成行。`think.plan` 不改状态;`thinking.section` 只把领域与对照项暂存为计算行的明细(`calculateHints`),在计算行出现时补上(实时与历史一致);正文开始(`answer.delta`)时计算行收口为完成;历史重建的思考文本改走 `thinking.delta` 行,不再挂在提纲行上。服务端仍下发 `think.plan` / `thinking.section`(公共事件合同与存档字段不变,供 `applyThinkingSectionProgress` 等使用),客户端只是不再把它们画成行。
- 验证:`frontend/tests/consultation-run-timeline.test.ts` 新增三条(算盘前提纲不成行、只有「读取分析方法」完成;整轮结束无提纲行、无 live 行;历史重建与实时同形且计算行仍有明细),三条在修前代码上失败、修后通过;改写一条既有断言(三栏见测试注释)。
- 防复发:进度栏只画真实发生的步骤(方法、计算、写作、模型推理);回答提纲若要再上屏,必须由正文标题驱动、不能用「下一事件到来即完成」的规则。
- 相关记录:BUG-942~944(进度 / 思考 / 正文分通道)、BUG-1074(发出即显示排队行)、BUG-1132(按人分段)。
- 复发自:无
- 修复版本:`codex/consult-timeline-no-plan-20261001`。
@@ -0,0 +1,34 @@
# PROGRESS · 普通对话进度栏去掉回答提纲行(2026-10-01)
任务书:`docs/tasks/TASK-consult-timeline-no-plan-20261001.md`;BUG-1145。
## 实现
- `frontend/src/lib/consultation-run-timeline.ts`:
- `think.plan` 直接返回原状态(不再生成提纲行)。
- `thinking.section` 只把领域与对照项累积到新增的 `calculateHints`,经 `applyCalculateHints` 补到计算行;`tool.started` 建计算行时也补上(提纲在算盘前下发,原 `enrichCalculate` 在实时流里因计算行尚不存在而落空,历史重建里才有明细;现在两边一致)。
- `answer.delta` 同时把计算行收口为完成(原来由 `thinking.section` 顺手收口)。
- `consultationTimelineFromSettled`:有思考文本就发一条 `thinking.delta`,删掉「把思考文本挂到最后一条提纲行」的补丁。
- 服务端 `think.plan` / `thinking.section` 照发(公共事件合同、`consultation-agentic-runtime` 测试锁定 `think.plan` 存在;客户端已不再用它画行)。`thinkPlanSteps` 仍被服务端与测试使用,未删。
## 改动的断言(原值 / 新值 / 原因)
| 测试 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `consultation-run-timeline.test.ts` 首条(改名为 “…then the first write (no outline row)”) | 行种类 `["method","calculate","think","write"]`,第 3 行「先回答你问的这件事」已完成 | 行种类 `["method","calculate","write"]`,不存在该标签行;写作行下标 3→2 | BUG-1145:提纲不是步骤,产品选 B 去掉 |
新增三条:算盘前提纲不成行且只有方法行完成;整轮结束无 think 行、无 live 行;历史重建同形且计算行有明细。三条与改写的一条在修前代码上均失败(已实测:4 fail),修后通过。
## 门禁(Node 22.14)
| 项 | 基线 `f1a169cb` | 分支 |
| --- | --- | --- |
| tsc | — | 0 |
| lint | — | 0 error / 126 warning |
| npm test | 4,851 条,fail 24 | 4,854 条,fail 24(同名) |
| build `/` | — | Static |
| `.next/static` js gzip 合计 | 1,632,680 B | 1,632,662 B(−18 B) |
## 环境缺口
- 浏览器级(真机看进度栏)留给 `docs/testing/consult-plain-answer-20261001.md` 第 10 步。
+1
View File
@@ -400,3 +400,4 @@
- **实现合入 `staging` 的同一次推送里,必须同时把状态板那一行改掉。** 2026-09-16 的对账发现 10 份早已合入的单仍写着「待领取 / 待验收」,会导致重复派活。
| `TASK-consult-plain-answer-20261001.md` | `PROGRESS-consult-plain-answer-20261001.md` | 普通对话「还是废话」:09-17 四步开场形状(格局名 → 谁推谁修 → 扮演哪个象)逼出谜语,问父母时爸妈被揉成一段;产品授权推翻该形状,改成先答 + 按问题里的对象分段 + 人话自检 + 空宫不单独下结论 + 父母卡标 mother/father(BUG-1132~1134) | **已实现,待 Claude 验收**(fork 子代理直接执行);未推 staging、未部署;模型对比为环境缺口,真机清单 `docs/testing/consult-plain-answer-20261001.md` | 分支 `codex/consult-plain-answer-20261001` |
| `TASK-consult-answer-clock-20261001.md` | `PROGRESS-consult-answer-clock-20261001.md` | 普通对话答题时钟 70 s 容不下推理模型(v4-pro 77 s),无出生分钟路线只受 110 s 工具钟管;产品定 5 分钟防卡死(BUG-1142) | **已实现,待 Claude 验收**(直接执行) | 分支 `codex/consult-answer-clock-20261001` |
| `TASK-consult-timeline-no-plan-20261001.md` | `PROGRESS-consult-timeline-no-plan-20261001.md` | 普通对话进度栏提前打勾:回答提纲在算盘前就作为四行步骤显示并全部打勾(BUG-1145);产品选 B 去掉提纲行,只显示真实步骤 | **实现完成,待 Claude 验收**(直接执行) | 分支 `codex/consult-timeline-no-plan-20261001` |
@@ -0,0 +1,31 @@
# TASK · 普通对话进度栏去掉回答提纲行(2026-10-01)
- 基线:`origin/staging` `f1a169cb`
- 分支 / worktree:`codex/consult-timeline-no-plan-20261001` / `.worktrees/consult-timeline-no-plan-20261001`
- 模式:直接执行(产品 10-01 选 B 后明确要求直接做),Claude 派子代理实现、Claude 独立验收
- BUG:BUG-1145
## 事故实证
产品 10-01 真机截图:问「我跟我父母的关系怎么样」,进度栏「读取分析方法」之后立刻出现「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」四行且全部打勾,最下面「正在计算本命盘…」仍在转。
## 根因
`frontend/src/lib/stream-agent-response.ts` `flushThinkingPlan` 在第一个流块就下发回答提纲(`think.plan` + 每节 `thinking.section`);`frontend/src/lib/consultation-run-timeline.ts` `reduceConsultationTimeline` 把每节做成 live think 行,`completeLiveThink` 在下一个事件到来时把它们全标完成。提纲不是走过的步骤,却按步骤渲染。
## 决策记录
产品 10-01 在两案中选 **B:去掉提纲行,只显示真实步骤**(读取分析方法 → 计算本命盘 → 正在写 → 完成)。未选 A(提纲等算完再出现、按正文标题逐条打勾),原因:BUG-1132 之后回答按人分段、两节可省,提纲与正文对不上;产品偏好「多余入口宁可删除」。
## 硬红线
1. 只改渲染层(timeline reducer);服务端事件、`thinkingPlan`、`applyThinkingSectionProgress`、存档字段、API / DB 结构不动。
2. 生时校正进度栏不动(它不用这个 reducer,只共用行类型与组件)。
3. `page.tsx` 不增长;不加 spinner / 骨架。
## 任务与验收
- T1 reducer:`think.plan` 不改状态;`thinking.section` 只暂存计算行明细并在计算行出现时补上;正文开始时计算行收口。验收:新增回归测试在修前失败、修后通过。
- T2 历史重建与实时同形(`consultationTimelineFromSettled` 走同一 reducer;思考文本改走 `thinking.delta` 行)。验收:历史重建测试。
- T3 记录:BUG_HISTORY、CHANGELOG、DESIGN.md、真机清单第 10 步、本任务书与进度记录、索引。
- 门禁:tsc 0;lint 0 error;`npm test` 失败名单与基线同名;`next build` `/` Static;首屏 gzip ±2%。
@@ -13,3 +13,4 @@
| 7 | 第 1 步的回答如果明显被截断(最后一节没写完就停了) | 记下时间和模型名报给 Claude——这是取消字数上限后要盯的风险(见进度记录「长度副作用核对」) |
| 8 | (BUG-1142,答题时钟改 5 分钟后)选 `deepseek-v4-pro` 新开对话,问「我和父母关系如何,他们怎么对待我」 | 回答完整写到「这周可以做的一件事」,不出现「回答未完成」;记下从发出到写完大约多少秒 |
| 9 | 用没填出生分钟的人物档案、选 `deepseek-v4-pro` 问一个长问题(例如「完整讲讲我今年的事业和感情」) | 回答写完,不出现「回答未完成」 |
| 10 | (BUG-1145)新开对话问任一问题,盯住回答上方的进度栏 | 只出现「读取分析方法」「计算本命盘」「正在写…」这类真实步骤;不再出现「先回答你问的这件事 / 把判断钉在盘上 / 时间窗口怎么看 / 这周可以做什么」四行,也没有在算盘时就打上的勾;刷新后打开这条历史对话,进度栏一样 |
+1 -1
View File
@@ -482,7 +482,7 @@ The birth-time rectification session is the consultation transcript plus a house
#### Streaming states
Every assistant reply moves through the same states on both chat surfaces, and each state has exactly one visual. The step timeline (`ConsultationRunTimeline`) is the only activity surface. Consultation thinking is complete `think.step` items (v1 `thinking.delta` still renders for one release). Rectification never shows thinking text: provider reasoning is dropped at the public boundary.
Every assistant reply moves through the same states on both chat surfaces, and each state has exactly one visual. The step timeline (`ConsultationRunTimeline`) is the only activity surface. Its rows are only steps the run has actually taken — reading the method, calculating the chart, writing (one row per heading the answer has written), and model reasoning when present. The answer outline the server plans (`think.plan` / `thinking.section`) is never a row: it only details the calculate row (2026-10-01, BUG-1145; it used to show four outline rows that ticked done the moment the calculation started). Consultation thinking is complete `think.step` items (v1 `thinking.delta` still renders for one release). Rectification never shows thinking text: provider reasoning is dropped at the public boundary.
| State | When | Visible | Transition in |
|---|---|---|---|
+38 -35
View File
@@ -30,6 +30,12 @@ export type ConsultationTimelineRow = Readonly<{
export type ConsultationTimelineState = Readonly<{
rows: readonly ConsultationTimelineRow[];
answer: string;
/**
* What the run's planned sections say the calculation looks at, held until
* the calculate row exists: the plan is sent before the tool starts, and it
* only ever details that row (BUG-1145).
*/
calculateHints?: Readonly<{ queries: readonly string[]; sources: readonly string[] }>;
}>;
const METHOD_ID = "method";
@@ -55,12 +61,13 @@ export function reduceConsultationTimeline(
return completeRow(state, METHOD_ID, CONSULTATION_DONE_SKILL_LABEL);
}
if (event.type === "tool.started") {
return upsertRow(completeLiveThink(state), {
const created = upsertRow(completeLiveThink(state), {
id: CALCULATE_ID,
kind: "calculate",
status: "live",
label: event.label || CONSULTATION_CHART_CALCULATION_LABEL,
});
return applyCalculateHints(created);
}
if (event.type === "activity") {
if (event.phase === "chart-calculation") {
@@ -88,18 +95,12 @@ export function reduceConsultationTimeline(
if (event.type === "tool.failed") {
return completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
}
if (event.type === "think.plan") {
let next = completeLiveThink(state);
for (const step of event.steps) {
next = upsertRow(next, {
id: `think-${step.id}`,
kind: "think",
status: "live",
label: consultationThinkTitle(step.title),
});
}
return next;
}
// The answer outline (think.plan / thinking.section) is what the answer
// will be written as, not a step the run has taken, so it never becomes a
// row: it was shown up front and ticked done as soon as the chart calculation
// started, before a word of the answer existed (BUG-1145). The headings the
// answer actually writes show as write rows below.
if (event.type === "think.plan") return state;
if (event.type === "think.step") {
const id = `think-${event.id}`;
const existing = state.rows.find((row) => row.id === id);
@@ -127,14 +128,7 @@ export function reduceConsultationTimeline(
heading: event.heading,
steps: event.steps,
};
const withCalc = enrichCalculate(completeRow(state, CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL), section);
const withoutOpen = completeLiveThink(withCalc);
return upsertRow(withoutOpen, {
id: `think-${section.id}`,
kind: "think",
status: "live",
label: consultationThinkTitle(section.title),
});
return applyCalculateHints(addCalculateHints(state, section));
}
if (event.type === "thinking.delta") {
const think = lastRow(state.rows, (row) => row.kind === "think" && row.status === "live");
@@ -152,7 +146,11 @@ export function reduceConsultationTimeline(
});
}
if (event.type === "answer.delta") {
const next = { ...completeLiveThink(state), answer: event.replace ? event.text : `${state.answer}${event.text}` };
// Answer text only comes after the calculation, so the calculate row is
// done by now even if its own completion event has not been seen (BUG-1145:
// the planned sections used to close it).
const calculated = completeRow(completeLiveThink(state), CALCULATE_ID, CONSULTATION_DONE_CHART_LABEL);
const next = { ...calculated, answer: event.replace ? event.text : `${state.answer}${event.text}` };
return syncWriteRows(next, false);
}
if (event.type === "run.completed" || event.type === "run.failed") {
@@ -209,7 +207,7 @@ export function consultationTimelineFromSettled(input: {
for (const section of sections) {
events.push({ type: "thinking.section", ...section });
}
if (input.thinkingText?.trim() && sections.length === 0) {
if (input.thinkingText?.trim()) {
events.push({ type: "thinking.delta", text: input.thinkingText });
}
if (text) events.push({ type: "answer.delta", text });
@@ -228,14 +226,7 @@ export function consultationTimelineFromSettled(input: {
workflow: { route: "settled", status: "ready", preciseTiming: "blocked", missingLayers: [] },
},
});
let state = reduceConsultationTimelineEvents(events);
if (input.thinkingText?.trim() && sections.length > 0) {
const lastThink = [...state.rows].reverse().find((row) => row.kind === "think");
if (lastThink) {
state = upsertRow(state, { ...lastThink, thinkingText: input.thinkingText.slice(0, 4_000) });
}
}
return state.rows;
return reduceConsultationTimelineEvents(events).rows;
}
export function consultationThinkTitle(title: string): string {
@@ -390,13 +381,25 @@ function syncWriteRows(state: ConsultationTimelineState, settled: boolean): Cons
return next;
}
function enrichCalculate(
function addCalculateHints(
state: ConsultationTimelineState,
section: PublicThinkingSection,
): ConsultationTimelineState {
const hints = state.calculateHints;
return {
...state,
calculateHints: {
queries: uniqueLabels([...(hints?.queries ?? []), ...queriesFromSection(section)]).slice(0, 8),
sources: uniqueLabels([...(hints?.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8),
},
};
}
function applyCalculateHints(state: ConsultationTimelineState): ConsultationTimelineState {
const calculate = state.rows.find((row) => row.id === CALCULATE_ID);
if (!calculate) return state;
const queries = uniqueLabels([...(calculate.queries ?? []), ...queriesFromSection(section)]).slice(0, 8);
const sources = uniqueLabels([...(calculate.sources ?? []), ...sourcesFromSection(section)]).slice(0, 8);
const hints = state.calculateHints;
if (!calculate || !hints) return state;
const queries = uniqueLabels([...(calculate.queries ?? []), ...hints.queries]).slice(0, 8);
const sources = uniqueLabels([...(calculate.sources ?? []), ...hints.sources]).slice(0, 8);
return upsertRow(state, { ...calculate, queries, sources });
}
@@ -9,7 +9,7 @@ import {
reduceConsultationTimelineEvents,
} from "../src/lib/consultation-run-timeline.ts";
test("career and wealth events append method, calculate 1/2, think, then the first write", () => {
test("career and wealth events append method, calculate 1/2, then the first write (no outline row)", () => {
const [question] = natalConsultationThinkingPlan({ domains: ["career", "wealth"] });
assert.ok(question);
const afterProgress = reduceConsultationTimelineEvents([
@@ -39,13 +39,15 @@ test("career and wealth events append method, calculate 1/2, think, then the fir
{ type: "answer.delta", text: "外松内紧。\n## 盘里支持这个判断的地方\n事业宫被土星压着。\n" },
], afterProgress);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "think", "write"]);
// 原值: 行种类 ["method", "calculate", "think", "write"],第 3 行是计划节「先回答你问的这件事」且已勾
// 新值: 行种类 ["method", "calculate", "write"],计划节不成行;计算行由正文开始收口
// 原因: TASK-consult-timeline-no-plan-20261001(BUG-1145):回答提纲不是已走过的步骤,产品 10-01 选 B 去掉提纲行
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "write"]);
assert.equal(state.rows[1]?.status, "done");
assert.equal(state.rows[2]?.label, "先回答你问的这件事");
assert.equal(state.rows[2]?.status, "done");
assert.equal(state.rows[3]?.id, "write-盘里支持这个判断的地方");
assert.equal(state.rows[3]?.status, "live");
assert.match(state.rows[3]?.label ?? "", /正在写盘里支持这个判断的地方/);
assert.equal(state.rows.some((row) => row.label === "先回答你问的这件事"), false);
assert.equal(state.rows[2]?.id, "write-盘里支持这个判断的地方");
assert.equal(state.rows[2]?.status, "live");
assert.match(state.rows[2]?.label ?? "", /正在写盘里支持这个判断的地方/);
});
test("thinking after a tool starts a new think row instead of reopening the first", () => {
@@ -107,3 +109,62 @@ test("settled natal messages hydrate a calculate row from domain sections", () =
const calculate = rows.find((row) => row.kind === "calculate");
assert.ok(calculate?.queries?.some((item) => item.includes("事业") || item.includes("D10") || item.includes("财富")));
});
// BUG-1145 (TASK-consult-timeline-no-plan-20261001): the answer outline was
// shown as four think rows and ticked done the moment the calculation started.
test("the answer outline sent before the calculation adds no row and ticks nothing", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const state = reduceConsultationTimelineEvents([
{ type: "run.started", runId: "run-1", requestId: "req-1" },
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
{ type: "think.plan", steps: plan.map((section) => ({ id: section.id, title: section.title })) },
...plan.map((section) => ({ type: "thinking.section" as const, ...section })),
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算本命盘…" },
]);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate"]);
assert.deepEqual(state.rows.filter((row) => row.status === "done").map((row) => row.kind), ["method"]);
assert.equal(state.rows[1]?.status, "live");
for (const section of plan) {
assert.equal(state.rows.some((row) => row.label === section.title), false, section.title);
}
// The outline still details what the calculation looks at.
assert.ok((state.rows[1]?.queries?.length ?? 0) + (state.rows[1]?.sources?.length ?? 0) > 0);
});
test("a finished run leaves no outline row and nothing live", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const state = reduceConsultationTimelineEvents([
{ type: "skill.started", name: "jyotish-vedic-astrology" },
{ type: "skill.completed", name: "jyotish-vedic-astrology" },
{ type: "think.plan", steps: plan.map((section) => ({ id: section.id, title: section.title })) },
...plan.map((section) => ({ type: "thinking.section" as const, ...section })),
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算本命盘…" },
{ type: "answer.delta", text: "你和父母不疏远。\n## 你和妈妈\n她操心多。\n## 这周可以做的一件事\n- 先问她最近怎样。\n" },
{
type: "run.completed",
receipt: {
runId: "run-1",
runtime: "mastra-agentic",
skill: { name: "jyotish-vedic-astrology", loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [],
workflow: { route: "parents", status: "ready", preciseTiming: "blocked", missingLayers: [] },
},
},
]);
assert.deepEqual(state.rows.map((row) => row.kind), ["method", "calculate", "write", "write"]);
assert.equal(state.rows.every((row) => row.status === "done"), true);
assert.equal(state.rows.some((row) => row.kind === "think"), false);
});
test("a stored natal answer rebuilds the same rows as live: no outline rows", () => {
const plan = natalConsultationThinkingPlan({ domains: ["parents"] });
const rows = consultationTimelineFromSettled({
text: "你和父母不疏远。\n## 你和妈妈\n她操心多。\n## 你和爸爸\n他话少。\n",
thinkingSections: plan,
});
assert.deepEqual(rows.map((row) => row.kind), ["method", "calculate", "write", "write"]);
assert.equal(rows.some((row) => row.kind === "think"), false);
assert.equal(rows.every((row) => row.status === "done"), true);
assert.ok((rows[1]?.queries?.length ?? 0) + (rows[1]?.sources?.length ?? 0) > 0);
});