mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(chat): keep each folded run's own clock (#7921)
* fix(chat): keep each folded run's own clock
A logical OD Next task runs as several physical Runs, and reopening a
conversation folds them into one turn. The fold keeps one message row, so
only Run 0's createdAt and Run N's endedAt survive and every Run boundary
in between is discarded. The renderer is then left with one clock for N
Runs, which shows up as two separate reports:
- a Run's shell with no timestamped events of its own (clarification
Runs typically have none, and the plain-stream agent family never
emits any) reaches neither the "opened this round" nor the "closed
this round" fallback, so its head is a bare "Done" with no duration;
- a thinking gap, inferred from "last timestamped event until the next
one", runs straight across the Run boundary and charges earlier Runs'
wall-clock time - including the minutes the user spent answering a
question form - to the current Run's Thoughts row, which then reports
more than the card head containing it.
Both are reload-only: strategyTaskRunIndex is served by the history
endpoint alone, so the same conversation renders correctly while live and
loses the numbers once reopened.
Restore the lost boundary rather than adjust either number. The fold
stamps each Run's own span onto the done_key it already emits, and the
renderer scopes the shell clock and the thinking gap to that Run. On the
recorded three-Run conversation the reloaded turn now reports 1m 20s /
2m 39s / 4m 42s and a 2m 57s Thoughts row - identical, to the millisecond,
to what the same Runs report live.
Also make a card's span cover the thinking row it credits, so the same
invariant holds for a second card opened mid-Run after a done marker.
OPEND-2823, OPEND-2824
* fix(chat): stop a card borrowing another run's clock
Review of #7921 caught the other half of the same defect. shellElapsed
still seeded from/to from the turn-global firstStartedAt/lastEndedAt when
a shell had no shellSpan of its own, then unioned those leftovers with the
new per-run bounds. A folded successor run that emits only status and text
- the typical clarification run, and every plain-stream agent - has no
shellSpan, so its card picked up the preceding run's tool timestamps and
reported that run's time as its own.
So the first commit fixed "a middle run shows no number" but left "the
number belongs to another run", behind a different trigger.
Drop the turn-global seed instead of clearing those clocks in closeRun().
firstStartedAt/lastEndedAt are the min/max of every timestamp in the turn,
so when this shell has no span of its own any value there was necessarily
stamped by a different shell: the fallback is, by construction, always
borrowing another card's clock and can never once be this card's own time.
Clearing at the run boundary would also only cover the cross-run trigger -
the identical borrow happens inside a single run when a second card opens
after a done marker with no timestamped events, where closeRun() never
runs - and it would silently change thinkGapStart and shellQuiet, which
read the same two clocks.
On the review's shape a card that ran for 20s reported 5m 10s; it now
reports 20s, which is what the same run reports live.
OPEND-2823, OPEND-2824
* docs(contracts): clarify missing folded-run timing boundaries
This commit is contained in:
@@ -1040,6 +1040,14 @@ interface QueuedSendUpdate {
|
||||
* folded. Every continuation's content, events and produced files are appended
|
||||
* to the turn's first message in Run order, so nothing is dropped and nothing
|
||||
* is duplicated.
|
||||
*
|
||||
* ⚠️ The turn keeps ONE message row, so it can carry only one `createdAt` and
|
||||
* one `endedAt` — the head's start and the tail's end. Every Run boundary in
|
||||
* between used to die here, and the renderer's clocks died with it
|
||||
* (OPEND-2823 / OPEND-2824; the full causal chain is on
|
||||
* `PersistedAgentEvent`'s `done_key.runStartedAt`). So each Run's own span is
|
||||
* stamped onto the `done_key` it already emits — the very event the renderer
|
||||
* uses to find the boundary — before its events are appended.
|
||||
*/
|
||||
export function foldStrategyTaskTurns(messages: ChatMessage[]): ChatMessage[] {
|
||||
if (!messages.some((message) => (message.strategyTaskRunIndex ?? 0) > 0)) {
|
||||
@@ -1056,7 +1064,7 @@ export function foldStrategyTaskTurns(messages: ChatMessage[]): ChatMessage[] {
|
||||
}
|
||||
if (runIndex === 0 || !turnHeadIndexByTask.has(taskId)) {
|
||||
turnHeadIndexByTask.set(taskId, folded.length);
|
||||
folded.push(message);
|
||||
folded.push({ ...message, events: stampRunSpan(message) });
|
||||
continue;
|
||||
}
|
||||
const headIndex = turnHeadIndexByTask.get(taskId)!;
|
||||
@@ -1068,7 +1076,7 @@ export function foldStrategyTaskTurns(messages: ChatMessage[]): ChatMessage[] {
|
||||
content: tailContent
|
||||
? `${headContent}${headContent && !headContent.endsWith('\n') ? '\n\n' : ''}${tailContent}`
|
||||
: headContent,
|
||||
events: [...(head.events ?? []), ...(message.events ?? [])],
|
||||
events: [...(head.events ?? []), ...stampRunSpan(message)],
|
||||
producedFiles: [...(head.producedFiles ?? []), ...(message.producedFiles ?? [])],
|
||||
// The turn's status is the latest Run's: the earlier Runs finishing is an
|
||||
// internal step, not the turn ending.
|
||||
@@ -1088,6 +1096,32 @@ export function foldStrategyTaskTurns(messages: ChatMessage[]): ChatMessage[] {
|
||||
return folded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write this Run's own wall-clock span onto the `done_key` it already carries.
|
||||
*
|
||||
* The message row is about to be merged away, and with it the only record of
|
||||
* when THIS Run started and ended. `done_key` is emitted once per Run and is
|
||||
* where the renderer already splits Runs apart, so the span rides along with
|
||||
* the boundary it belongs to instead of needing a channel of its own.
|
||||
*
|
||||
* A Run still in flight has no `endedAt`; a Run recorded before `done_key`
|
||||
* existed has no marker to stamp. Both simply keep today's behaviour — the
|
||||
* renderer treats an absent span as "unknown" and falls back to the turn's.
|
||||
*/
|
||||
function stampRunSpan(message: ChatMessage): NonNullable<ChatMessage['events']> {
|
||||
const events = message.events ?? [];
|
||||
if (message.createdAt == null && message.endedAt == null) return events;
|
||||
return events.map((event) => (
|
||||
event.kind === 'done_key'
|
||||
? {
|
||||
...event,
|
||||
...(message.createdAt != null ? { runStartedAt: message.createdAt } : {}),
|
||||
...(message.endedAt != null ? { runEndedAt: message.endedAt } : {}),
|
||||
}
|
||||
: event
|
||||
));
|
||||
}
|
||||
|
||||
function shouldHideEmptyBrandAssistantMessage(message: ChatMessage, metadata?: ProjectMetadata): boolean {
|
||||
if (metadata?.importedFrom !== 'brand-extraction' && metadata?.kind !== 'brand') return false;
|
||||
if (message.role !== 'assistant') return false;
|
||||
|
||||
@@ -95,7 +95,20 @@ const ABANDON_NAME_RE = /(^|__)todo_abandon$/i;
|
||||
interface RawTodo { content: string; status: string }
|
||||
|
||||
/** 还开着的那一段推理:它自己、它落在哪个数组里、它从哪一刻开始填空 */
|
||||
interface OpenThink { item: ShellText; arr: ShellItem[]; from: number | null }
|
||||
interface OpenThink {
|
||||
item: ShellText;
|
||||
arr: ShellItem[];
|
||||
from: number | null;
|
||||
/**
|
||||
* 这段推理落在**哪张卡 / 哪条 todo** 名下 —— 认领成功时要把这一截空白记进它们的
|
||||
* 跨度,否则卡头会小于卡里那一行(`closeThink` 里的原委)。
|
||||
*
|
||||
* 开表那一刻取,不在结账时再问一次:那时候 `activeShell()` / `current` 可能已经
|
||||
* 换人了(下一份清单一落就换),记到别人头上比不记更糟。
|
||||
*/
|
||||
shell: ExecutionShell | null;
|
||||
seg: TodoSegment | null;
|
||||
}
|
||||
|
||||
function readTodoList(input: unknown): RawTodo[] {
|
||||
const rec = input && typeof input === 'object' ? (input as Record<string, unknown>) : {};
|
||||
@@ -489,20 +502,44 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
|
||||
let shellSeq = 0;
|
||||
/**
|
||||
* 这一轮开过的**每一张**壳,按开壳先后 —— 也就是它们在 `blocks` 里的先后。
|
||||
* **当前这个 run** 开过的每一张壳,按开壳先后。每道 run 边界清空一次。
|
||||
*
|
||||
* 收尾原来是 `for (const shell of [top, todoCard])`,写死了「一轮最多两张壳」。
|
||||
* 折叠轮次(`foldStrategyTaskTurns`)把 N 个物理 run 接成一条事件流之后这不再成立:
|
||||
* 每个 run 开自己的壳,先跑完的那几张也得各自定住状态与秒数,否则它们会永远
|
||||
* 停在「进行中」并跟着当前这一轮一起转圈。
|
||||
*
|
||||
* 这里原来攒的是**整条流**的每一张壳(`allShells`),秒表的「开这一轮的那张壳」
|
||||
* 于是读到了整条流的第一张。而那两条边界规则(见 `shellElapsed`)说的一直是
|
||||
* 「开**这一轮**」「收**这一轮**」,折叠视图里的「一轮」就是一个物理 run ——
|
||||
* 读成整条流之后,中间那几个 run 的壳两头都够不着,而澄清 run 恰恰常常一个带时刻的
|
||||
* 事件都没有:兜底一失效,壳头就只剩光秃秃一句「已完成」(OPEND-2823)。
|
||||
* 收窄到 run 之后整条流的那份名单没有第二个读者,所以不再攒。
|
||||
*/
|
||||
const allShells: ExecutionShell[] = [];
|
||||
let runShells: ExecutionShell[] = [];
|
||||
const nextShell = (): ExecutionShell => {
|
||||
shellSeq += 1;
|
||||
const shell = makeShell(shellSeq);
|
||||
allShells.push(shell);
|
||||
runShells.push(shell);
|
||||
return shell;
|
||||
};
|
||||
/**
|
||||
* **当前这个 run 自己的起止** —— 折叠视图里唯一说得出 run 边界的东西。
|
||||
*
|
||||
* 出处是 `done_key` 上的 `runStartedAt` / `runEndedAt`:折叠把 N 条消息并成一条,
|
||||
* 每个 run 自己的 `createdAt` / `endedAt` 就此消失,所以 `foldStrategyTaskTurns`
|
||||
* 在拼接之前把它盖在这个 run 的 `done_key` 上(那正是这里认 run 边界用的同一枚事件)。
|
||||
*
|
||||
* 没折叠的一轮只有一个 run,这两个值就是轮次自己的起止 —— 与从前逐字相同。
|
||||
* 后继 run 拿不到盖章时留 `null`:那是「不知道」,不是「等于轮次开头」——
|
||||
* 拿轮次开头顶上会把前面几个 run 的时间算进它名下,正是这次要修的那种谎报。
|
||||
*/
|
||||
let runStartedAt: number | null = input.startedAtMs ?? null;
|
||||
/**
|
||||
* `null` = 这个 run 还没盖过章。收尾那一个 run 由 `finishTurn` 兜到轮次收尾
|
||||
* (折叠时轮次收尾就是最后一个 run 的收尾),中途结束的那几个只能靠盖章。
|
||||
*/
|
||||
let runEndedAt: number | null = null;
|
||||
|
||||
let top: ExecutionShell | null = null;
|
||||
let todoCard: ExecutionShell | null = null;
|
||||
@@ -660,6 +697,27 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
return gapLanded.every((landed) => landed === open.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一段推理从哪一刻开始「填空」。
|
||||
*
|
||||
* 上一件带时刻的事结束的那一刻(`lastEndedAt`),一件都还没有就是这个 run 的开头。
|
||||
*
|
||||
* ⚠️ **不许早于这个 run 的开头**(OPEND-2824)。`lastEndedAt` 是**全轮共用**的,
|
||||
* 而折叠视图里一条流装着 N 个 run:后继 run 的头一段推理照原样取,起点会落在
|
||||
* **上一个 run** 最后一件事上,于是把 run 之间的墙上时间 —— 包括用户盯着
|
||||
* `<question-form>` 想答案的那几分钟 —— 全部算成「模型在想」。真机语料里
|
||||
* run 2 的第一格思考因此从 2m 57s 涨成 7m 2s,而它所在那张卡的头只写 1m 45s:
|
||||
* 卡里那一行比卡头还大,正是用户截图里那一幕。
|
||||
*
|
||||
* 那几分钟里**没有任何模型在跑**,所以这不是「两个数分属不同范围、标清楚就行」,
|
||||
* 是一段时间被记到了错的账上 —— 夹在两个 run 之间的空白谁也不领。
|
||||
*/
|
||||
function thinkGapStart(): number | null {
|
||||
if (lastEndedAt == null) return runStartedAt;
|
||||
if (runStartedAt == null) return lastEndedAt;
|
||||
return Math.max(lastEndedAt, runStartedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给还开着的那段推理结账:它填掉的空白到 `at` 为止。
|
||||
*
|
||||
@@ -682,8 +740,28 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
const prev = thinkMs.get(open.item);
|
||||
if (prev === null) return; // 已经作废,不再往上加
|
||||
if (!ownsGap(open)) { thinkMs.set(open.item, null); return; }
|
||||
const ms = open.from == null ? -1 : at - open.from;
|
||||
thinkMs.set(open.item, ms < 0 ? null : (prev ?? 0) + ms);
|
||||
const from = open.from;
|
||||
if (from == null || at < from) { thinkMs.set(open.item, null); return; }
|
||||
thinkMs.set(open.item, (prev ?? 0) + (at - from));
|
||||
/*
|
||||
* **记在行上的这一截,同时要记进它所在的那张卡**(OPEND-2824)。
|
||||
*
|
||||
* 壳自己的跨度只由带时刻的事件撑开,而推理一个时刻都不带。整轮第一张 / 最后一张
|
||||
* 壳靠 run 的起止兜住了两头(见 `shellElapsed`),但一个 run 中途另起的那张卡
|
||||
* 兜不住:它的表从**它自己第一件带时刻的事**开始走,而落在它头上的那段推理比
|
||||
* 这更早 —— 于是卡头写 10s,卡里那一行写 4m。
|
||||
*
|
||||
* 这里补的不是一个数,是一条归属:这段空白既然已经被判给了卡里的这一行,
|
||||
* 它就在这张卡的名下,卡的跨度本来就该覆盖它。判据与行用的是**同一份证据**
|
||||
* (同一个 `from` / `at`),所以不会出现「卡头比行大一点点」这种为了好看凑出来的数。
|
||||
*
|
||||
* 只在**认领成功**的这一支补。空白不是它一个人的(`ownsGap` 为假)时那一行本来
|
||||
* 就不报数,没有需要被覆盖的东西,补了反而是替一段无主的时间找了个卡。
|
||||
*/
|
||||
widen(shellSpan, open.shell, from);
|
||||
widen(shellSpan, open.shell, at);
|
||||
widen(segSpan, open.seg, from);
|
||||
widen(segSpan, open.seg, at);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -789,6 +867,21 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
// 后面那个 run 的 `<od-done key="…"/>` 会对不上,整段结论留在壳里
|
||||
if (started) closeRun();
|
||||
runKey = key;
|
||||
/*
|
||||
* 新的 run = 新的钟。先清零再认盖章:拿不到盖章时留 `null`,让秒表
|
||||
* 退回「只认事件时刻」,而不是继承上一个 run 的边界(那会把别人的时间
|
||||
* 记在这个 run 头上)。
|
||||
*/
|
||||
runShells = [];
|
||||
runStartedAt = null;
|
||||
runEndedAt = null;
|
||||
}
|
||||
if (key) {
|
||||
// 折叠视图盖上来的那个 run 自己的起止(见 contracts 的 `done_key.runStartedAt`)。
|
||||
// 头一个 run 的这枚 `done_key` 不换密钥,但同样带着章 —— 它把轮次起止收窄成
|
||||
// 这个 run 自己的起止,这正是折叠之后 run 0 需要的那口钟。
|
||||
if (event.runStartedAt != null) runStartedAt = event.runStartedAt;
|
||||
if (event.runEndedAt != null) runEndedAt = event.runEndedAt;
|
||||
}
|
||||
}
|
||||
// `done_key` 是协议元数据,不是这一轮的内容 —— 在 ensureShell 之前跳掉,
|
||||
@@ -857,7 +950,7 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
// 在循环体里会把它窄化成 `null`(和上面 `openText` 同一个坑)。显式断开窄化。
|
||||
const think = openThink as OpenThink | null;
|
||||
if (segment?.thinking && think?.item !== segment) {
|
||||
openThink = { item: segment, arr, from: lastEndedAt ?? input.startedAtMs ?? null };
|
||||
openThink = { item: segment, arr, from: thinkGapStart(), shell: activeShell(), seg: current };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1322,13 +1415,24 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
*/
|
||||
function closeRun(): void {
|
||||
liftConclusion();
|
||||
// 这个 run 的最后一段推理走到它自己最后一件带时刻的事为止,不跨到下一个 run
|
||||
if (lastEndedAt != null) closeThink(lastEndedAt);
|
||||
/*
|
||||
* 这个 run 的最后一段推理走到**它这个 run 的收尾**为止,不跨到下一个 run。
|
||||
* 盖了章就用章上那一刻(比事件时刻更准:澄清 run 常常一个带时刻的事件都没有);
|
||||
* 没盖章才退回它最后一件带时刻的事。
|
||||
*/
|
||||
const thinkEnd = runEndedAt ?? lastEndedAt;
|
||||
if (thinkEnd != null) closeThink(thinkEnd);
|
||||
openThink = null;
|
||||
// 这个 run 收尾的那张壳 —— 和 `finishTurn` 里的 `lastShell` 是同一句话
|
||||
const lastRunShell = todoCard ?? top;
|
||||
// `todoCard` 常常就是 `top` 本人(D50 之后清单不另起卡),去重再收
|
||||
for (const shell of new Set([top, todoCard])) {
|
||||
if (!shell) continue;
|
||||
shell.elapsedMs = shellElapsed(false, shell, shell === allShells[0], false);
|
||||
shell.elapsedMs = shellElapsed(
|
||||
shell,
|
||||
shell === runShells[0] ? runStartedAt : null,
|
||||
shell === lastRunShell ? runEndedAt : null,
|
||||
);
|
||||
shell.quietMs = null;
|
||||
if (shell.status === 'running') shell.status = 'done';
|
||||
closeRunningSegments(shell);
|
||||
@@ -1400,18 +1504,33 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
* `todoCard` 常常就是 `top` 本人(D50 之后清单不另起卡),所以要去重再数。
|
||||
*/
|
||||
/*
|
||||
* 「开这一轮的那张壳」是**整轮**的第一张,不是当前这个 run 的第一张 ——
|
||||
* 折叠轮次里 `top` 已经换过好几茬了(见 `closeRun`),拿它当第一张的话,
|
||||
* 最后那个 run 的壳会把表拨回轮次开头,把前面几个 run 的时间也算进自己名下。
|
||||
* 「开这一轮的那张壳」是**当前这个 run** 的第一张。
|
||||
*
|
||||
* 这里原来读的是 `allShells[0]`(整轮第一张),配的是「轮次开头」那个起点 ——
|
||||
* 两者本来就是一对:折叠之前一轮只有一个 run,整轮第一张壳就是这个 run 的第一张,
|
||||
* 轮次开头就是这个 run 的开头。折叠之后这一对同时失真:后继 run 的第一张壳
|
||||
* 拿不到任何起点(壳头于是没有秒数,OPEND-2823),而「轮次开头」也早已不是它的开头
|
||||
* (借给它就是把前面几个 run 的时间算进它名下)。现在两边一起收窄到 run 自己那口钟
|
||||
* (`runShells` / `runStartedAt`),没有折叠的一轮逐字等价于从前。
|
||||
*/
|
||||
const firstShell = allShells[0] ?? null;
|
||||
const firstShell = runShells[0] ?? null;
|
||||
const lastShell = todoCard ?? top;
|
||||
|
||||
for (const shell of [top, todoCard]) {
|
||||
if (!shell) continue;
|
||||
// 只有**还在跑的那张**跟着 now 走;先结束的那张定在自己的最后一刻
|
||||
const live = running && shell === activeShell();
|
||||
shell.elapsedMs = shellElapsed(live, shell, shell === firstShell, shell === lastShell);
|
||||
/*
|
||||
* 收这个 run 的那张壳走到 run 收尾为止 —— 跑着的时候「收尾」就是现在。
|
||||
* 收尾那一个 run 没有后继来给它盖章,而轮次收尾就是它的收尾(折叠取的正是
|
||||
* 最后一个 run 的 `endedAt`),所以这里兜一手 `input.endedAtMs`。
|
||||
*/
|
||||
const closeTo = live ? (input.nowMs ?? null) : (runEndedAt ?? input.endedAtMs ?? null);
|
||||
shell.elapsedMs = shellElapsed(
|
||||
shell,
|
||||
shell === firstShell ? runStartedAt : null,
|
||||
shell === lastShell ? closeTo : null,
|
||||
);
|
||||
shell.quietMs = shellQuiet(live, shell);
|
||||
}
|
||||
|
||||
@@ -1486,29 +1605,62 @@ export function buildTurnBlocks(input: BuildTurnInput): TurnBlock[] {
|
||||
* 两张壳的那一轮仍然分得开:第一张拿轮次**开头**、最后一张拿轮次**收尾**,
|
||||
* 中间那道缝(卡外那段结论)谁也不领。两张写上同一个数的 T34 坏画面
|
||||
* (`chat-panel-feedback.md`「被推翻的两条」)因此在结构上就出不来。
|
||||
*
|
||||
* ── 「这一轮」= 一个物理 run,不是折起来的那整条流(OPEND-2823)────────────
|
||||
*
|
||||
* 上面那条不变量一个字没改,改的是**谁来当这一轮**。两个边界现在由调用处按
|
||||
* **run** 算好了递进来(`openFrom` / `closeTo`),这里只负责把它们并进跨度。
|
||||
*
|
||||
* 为什么必须收窄:`foldStrategyTaskTurns` 在重新打开历史时把 N 个物理 run 接成
|
||||
* 一条流,而这两条边界原来读的是**整轮**的第一张壳与整轮收尾。于是中间那几个 run
|
||||
* 的壳两头都够不着 —— 偏偏澄清 run 常常一个带时刻的事件都没有(plain-stream 那一族
|
||||
* 整轮如此),兜底一失效,`from` / `to` 有一头是 null,壳头就只剩光秃秃一句
|
||||
* 「已完成」。真机语料 `odnext-parchment.reload.json` 三个 run:live 时报
|
||||
* 1m 20s / 2m 39s / 4m 42s,重新打开之后变成 (无) / (无) / 1m 45s。
|
||||
*
|
||||
* 反过来也不能把整轮的边界**借**给后继 run 的壳:那等于把前面几个 run 的时间
|
||||
* 算进它名下,是同一个谎报换个方向。拿不到 run 自己的边界时就传 `null`,
|
||||
* 退回「只认事件时刻」—— 不知道就是不知道(§2.2b)。
|
||||
*/
|
||||
function shellElapsed(
|
||||
running: boolean,
|
||||
shell: ExecutionShell | undefined,
|
||||
isFirst: boolean,
|
||||
isLast: boolean,
|
||||
/** 这张壳开了它那个 run 时,那个 run 的起点;否则 `null` */
|
||||
openFrom: number | null,
|
||||
/** 这张壳收了它那个 run 时,那个 run 的终点(还在跑就是「现在」);否则 `null` */
|
||||
closeTo: number | null,
|
||||
): number | null {
|
||||
const span = shell ? shellSpan.get(shell) : undefined;
|
||||
let from = span ? span.from : firstStartedAt;
|
||||
let to = span ? span.to : lastEndedAt;
|
||||
|
||||
// 开这一轮的那张壳:表从轮次开头就开始走(第一个工具之前的推理在这一截里)
|
||||
if (isFirst && input.startedAtMs != null) {
|
||||
from = from == null ? input.startedAtMs : Math.min(from, input.startedAtMs);
|
||||
}
|
||||
/*
|
||||
* 收这一轮的那张壳:走到轮次收尾为止 —— 跑着的时候「收尾」就是现在,秒表继续走。
|
||||
* `running` 只可能落在最后一张壳上(调用处的 `live` 判据是 `shell === activeShell()`,
|
||||
* 而 `activeShell()` 就是这里的最后一张),所以秒表不需要另开一条分支。
|
||||
* **只认这张壳自己的事件。**
|
||||
*
|
||||
* 这里原来在拿不到 `shellSpan` 时回落到 `firstStartedAt` / `lastEndedAt`,而那两个
|
||||
* 是**全轮所有时刻**的 min / max。既然这张壳没有自己的跨度,那两个值里但凡有数,
|
||||
* 就必然是**别的壳**盖出来的 —— 所以那条兜底在构造上等价于「借另一张卡的表」,
|
||||
* 不存在它恰好等于本张卡的情形。
|
||||
*
|
||||
* 借来的表怎么变成用户看得见的错(评审 #7921):一个只有 status / text、一个带时刻的
|
||||
* 事件都没有的后继 run(澄清 run 的典型形态),它那张壳捡起前一个 run 的工具时刻,
|
||||
* 再和自己的 run 边界取 min / max —— 真机形状的语料里,一张只跑了 20 秒的卡
|
||||
* 因此写成 5m 10s,多出来的五分钟是前一个 run 加上用户读产出、想怎么回话的那一段。
|
||||
*
|
||||
* 为什么不是「在 `closeRun()` 里把那两个时钟清零」:那只堵住跨 run 这一个入口。
|
||||
* 同一段代码在**一个 run 之内**同样会借 —— 一轮里 done 之后另起第二张卡、而那张卡
|
||||
* 没有带时刻的事件时,`closeRun()` 根本不会跑。而且那两个时钟另有两位读者
|
||||
* (`thinkGapStart` / `shellQuiet`),在边界上清零会顺手改掉它们的语义,
|
||||
* 那是副作用不是修复。
|
||||
*
|
||||
* 拿不到就返回 null —— 不知道就是不知道(§2.2b),和本文件其它几处同一条纪律。
|
||||
*/
|
||||
const turnEnd = running ? input.nowMs : input.endedAtMs;
|
||||
if (isLast && turnEnd != null) {
|
||||
to = to == null ? turnEnd : Math.max(to, turnEnd);
|
||||
const span = shell ? shellSpan.get(shell) : undefined;
|
||||
let from = span ? span.from : null;
|
||||
let to = span ? span.to : null;
|
||||
|
||||
// 开这个 run 的那张壳:表从 run 开头就开始走(第一个工具之前的推理在这一截里)
|
||||
if (openFrom != null) {
|
||||
from = from == null ? openFrom : Math.min(from, openFrom);
|
||||
}
|
||||
// 收这个 run 的那张壳:走到 run 收尾为止 —— 跑着的时候「收尾」就是现在,秒表继续走
|
||||
if (closeTo != null) {
|
||||
to = to == null ? closeTo : Math.max(to, closeTo);
|
||||
}
|
||||
if (from == null || to == null) return null;
|
||||
const ms = to - from;
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* 红测:**折叠轮次里,每个物理 run 的钟必须是它自己的那一口**(OPEND-2823 / OPEND-2824)。
|
||||
*
|
||||
* ## 两张单是同一个真因
|
||||
*
|
||||
* `foldStrategyTaskTurns` 在**重新打开历史**时把 N 个物理 run 接成一条消息,却只留
|
||||
* 了 run 0 的 `createdAt` 和最后一个 run 的 `endedAt` —— **中间每一道 run 边界连同
|
||||
* 每个后继 run 自己的起点,全被丢掉了**。于是 `buildTurnBlocks` 手上只有**一口钟**
|
||||
* 要给 N 个 run 用,两张单各是这口钟的一头:
|
||||
*
|
||||
* · OPEND-2823 「已完成旁边没有耗时」——
|
||||
* 壳头耗时的兜底(`shellElapsed` 里的轮次起止)挂在「整轮第一张壳 / 整轮最后一张
|
||||
* 壳」上。折叠之后,中间那几个 run 的壳两头都够不着,而**它们恰恰是最需要兜底
|
||||
* 的那一批**:澄清 run 通常只说一段话,一个带时刻的事件都没有(plain-stream 那
|
||||
* 一族整轮如此)。两头都取不到 → `elapsedMs` 恒为 null → 壳头只剩光秃秃一句
|
||||
* 「已完成」。
|
||||
*
|
||||
* · OPEND-2824 「进行中的总耗时小于下面的思考耗时」——
|
||||
* 推理没有自己的时刻,只能靠「它填掉了哪一段空白」反推,而那段空白的起点
|
||||
* `lastEndedAt` 是**全轮共用**的。折叠之后它直接跨过 run 边界,把前面几个 run
|
||||
* 的墙上时间(含用户盯着 `<question-form>` 想答案的那几分钟)一并算进当前 run
|
||||
* 的一格「思考过程」里;壳头却仍只报自己这一个 run。于是卡里那一行比卡头还大。
|
||||
*
|
||||
* ## 判据:同一条会话,折叠前后必须是同一批数字
|
||||
*
|
||||
* 这不是新发明的口径,是这个仓库自己早就写下的那条等式的延伸 ——
|
||||
* `tests/components/chat/odnext-reload-run-boundaries.test.tsx` 已经钉住
|
||||
* 「折起来那一条算出的**块序** === 三个 run 各自算出的块序首尾相接」。
|
||||
* 折叠是**视图层的拼接**,那么块上的**耗时**同样不该因为拼接而变。
|
||||
*
|
||||
* 语料是真机那一条会话的原始字节(`fixtures/chat/odnext-parchment.reload.json`,
|
||||
* 直接从用户库里的 `messages` 行导出),不是照着形状重打的夹具。三个 run 的真实跨度
|
||||
* 分别是 1m 20s / 2m 39s / 4m 42s,库里逐字记着。
|
||||
*
|
||||
* ⚠️ **不许靠调数字收口。** 提单人明确写过:两个值若分属不同轮次或统计范围,要把范围
|
||||
* 说清楚,而不是把小的那个改大。这里的修法是**把丢掉的 run 边界找回来**,让两个数
|
||||
* 从一开始就落在同一个范围里 —— 修完之后 reload 的每一个数都等于 live 的那一个,
|
||||
* 一个都没有被「调整」过。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ChatMessage, PersistedAgentEvent } from '@open-design/contracts';
|
||||
import { foldStrategyTaskTurns } from '../../../src/components/ChatPane';
|
||||
import { buildTurnBlocks } from '../../../src/runtime/chat/build-turn-blocks';
|
||||
import { groupThinking, type ThoughtsGroup } from '../../../src/runtime/chat/group-thinking';
|
||||
import { formatElapsed, formatShellElapsed } from '../../../src/runtime/chat/format';
|
||||
import type { BuildTurnInput, ExecutionShell, ShellItem, TurnBlock } from '../../../src/runtime/chat/contract';
|
||||
import fixture from '../../fixtures/chat/odnext-parchment.reload.json';
|
||||
|
||||
/** 历史接口返回的那一份:带 daemon join 进来的任务位置 → 会被折叠。 */
|
||||
const RELOAD = fixture.messages as unknown as ChatMessage[];
|
||||
/** live 手上的那一份:同样的行,但 SSE 链路从不写任务位置 → 不折叠。 */
|
||||
const LIVE: ChatMessage[] = RELOAD.map((m) => {
|
||||
const rest = { ...(m as unknown as Record<string, unknown>) };
|
||||
delete rest['strategyTaskExecutionId'];
|
||||
delete rest['strategyTaskRunIndex'];
|
||||
return rest as unknown as ChatMessage;
|
||||
});
|
||||
const RUNS = LIVE.filter((m) => m.role === 'assistant');
|
||||
|
||||
function blocksOf(message: ChatMessage): TurnBlock[] {
|
||||
return buildTurnBlocks({
|
||||
events: (message.events ?? []) as PersistedAgentEvent[],
|
||||
runStatus: message.runStatus ?? 'succeeded',
|
||||
...(message.createdAt != null ? { startedAtMs: message.createdAt } : {}),
|
||||
...(message.endedAt != null ? { endedAtMs: message.endedAt } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const shellsOf = (blocks: TurnBlock[]): ExecutionShell[] =>
|
||||
blocks.filter((b): b is ExecutionShell => b.kind === 'shell');
|
||||
|
||||
const foldedTurn = (): ChatMessage => {
|
||||
const folded = foldStrategyTaskTurns(RELOAD).filter((m) => m.role === 'assistant');
|
||||
expect(folded, '这条会话就是要折成一条').toHaveLength(1);
|
||||
return folded[0]!;
|
||||
};
|
||||
|
||||
/** 一张壳里所有「思考过程」那一格 —— 顶层的,以及落在 todo 抽屉里的。 */
|
||||
function thoughtsIn(items: readonly ShellItem[]): ThoughtsGroup[] {
|
||||
const out: ThoughtsGroup[] = [];
|
||||
for (const group of groupThinking(items as ShellItem[], false)) {
|
||||
if (group.kind === 'thoughts') out.push(group);
|
||||
else if (group.kind === 'todo') out.push(...thoughtsIn(group.segment.items));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 屏幕上那一行到底写了什么 —— 只看渲染出来的字,不看字段。 */
|
||||
const headText = (shell: ExecutionShell): string | null => formatShellElapsed(shell.elapsedMs);
|
||||
|
||||
describe('OPEND-2823 · 折叠轮次里每张壳都要报得出耗时', () => {
|
||||
/**
|
||||
* 先证「量法看得见」:live 那三条各自都报得出耗时,而且就是库里那三个真实跨度。
|
||||
* 这一条绿是下面那条断言不空转的前提 —— 没见过绿的对照组,红读数说明不了问题。
|
||||
*/
|
||||
it('对照组 · live 三个 run 各自报得出真实跨度', () => {
|
||||
const live = RUNS.map((run) => shellsOf(blocksOf(run)));
|
||||
expect(live.map((shells) => shells.length)).toEqual([1, 1, 1]);
|
||||
expect(live.map(([shell]) => shell!.elapsedMs)).toEqual([79_779, 159_066, 282_314]);
|
||||
expect(live.map(([shell]) => headText(shell!))).toEqual(['1m 20s', '2m 39s', '4m 42s']);
|
||||
});
|
||||
|
||||
/**
|
||||
* 真正的不变量:**折叠是拼接,不是重算** —— 同一条会话,重新打开之后每张壳报的
|
||||
* 耗时必须和它自己在 live 时报的一模一样。
|
||||
*
|
||||
* 红的样子(`origin/main`):`['1m 20s','2m 39s','4m 42s']` → `[null, null, '1m 45s']`。
|
||||
* 前两张壳的「已完成」旁边一个数都没有,第三张还少报了整整三分钟。
|
||||
*/
|
||||
it('重新打开之后,每张壳的耗时和 live 时逐字相同', () => {
|
||||
const liveHeads = RUNS.map((run) => headText(shellsOf(blocksOf(run))[0]!));
|
||||
const reloadHeads = shellsOf(blocksOf(foldedTurn())).map(headText);
|
||||
expect(reloadHeads).toEqual(liveHeads);
|
||||
});
|
||||
|
||||
/**
|
||||
* 把这张单的原话钉死一遍:**有完整计时数据时,「已完成」旁边必须有耗时**。
|
||||
* 三个 run 在库里都带着自己的 `createdAt` / `endedAt`,数据是齐的。
|
||||
*/
|
||||
it('数据齐全时没有一张壳是光秃秃的「已完成」', () => {
|
||||
for (const shell of shellsOf(blocksOf(foldedTurn()))) {
|
||||
expect(shell.status).toBe('done');
|
||||
expect(headText(shell), '「已完成」旁边不许没有耗时').not.toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('OPEND-2824 · 卡头的总耗时不得小于卡里的阶段耗时', () => {
|
||||
/**
|
||||
* 提单人截图里的形状:「进行中 1m44s」下面挂着「思考过程 4m3s」。
|
||||
* 真机语料算出来的是 **1m 45s / 7m 2s** —— 同一个形状,数量级也对得上。
|
||||
*
|
||||
* 这里断言的是**渲染出来的那两行字**背后的同一对值:卡头 ≥ 卡里任何一格。
|
||||
*/
|
||||
it('折叠轮次里,没有一格「思考过程」比它所在的卡头更大', () => {
|
||||
const shells = shellsOf(blocksOf(foldedTurn()));
|
||||
const offenders: string[] = [];
|
||||
for (const shell of shells) {
|
||||
for (const thoughts of thoughtsIn(shell.items)) {
|
||||
if (shell.elapsedMs == null || thoughts.elapsedMs == null) continue;
|
||||
if (thoughts.elapsedMs > shell.elapsedMs) {
|
||||
offenders.push(
|
||||
`卡头 ${formatShellElapsed(shell.elapsedMs)} < 思考过程 ${formatElapsed(thoughts.elapsedMs)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* 同一条不变量在**还在跑**的那一帧也要成立 —— 截图拍到的正是「进行中」那一档。
|
||||
* 折叠轮次里只有最后一个 run 可能还在跑,前面几个早已定死。
|
||||
*/
|
||||
it('还在跑的那一帧同样成立', () => {
|
||||
const folded = foldedTurn();
|
||||
const shells = shellsOf(buildTurnBlocks({
|
||||
events: (folded.events ?? []) as PersistedAgentEvent[],
|
||||
runStatus: 'running',
|
||||
startedAtMs: folded.createdAt,
|
||||
nowMs: folded.endedAt! + 60_000,
|
||||
}));
|
||||
for (const shell of shells) {
|
||||
for (const thoughts of thoughtsIn(shell.items)) {
|
||||
if (shell.elapsedMs == null || thoughts.elapsedMs == null) continue;
|
||||
expect(
|
||||
thoughts.elapsedMs,
|
||||
`卡头 ${shell.elapsedMs}ms 装不下一格 ${thoughts.elapsedMs}ms 的思考`,
|
||||
).toBeLessThanOrEqual(shell.elapsedMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 一段推理**不许跨过 run 边界去认领时间**。
|
||||
*
|
||||
* run 2 那一格「思考过程」在 live 时是 2m 57s(它自己那个 run 里的空白);折叠之后
|
||||
* 变成 7m 2s —— 多出来的四分钟是 run 0、run 1 的时间,以及用户盯着表单想答案的
|
||||
* 那 6 秒。那不是模型在想,是别人的钟被读了进来。
|
||||
*/
|
||||
/**
|
||||
* 同一条不变量的**另一条路**:一个 run 里 agent 发了 done、又开新计划接着干,
|
||||
* 于是卡外那段结论把执行记录切成两张卡(`applyTodoList` 的边界规则)。
|
||||
*
|
||||
* 第二张卡的表从**它自己第一件带时刻的事**开始走,而落在它头上的那段推理比这更早
|
||||
* (推理一个时刻都不带,只能靠它填掉的空白反推)。于是卡头 10s、卡里 4m ——
|
||||
* 和折叠轮次那一幕是同一个形状,只是这次一个 run 就够了,连折叠都不用。
|
||||
*
|
||||
* 这里不是「两个数分属不同范围」:那段空白从头到尾只有这一行推理占着,
|
||||
* 它就在这张卡里,卡的跨度本来就该覆盖它。
|
||||
*/
|
||||
it('一个 run 里另起的第二张卡,卡头同样装得下卡里的推理', () => {
|
||||
const KEY = 'a7f3c91ed2b40561';
|
||||
const events: PersistedAgentEvent[] = [
|
||||
{ kind: 'done_key', key: KEY },
|
||||
{ kind: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' }, startedAt: 1_000_100 },
|
||||
{ kind: 'tool_result', toolUseId: 't1', content: 'ok', isError: false, completedAt: 1_010_000 },
|
||||
{ kind: 'text', text: `<od-done key="${KEY}"></od-done>初版好了。` },
|
||||
// done 之后又开一份新计划继续干 —— 结论把记录切成两张卡
|
||||
{ kind: 'tool_use', id: 'td', name: 'TodoWrite', input: { todos: [{ content: '继续做', status: 'in_progress' }] }, startedAt: 1_010_500 },
|
||||
{ kind: 'thinking', text: '这一步要想很久。' },
|
||||
{ kind: 'tool_use', id: 't2', name: 'Bash', input: { command: 'ls' }, startedAt: 1_250_000 },
|
||||
{ kind: 'tool_result', toolUseId: 't2', content: 'ok', isError: false, completedAt: 1_255_000 },
|
||||
];
|
||||
const shells = shellsOf(buildTurnBlocks({
|
||||
events, runStatus: 'succeeded', startedAtMs: 1_000_000, endedAtMs: 1_260_000,
|
||||
}));
|
||||
expect(shells, '这一例就是要切成两张卡').toHaveLength(2);
|
||||
const second = shells[1]!;
|
||||
const thoughts = thoughtsIn(second.items);
|
||||
// 先证语料真的造出了那一格思考 —— 没有它下面两条会空转
|
||||
expect(thoughts).toHaveLength(1);
|
||||
expect(thoughts[0]!.elapsedMs).toBe(239_500);
|
||||
expect(second.elapsedMs).not.toBeNull();
|
||||
expect(thoughts[0]!.elapsedMs!).toBeLessThanOrEqual(second.elapsedMs!);
|
||||
// 抽屉那一级同样要装得下(`TodoRow` 右边也挂着一个耗时)
|
||||
const todo = second.items.find((it) => it.kind === 'todo');
|
||||
expect(todo?.kind).toBe('todo');
|
||||
const segment = (todo as { segment: { elapsedMs: number | null } }).segment;
|
||||
expect(segment.elapsedMs).not.toBeNull();
|
||||
expect(thoughts[0]!.elapsedMs!).toBeLessThanOrEqual(segment.elapsedMs!);
|
||||
});
|
||||
|
||||
it('推理的耗时不跨 run 边界 —— 折叠前后逐字相同', () => {
|
||||
const live = RUNS.flatMap((run) => shellsOf(blocksOf(run)).flatMap((s) => thoughtsIn(s.items)))
|
||||
.map((g) => g.elapsedMs);
|
||||
const reload = shellsOf(blocksOf(foldedTurn())).flatMap((s) => thoughtsIn(s.items))
|
||||
.map((g) => g.elapsedMs);
|
||||
// 先证指纹认得出东西 —— 全 null 的话下面那条会空转
|
||||
expect(live.filter((ms) => ms != null).length).toBeGreaterThan(0);
|
||||
expect(reload).toEqual(live);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 评审 #7921 指出的**另一半**:补上 run 边界之后,壳头**仍然**会去借别的 run 的钟。
|
||||
*
|
||||
* `shellElapsed` 拿不到这张壳自己的 `shellSpan` 时,起止会回落到 `firstStartedAt` /
|
||||
* `lastEndedAt` —— 那两个是**全轮共用**的,`closeRun()` 也不清它们。于是一个
|
||||
* 「只有 status / text、一个带时刻的事件都没有」的后继 run(澄清 run 的典型形态),
|
||||
* 它那张壳会捡起**前一个 run** 的工具时刻,再和自己的 run 边界取 min / max,
|
||||
* 把前一个 run 整段算进自己名下。
|
||||
*
|
||||
* 这和这个文件开头那两张单是同一个毛病(数字来自别的 run),只是触发条件换了:
|
||||
* 前面修掉的是「中间 run 没有数字」,这一条是「中间 run 的数字是别人的」。
|
||||
*
|
||||
* ⚠️ 那条兜底**任何时候都说不出正确答案**:这张壳没有自己的 `shellSpan`,而
|
||||
* `firstStartedAt` / `lastEndedAt` 只可能由**别的壳**盖出来(它们是全轮所有时刻的
|
||||
* min / max)。所以「回落到全轮时钟」在构造上等价于「借另一张卡的表」,不存在
|
||||
* 它恰好等于本张卡的情形。清掉它,让秒表只认这张壳自己的事件 + 它那个 run 的边界。
|
||||
*/
|
||||
describe('评审 #7921 · 壳头不许借别的 run 的时钟', () => {
|
||||
const K0 = 'c1d2e3f4a5b60718';
|
||||
const K1 = 'f7e6d5c4b3a29180';
|
||||
const TASK = 'task-borrowed-clock';
|
||||
|
||||
/** run 0:正常干活,有带时刻的工具事件 —— 它就是被借的那口钟。 */
|
||||
const RUN0: ChatMessage = {
|
||||
id: 'run0', role: 'assistant', content: '',
|
||||
createdAt: 1_000_000, endedAt: 1_060_000, runStatus: 'succeeded',
|
||||
strategyTaskExecutionId: TASK, strategyTaskRunIndex: 0,
|
||||
events: [
|
||||
{ kind: 'done_key', key: K0 },
|
||||
{ kind: 'tool_use', id: 't1', name: 'Bash', input: { command: 'ls' }, startedAt: 1_010_000 },
|
||||
{ kind: 'tool_result', toolUseId: 't1', content: 'ok', isError: false, completedAt: 1_050_000 },
|
||||
{ kind: 'text', text: `<od-done key="${K0}"></od-done>初版给你了,要哪个方向?` },
|
||||
],
|
||||
} as unknown as ChatMessage;
|
||||
|
||||
/**
|
||||
* run 1:**只有 status 和 text**,一个 `startedAt` / `completedAt` 都没有。
|
||||
* 中间隔着四分钟 —— 用户在读上一轮的产出、想怎么回话,那段时间没有任何模型在跑。
|
||||
*
|
||||
* 头一段正文留在壳里(done 之前的过程叙述),所以这张壳不是空壳、不会被 B47 丢掉;
|
||||
* 而它一条 thinking 都没有,所以也不会有推理把 `shellSpan` 撑出来 ——
|
||||
* 正是评审描述的那个形态。
|
||||
*/
|
||||
const RUN1: ChatMessage = {
|
||||
id: 'run1', role: 'assistant', content: '',
|
||||
createdAt: 1_300_000, endedAt: 1_320_000, runStatus: 'succeeded',
|
||||
strategyTaskExecutionId: TASK, strategyTaskRunIndex: 1,
|
||||
events: [
|
||||
{ kind: 'done_key', key: K1 },
|
||||
{ kind: 'status', label: 'starting' },
|
||||
{ kind: 'text', text: '先看看你的项目。' },
|
||||
{ kind: 'text', text: `<od-done key="${K1}"></od-done>已经按第二个方向改好了。` },
|
||||
],
|
||||
} as unknown as ChatMessage;
|
||||
|
||||
const foldedPair = (): ChatMessage => {
|
||||
const folded = foldStrategyTaskTurns([RUN0, RUN1]).filter((m) => m.role === 'assistant');
|
||||
expect(folded, '两个 run 要折成一条').toHaveLength(1);
|
||||
return folded[0]!;
|
||||
};
|
||||
|
||||
/** 先证语料真的造出了那个形态 —— 不然下面几条会空转。 */
|
||||
it('对照组 · run 1 确实一个带时刻的事件都没有', () => {
|
||||
const stamped = (RUN1.events ?? []).filter(
|
||||
(e) => (e as { startedAt?: number }).startedAt != null
|
||||
|| (e as { completedAt?: number }).completedAt != null,
|
||||
);
|
||||
expect(stamped).toHaveLength(0);
|
||||
// 而 run 0 有 —— 它就是会被借走的那口钟
|
||||
expect((RUN0.events ?? []).some((e) => (e as { startedAt?: number }).startedAt != null)).toBe(true);
|
||||
});
|
||||
|
||||
it('折叠之后,run 1 的壳头报的是它自己那 20 秒,不是从 run 0 开始的五分钟', () => {
|
||||
const shells = shellsOf(blocksOf(foldedPair()));
|
||||
expect(shells, '两个 run 各一张壳').toHaveLength(2);
|
||||
const second = shells[1]!;
|
||||
// 它自己的跨度:1_320_000 - 1_300_000
|
||||
expect(second.elapsedMs).toBe(20_000);
|
||||
expect(headText(second)).toBe('20s');
|
||||
});
|
||||
|
||||
/**
|
||||
* 同一件事换个说法钉一遍:壳头**绝不能**把 run 之间那段没人在跑的时间算进来。
|
||||
* 红的时候这里是 310_000ms(从 run 0 的第一个工具一路量到 run 1 收尾)。
|
||||
*/
|
||||
it('run 1 的壳头装不下 run 之间那段空白', () => {
|
||||
const shells = shellsOf(blocksOf(foldedPair()));
|
||||
const second = shells[1]!;
|
||||
const ownSpan = RUN1.endedAt! - RUN1.createdAt!;
|
||||
const sinceRun0 = RUN1.endedAt! - RUN0.createdAt!;
|
||||
expect(second.elapsedMs).not.toBeNull();
|
||||
expect(second.elapsedMs!).toBeLessThanOrEqual(ownSpan);
|
||||
expect(second.elapsedMs!, '把 run 0 也算进来了').toBeLessThan(sinceRun0);
|
||||
});
|
||||
|
||||
/** 还是那条最强的锚:折叠出来的数字必须逐字等于它自己 live 时的数字。 */
|
||||
it('折叠前后逐字相同 —— 两张壳都是', () => {
|
||||
const live = [RUN0, RUN1].map((run) => headText(shellsOf(blocksOf(run))[0]!));
|
||||
const reload = shellsOf(blocksOf(foldedPair())).map(headText);
|
||||
expect(live).toEqual(['1m 0s', '20s']);
|
||||
expect(reload).toEqual(live);
|
||||
});
|
||||
});
|
||||
|
||||
describe('反向锚点 · 真的没有计时数据时,展示规则保持一致', () => {
|
||||
/** 一个带时刻的事件都没有、轮次自己的起止也没有 —— 这时候「不知道」就是不知道。 */
|
||||
const NOTHING: PersistedAgentEvent[] = [
|
||||
{ kind: 'status', label: 'starting' },
|
||||
{ kind: 'thinking', text: '先看看要改哪里。' },
|
||||
{ kind: 'text', text: '按你说的改好了。' },
|
||||
];
|
||||
|
||||
const build = (input: Partial<BuildTurnInput>): ExecutionShell[] =>
|
||||
shellsOf(buildTurnBlocks({ events: NOTHING, runStatus: 'succeeded', ...input }));
|
||||
|
||||
it('两头都取不到:壳头不显示耗时,而不是编一个 0s,也不抛', () => {
|
||||
const shells = build({});
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0]!.elapsedMs).toBeNull();
|
||||
expect(headText(shells[0]!)).toBeNull();
|
||||
});
|
||||
|
||||
it('只缺一头也一样 —— 缺就是缺,不拿另一头顶上', () => {
|
||||
expect(headText(build({ startedAtMs: 1_000_000 })[0]!)).toBeNull();
|
||||
expect(headText(build({ endedAtMs: 1_042_000 })[0]!)).toBeNull();
|
||||
});
|
||||
|
||||
it('两头都有就必须报出来 —— 同一批数据不许一会儿有一会儿没有', () => {
|
||||
expect(headText(build({ startedAtMs: 1_000_000, endedAtMs: 1_042_000 })[0]!)).toBe('42s');
|
||||
});
|
||||
});
|
||||
@@ -1004,7 +1004,39 @@ export type PersistedAgentEvent =
|
||||
* existed simply have none — clients MUST fall back to the legacy bare-marker
|
||||
* heuristic there rather than treating "no key" as "no boundary".
|
||||
*/
|
||||
| { kind: 'done_key'; key: string }
|
||||
| {
|
||||
kind: 'done_key';
|
||||
key: string;
|
||||
/**
|
||||
* This physical Run's own wall-clock span.
|
||||
*
|
||||
* A logical OD Next task runs as several physical Runs, and the client
|
||||
* folds them into one turn when history is reloaded
|
||||
* (`foldStrategyTaskTurns`). That fold concatenates every Run's events
|
||||
* into a single stream but can only keep ONE message row, so the first
|
||||
* Run's `createdAt` and the last Run's `endedAt` survive and every
|
||||
* boundary in between is lost. The renderer then has one clock for N
|
||||
* Runs: a Run whose events carry no timestamps of their own (a
|
||||
* clarification Run typically has none, and the plain-stream agent
|
||||
* family never emits any) has no boundary left to fall back on and shows
|
||||
* no duration at all, while a thinking gap — inferred from "last stamped
|
||||
* event until the next one" — runs straight across the boundary and
|
||||
* charges earlier Runs' wall-clock time to the current one.
|
||||
*
|
||||
* `done_key` is already emitted once per Run and is already what the
|
||||
* renderer uses to detect a Run boundary, so the span belongs here: it
|
||||
* travels with the boundary it describes and needs no parallel channel.
|
||||
*
|
||||
* Optional because turns recorded before this existed carry none, and
|
||||
* because a Run that is still in flight has no end yet. An absent boundary
|
||||
* is unknown. Clients may still use timestamps belonging to the same Run,
|
||||
* but MUST NOT substitute a preceding Run's timestamps or the aggregate
|
||||
* folded turn's span for a missing successor-Run boundary. If no own timing
|
||||
* data is available, leave the duration unknown rather than inventing one.
|
||||
*/
|
||||
runStartedAt?: number;
|
||||
runEndedAt?: number;
|
||||
}
|
||||
/**
|
||||
* This turn's follow-up suggestions — the three one-line actions the chat
|
||||
* offers under a delivered answer. Parsed by the daemon out of the agent's
|
||||
|
||||
Reference in New Issue
Block a user