修正工具组独立计时并移除重复整轮耗时
每个工具组只计算自身调用跨度,已完成组立即停止增长与进行中标记。 整轮总耗时仅在回合状态或完成小结显示一次,单工具仍按百毫秒动态更新。 补齐前组已完成后组运行、边界缺失与倒序的回归及浏览器证据。
This commit is contained in:
@@ -495,8 +495,6 @@ export function ProjectSupervisorView({
|
||||
<ToolCallGroup
|
||||
key={block.key}
|
||||
calls={block.calls}
|
||||
turnStartedAt={turn.startedAt}
|
||||
turnEndedAt={turn.endedAt}
|
||||
active={turn.active}
|
||||
className="message-tool-call"
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AgentMessageContent } from '../../../../../packages/shared/src/componen
|
||||
import type { DirectChatToolCard } from './directThreadChat';
|
||||
import {
|
||||
formatToolCallDuration,
|
||||
resolveTurnTiming,
|
||||
resolveToolGroupTiming,
|
||||
toolCallDurationMs,
|
||||
toolCallGroupSummary,
|
||||
toolCallInputText,
|
||||
@@ -22,12 +22,13 @@ import { useLiveNow } from './useLiveNow';
|
||||
|
||||
/**
|
||||
* 一回合的工具调用折叠块(Codex 风格):
|
||||
* 块头一行汇总 + 整轮总耗时 + 时间范围,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 块头一行汇总 + 本组用时,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
|
||||
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
|
||||
*
|
||||
* 计时口径(总耗时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
|
||||
* - 总耗时属于**整轮**:从用户实际发送到明确终态,跨多个工具块共用同一组边界,包含
|
||||
* 没有工具的 LLM 等待;不是本块工具的时间跨度,本块工具全部结束后只要回合还在跑就继续增长。
|
||||
* 计时口径(组用时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
|
||||
* - 本组用时属于**这一组工具**:`min(工具开始)` → `max(工具完成)`。同轮另一组还在跑不会让它
|
||||
* 继续增长;本组工具全部拿到终态就立即冻结。缺开始 / 缺终态边界时隐藏,不借别组或整轮边界。
|
||||
* 整轮总耗时(用户发送 → 回合终态)只在对话底部渲染一处,不在这里重复。
|
||||
* - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。
|
||||
*
|
||||
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
|
||||
@@ -38,26 +39,14 @@ import { useLiveNow } from './useLiveNow';
|
||||
/** 一回合的工具调用折叠块;空集合不渲染。 */
|
||||
export function ToolCallGroup({
|
||||
calls,
|
||||
turnStartedAt = 0,
|
||||
turnEndedAt = 0,
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
calls: DirectChatToolCard[];
|
||||
/**
|
||||
* 本轮起点:该轮用户消息的实际发送时间优先,缺失时用原生 `turn.started.at`。
|
||||
* 拿不到就传 0(或省略),此时隐藏不能证明的耗时。
|
||||
*/
|
||||
turnStartedAt?: number | null;
|
||||
/**
|
||||
* 本轮明确终态时间(`turn.completed.at`)。运行中、或终态时间缺失时传 0;
|
||||
* 只有明确终态才冻结总耗时,不按最后一次工具更新时间推断回合结束。
|
||||
*/
|
||||
turnEndedAt?: number | null;
|
||||
/**
|
||||
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中":
|
||||
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
|
||||
* 那是数据残留,不该让用户一直看到"执行中"。
|
||||
* 这个块是否属于**正在跑的回合**:决定本组是否还能用当前时钟增长,以及残留的
|
||||
* `running` 快照要不要显示"执行中 / 进行中"。已结束的回合里残留的 `running` 快照
|
||||
* (进程被杀、completed 事件没到)是数据残留,不该让用户一直看到"执行中"。
|
||||
*/
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
@@ -68,27 +57,21 @@ export function ToolCallGroup({
|
||||
(left, right) => left.startedAt - right.startedAt,
|
||||
);
|
||||
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
|
||||
// 行级"执行中"与 `data-status` 看**工具**是否还在跑;块头右侧的总耗时看**整轮**:
|
||||
// 回合还在跑就一直标"进行中"(哪怕组内工具都结束了,LLM 还在推理)。
|
||||
// 行级"执行中"与 `data-status` 看**这一组**是否还有工具在跑:同轮另一组在跑、或回合还在
|
||||
// 推理,都不会让已经跑完的组继续标"进行中"。
|
||||
const toolRunning = active && hasRunningSnapshot;
|
||||
// 该行是否需要跟着墙上时钟走:整轮总耗时(回合还没收口且起点已知)或本块里还在跑的工具。
|
||||
// 是否需要跟着墙上时钟走:只取决于本组里"还有工具在跑(且回合还在跑)"。
|
||||
const ticking =
|
||||
active &&
|
||||
((turnStartedAt ?? 0) > 0 ||
|
||||
orderedCalls.some(
|
||||
(call) => call.status === 'running' && call.startedAt > 0,
|
||||
));
|
||||
orderedCalls.some(
|
||||
(call) => call.status === 'running' && call.startedAt > 0,
|
||||
);
|
||||
const now = useLiveNow(ticking);
|
||||
if (calls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
// 整轮总耗时:回合还在跑就用当前时钟继续增长(组内工具全结束也一样),终态后冻结。
|
||||
const timing = resolveTurnTiming({
|
||||
startedAt: turnStartedAt,
|
||||
endedAt: turnEndedAt,
|
||||
running: active,
|
||||
now,
|
||||
});
|
||||
// 本组用时:只看这一组自己的工具边界;本组全完成即冻结。
|
||||
const timing = resolveToolGroupTiming(orderedCalls, { running: active, now });
|
||||
const summary = toolCallGroupSummary(orderedCalls);
|
||||
const status = toolRunning
|
||||
? 'running'
|
||||
@@ -97,8 +80,8 @@ export function ToolCallGroup({
|
||||
: 'completed';
|
||||
const headLabel = [
|
||||
summary,
|
||||
active ? '进行中' : (timing.timeLabel ?? ''),
|
||||
timing.durationText ? `总耗时 ${timing.durationText}` : '',
|
||||
toolRunning ? '进行中' : '',
|
||||
timing.durationText ? `本组用时 ${timing.durationText}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
@@ -131,16 +114,12 @@ export function ToolCallGroup({
|
||||
</span>
|
||||
<span className="agent-tool-call-group-summary">{summary}</span>
|
||||
<span className="agent-tool-call-group-meta">
|
||||
{active ? (
|
||||
{toolRunning ? (
|
||||
<span className="agent-tool-call-group-running">进行中</span>
|
||||
) : timing.timeLabel ? (
|
||||
<span className="agent-tool-call-group-time">
|
||||
{timing.timeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{timing.durationText ? (
|
||||
<span className="agent-tool-call-group-duration">
|
||||
{`总耗时 ${timing.durationText}`}
|
||||
{`本组用时 ${timing.durationText}`}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
+74
@@ -318,6 +318,80 @@ export type DirectTurnTiming = {
|
||||
timeLabel: string | null;
|
||||
};
|
||||
|
||||
export type DirectToolGroupTiming = {
|
||||
/** 本组用时(毫秒,已取整到 100ms 网格);null = 边界不完整,这一组的用时隐藏。 */
|
||||
durationMs: number | null;
|
||||
/** `本组用时` 后面的文案;null = 隐藏。 */
|
||||
durationText: string | null;
|
||||
/** 本组此刻是否仍在跑(组内有运行中的工具且回合还在跑):只有它为真才跟时钟增长。 */
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本组用时:只读**这一组自己**的工具边界 —— `min(工具开始)` → `max(工具完成)`。
|
||||
*
|
||||
* 它与整轮总耗时是两件事:整轮是用户发送 → 回合终态(含无工具的 LLM 等待),只在对话里
|
||||
* 渲染一处;组头只回答"这一组工具跑了多久"。因此这个函数**不接收任何回合边界**,也不会
|
||||
* 因为同轮另一组还在跑而跟着增长(那是上一版把两组显示成同一用时的原因)。
|
||||
*
|
||||
* - 缺任何开始时间(`startedAt <= 0`)、或某个**已结束**的工具缺完成时间(`updatedAt <= 0`)
|
||||
* → 整组用时未知:隐藏,不拿别组或整轮边界补(已知的单行耗时照常显示)。
|
||||
* - 组内还有运行中的工具且回合在跑时,终点取调用方给的宿主时钟 `now`,组头每 100ms 增长;
|
||||
* 组内工具全部拿到终态那一刻立即冻结,不等回合收口。
|
||||
* - 每条工具**自己的**原始边界先成立(终点 >= 起点,运行中的工具用 `now` 当终点),
|
||||
* 再聚合成 `min(开始)` → `max(完成)`:任何一条倒序 / 非有限都让整组隐藏,不靠别的正常
|
||||
* 工具体把坏边界掩盖成合法区间;亚 100ms 的倒序也不会被展示量化抹成 `0.0秒`。
|
||||
* - 合法的同一时刻显示 `0.0秒`。
|
||||
*/
|
||||
export function resolveToolGroupTiming(
|
||||
calls: readonly Pick<
|
||||
DirectChatToolCard,
|
||||
'status' | 'startedAt' | 'updatedAt'
|
||||
>[],
|
||||
options?: { running?: boolean; now?: number },
|
||||
): DirectToolGroupTiming {
|
||||
const hidden: DirectToolGroupTiming = {
|
||||
durationMs: null,
|
||||
durationText: null,
|
||||
running: false,
|
||||
};
|
||||
if (calls.length === 0) return hidden;
|
||||
const turnRunning = Boolean(options?.running);
|
||||
const now = Number(options?.now);
|
||||
let startedAt = 0;
|
||||
let endedAt = 0;
|
||||
let running = false;
|
||||
for (const call of calls) {
|
||||
const start = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||
if (start <= 0) return hidden;
|
||||
let end = 0;
|
||||
if (call.status === 'running') {
|
||||
// 运行中的工具没有终态时间:只有"组内还在跑且回合在跑"时才用当前时钟当终点。
|
||||
if (!turnRunning || !Number.isFinite(now) || now <= 0) return hidden;
|
||||
running = true;
|
||||
end = now;
|
||||
} else {
|
||||
end = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||
if (end <= 0) return hidden;
|
||||
}
|
||||
// 单条边界先判:倒序(含量化后会被抹成 0.0 秒的亚 100ms 倒序)整组隐藏,
|
||||
// 不允许被同组另一条正常工具的区间掩盖。
|
||||
if (end < start) return hidden;
|
||||
startedAt = startedAt === 0 ? start : Math.min(startedAt, start);
|
||||
endedAt = Math.max(endedAt, end);
|
||||
}
|
||||
const start = quantizeDisplayMs(startedAt);
|
||||
const end = quantizeDisplayMs(endedAt);
|
||||
// 聚合后量化:每条都已成立,聚合区间不会倒序;量化取整按单调函数做,也不会互相矛盾。
|
||||
if (start <= 0 || end <= 0 || end < start) return hidden;
|
||||
const durationMs = end - start;
|
||||
return {
|
||||
durationMs,
|
||||
durationText: formatTurnDuration(durationMs),
|
||||
running,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。
|
||||
*
|
||||
|
||||
@@ -8167,11 +8167,13 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
)[0] as HTMLElement;
|
||||
const groupHead = within(group).getByTestId('agent-tool-call-group-head');
|
||||
expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更');
|
||||
// 块头右侧是**整轮**总耗时(用户发送 → 回合终态),不是本块工具的时间跨度:
|
||||
// 组内工具都结束了,但回合还在跑,块头仍标"进行中"、耗时继续增长(只断言它是一位小数的数值)。
|
||||
// 组头右侧是**本组**用时(本组工具 min(开始) → max(完成) = 100ms),与整轮总耗时无关:
|
||||
// 组内工具都结束了,即使回合还在跑,也不标"进行中"、也不再滚动。
|
||||
expect(group.getAttribute('data-status')).toBe('completed');
|
||||
expect(groupHead.textContent).toContain('进行中');
|
||||
expect(groupHead.textContent).toMatch(/总耗时 \d+\.\d+秒/);
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('100');
|
||||
expect(groupHead.textContent).toContain('本组用时 0.1秒');
|
||||
expect(groupHead.textContent).not.toContain('进行中');
|
||||
expect(groupHead.textContent).not.toContain('总耗时');
|
||||
// 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。
|
||||
const liveChildren = Array.from((messageList as HTMLElement).children);
|
||||
expect(
|
||||
@@ -8270,12 +8272,23 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
);
|
||||
expect(settledHead.tagName).toBe('BUTTON');
|
||||
expect(settledHead.getAttribute('aria-expanded')).toBe('false');
|
||||
// 回合收口后总耗时冻结在整轮边界上(用户发送 → turn.completed.at),
|
||||
// 不再跟着墙上时钟走,也不是本块工具的时间跨度。
|
||||
// 回合收口不改变组头:组头始终是**本组**用时,不随回合变长。
|
||||
expect(settledGroup.getAttribute('data-status')).toBe('completed');
|
||||
expect(settledGroup.getAttribute('data-duration-ms')).toBe('500');
|
||||
expect(settledHead.textContent).toContain('总耗时 0.5秒');
|
||||
expect(settledGroup.getAttribute('data-duration-ms')).toBe('100');
|
||||
expect(settledHead.textContent).toContain('本组用时 0.1秒');
|
||||
expect(settledHead.textContent).not.toContain('进行中');
|
||||
expect(settledHead.textContent).not.toContain('总耗时');
|
||||
// 整轮总耗时在界面上只有一处:回合小结;任何工具组都不再显示它。
|
||||
expect(
|
||||
Array.from(
|
||||
supervisorSurface.querySelectorAll('[data-testid="turn-usage"]'),
|
||||
).filter((node) => node.textContent?.includes('总耗时')),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
Array.from(
|
||||
supervisorSurface.querySelectorAll('.agent-tool-call-group'),
|
||||
).filter((node) => node.textContent?.includes('总耗时')),
|
||||
).toHaveLength(0);
|
||||
const settledBody = settledGroup.querySelector(
|
||||
`#${settledHead.getAttribute('aria-controls')}`,
|
||||
);
|
||||
@@ -8869,8 +8882,14 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
);
|
||||
expect(clientTurnId).not.toBe('');
|
||||
|
||||
// 计时只读事件级 `at`:用户发送这一刻就是整轮起点。
|
||||
const freshTurnSentAt = Date.now();
|
||||
// 正式条目沿用同身份乐观消息的真实发送时间。不能在等待 IPC 调用后再取 Date.now()
|
||||
// 冒充发送时刻:全量运行的调度间隔会被正确计入总耗时,使硬编码的 1.0 秒期望漂移。
|
||||
const sentTimeElement = supervisorSurface.querySelector<HTMLTimeElement>(
|
||||
'.message--user time.message-sent-at',
|
||||
);
|
||||
expect(sentTimeElement).not.toBeNull();
|
||||
const freshTurnSentAt = Date.parse(sentTimeElement!.dateTime);
|
||||
expect(Number.isFinite(freshTurnSentAt)).toBe(true);
|
||||
await act(async () => {
|
||||
// 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。
|
||||
supervisorHarness.emitDirectThreadEvents(
|
||||
@@ -8914,9 +8933,11 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
);
|
||||
expect(runningHead.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(runningHead.textContent).toContain('1 个命令');
|
||||
// 回合还在跑:总耗时按"用户发送 → 现在"增长,一位小数、显示进行中。
|
||||
// 组内还有工具在跑:组头按"本组起点 → 现在"增长,一位小数、显示进行中。
|
||||
// 整轮总耗时不在组头(它只在回合小结 / 底部耗时行出现)。
|
||||
expect(runningHead.textContent).toContain('进行中');
|
||||
expect(runningHead.textContent).toMatch(/总耗时 \d+\.\d+秒/);
|
||||
expect(runningHead.textContent).toMatch(/本组用时 \d+\.\d+秒/);
|
||||
expect(runningHead.textContent).not.toContain('总耗时');
|
||||
const runningChildren = Array.from((messageList as HTMLElement).children);
|
||||
expect(
|
||||
runningChildren.findIndex((node) => node === runningGroup),
|
||||
@@ -8952,16 +8973,17 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
const settledRunningGroup = within(supervisorSurface).getAllByTestId(
|
||||
'agent-tool-call-group',
|
||||
)[0] as HTMLElement;
|
||||
// 工具已经结束(块内没有 running 快照),但回合还在跑:块头仍标"进行中",
|
||||
// 总耗时按"用户发送 → 现在"滚动(这里不钉具体毫秒,动态增长由 tool-call-group 用例覆盖)。
|
||||
// 工具已经结束(块内没有 running 快照):本组用时立刻冻结,也不再标"进行中",
|
||||
// 即使回合还在跑(这正是之前把整轮总耗时借给每组造成的误报)。
|
||||
expect(settledRunningGroup.getAttribute('data-status')).toBe('completed');
|
||||
expect(
|
||||
within(settledRunningGroup).getByTestId('agent-tool-call-group-head')
|
||||
.textContent,
|
||||
).toContain('进行中');
|
||||
fireEvent.click(
|
||||
within(settledRunningGroup).getByTestId('agent-tool-call-group-head'),
|
||||
expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400');
|
||||
const settledRunningHead = within(settledRunningGroup).getByTestId(
|
||||
'agent-tool-call-group-head',
|
||||
);
|
||||
expect(settledRunningHead.textContent).toContain('本组用时 0.4秒');
|
||||
expect(settledRunningHead.textContent).not.toContain('进行中');
|
||||
expect(settledRunningHead.textContent).not.toContain('总耗时');
|
||||
fireEvent.click(settledRunningHead);
|
||||
const settledRunningRows = within(settledRunningGroup).getAllByTestId(
|
||||
'agent-tool-call-row',
|
||||
);
|
||||
@@ -8994,14 +9016,14 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
'agent-tool-call-group',
|
||||
)[0] as HTMLElement;
|
||||
expect(settledGroup).toBeTruthy();
|
||||
// 回合收口:整轮总耗时冻结在用户发送 → turn.completed.at 的跨度上。
|
||||
// 回合收口只决定底部小结:组头仍然是本组自己的用时。
|
||||
expect(settledGroup.getAttribute('data-status')).toBe('completed');
|
||||
expect(settledGroup.getAttribute('data-duration-ms')).toBe('1000');
|
||||
expect(settledGroup.getAttribute('data-duration-ms')).toBe('400');
|
||||
expect(
|
||||
within(settledGroup).getByTestId('agent-tool-call-group-head')
|
||||
.textContent,
|
||||
).toContain('总耗时 1.0秒');
|
||||
// 同一次收口也把总耗时写进回合小结,读的是同一组边界。
|
||||
).toContain('本组用时 0.4秒');
|
||||
// 整轮总耗时冻结在用户发送 → turn.completed.at,且只在回合小结里出现一处。
|
||||
expect(
|
||||
within(supervisorSurface).getByTestId('turn-usage').textContent,
|
||||
).toContain('总耗时 1.0秒');
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
formatClockTime,
|
||||
formatToolCallDuration,
|
||||
formatTurnDuration,
|
||||
resolveToolGroupTiming,
|
||||
resolveTurnTiming,
|
||||
toolCallDurationMs,
|
||||
toolCallGroupSummary,
|
||||
@@ -287,7 +288,11 @@ export function registerToolCallGroupTests() {
|
||||
expect(formatClockTime(0)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders per-row durations plus the turn total, and nothing when timestamps are missing', () => {
|
||||
/**
|
||||
* 组头显示的是**本组**用时(`min(开始)` → `max(完成)`),与整轮总耗时无关:
|
||||
* 组头不再接收任何回合边界,也不会重复渲染整轮时间范围。
|
||||
*/
|
||||
it('renders per-row durations plus this group total, and nothing when timestamps are missing', () => {
|
||||
const { container } = render(
|
||||
React.createElement(ToolCallGroup, {
|
||||
calls: [
|
||||
@@ -314,25 +319,22 @@ export function registerToolCallGroupTests() {
|
||||
updatedAt: 17900,
|
||||
}),
|
||||
],
|
||||
turnStartedAt: 1000,
|
||||
turnEndedAt: 17900,
|
||||
}),
|
||||
);
|
||||
const group = container.querySelector(
|
||||
'[data-testid="agent-tool-call-group"]',
|
||||
) as HTMLElement;
|
||||
// 总耗时读**整轮**边界:1000 → 17900,块头显示「总耗时 16.9秒」,
|
||||
// 本组用时读**这一组**的边界:1000 → 17900,块头显示「本组用时 16.9秒」,
|
||||
// `data-duration-ms` 暴露取整到 100ms 网格后的展示耗时。
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('16900');
|
||||
const head = within(group).getByTestId('agent-tool-call-group-head');
|
||||
expect(head.textContent).toContain('总耗时 16.9秒');
|
||||
expect(head.textContent).toContain('本组用时 16.9秒');
|
||||
// 整轮总耗时只在对话底部渲染一处,组头不再出现"总耗时",也不再显示时间范围。
|
||||
expect(head.textContent).not.toContain('总耗时');
|
||||
expect(head.querySelector('.agent-tool-call-group-time')).toBeNull();
|
||||
expect(head.getAttribute('aria-label')).toMatch(
|
||||
/^已执行 1 个命令、1 个文件变更、1 个联网搜索,\d{2}:\d{2}:\d{2}\.\d → \d{2}:\d{2}:\d{2}\.\d,总耗时 16\.9秒$/,
|
||||
/^已执行 1 个命令、1 个文件变更、1 个联网搜索,本组用时 16\.9秒$/,
|
||||
);
|
||||
// 时间戳不写死时区:`HH:mm:ss.S → HH:mm:ss.S`(发送 → 结束)。
|
||||
expect(
|
||||
head.querySelector('.agent-tool-call-group-time')?.textContent,
|
||||
).toMatch(/^\d{2}:\d{2}:\d{2}\.\d → \d{2}:\d{2}:\d{2}\.\d$/);
|
||||
|
||||
fireEvent.click(head);
|
||||
const rows = within(group).queryAllByTestId('agent-tool-call-row');
|
||||
@@ -376,33 +378,33 @@ export function registerToolCallGroupTests() {
|
||||
});
|
||||
|
||||
/**
|
||||
* 动态计时:两套耗时都每 100ms 刷新、始终一位小数。
|
||||
* 动态计时:组用时与单条耗时都每 100ms 刷新、始终一位小数。
|
||||
*
|
||||
* 假时钟同时接管 `Date.now()`:`useLiveNow` 读时间戳算差值,不按 tick 累加。
|
||||
*/
|
||||
it('grows the turn total every 100ms with one decimal and freezes it when the turn ends', () => {
|
||||
it('grows this group total every 100ms while one of its tools runs and freezes when the group finishes', () => {
|
||||
vi.useFakeTimers();
|
||||
const startedAt = Date.now();
|
||||
const base = Date.now();
|
||||
const calls = [
|
||||
toolCall({
|
||||
id: 'live-a',
|
||||
kind: 'command',
|
||||
summary: 'npm run build',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
// 本组最早的工具起点:400ms 前开始。
|
||||
startedAt: base - 400,
|
||||
}),
|
||||
toolCall({
|
||||
id: 'live-b',
|
||||
kind: 'web_search',
|
||||
summary: '玩法调研',
|
||||
status: 'running',
|
||||
startedAt: startedAt + 400,
|
||||
startedAt: base - 100,
|
||||
}),
|
||||
];
|
||||
const view = render(
|
||||
React.createElement(ToolCallGroup, {
|
||||
calls,
|
||||
turnStartedAt: startedAt,
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
@@ -410,96 +412,267 @@ export function registerToolCallGroupTests() {
|
||||
'[data-testid="agent-tool-call-group"]',
|
||||
) as HTMLElement;
|
||||
const head = within(group).getByTestId('agent-tool-call-group-head');
|
||||
// 只有本组还有工具在跑时才标"进行中",并且组头显示的是本组用时。
|
||||
expect(head.textContent).toContain('进行中');
|
||||
expect(head.textContent).toContain('总耗时 0.0秒');
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('0');
|
||||
expect(head.textContent).toContain('本组用时 0.4秒');
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('400');
|
||||
|
||||
// 没有新事件,时间推进总耗时也增长;5000ms 后是 `5.0秒`。
|
||||
// 没有新事件,时间推进组用时也增长;5000ms 后是 `5.4秒`。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('5000');
|
||||
expect(head.textContent).toContain('总耗时 5.0秒');
|
||||
// 分钟进位:65000ms → `1分钟 5.0秒`。
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('5400');
|
||||
expect(head.textContent).toContain('本组用时 5.4秒');
|
||||
// 分钟进位:65400ms → `1分钟 5.4秒`。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000);
|
||||
});
|
||||
expect(head.textContent).toContain('总耗时 1分钟 5.0秒');
|
||||
expect(head.textContent).toContain('本组用时 1分钟 5.4秒');
|
||||
|
||||
// 回合收口:总耗时冻结在终态时间上,继续推进时钟不再变化。
|
||||
const frozenEnd = startedAt + 65000;
|
||||
// 组内工具全部拿到终态:组用时冻结在**工具终点**上(不是当前时钟),
|
||||
// 继续推进时钟不再变化,也不等回合收口。
|
||||
// 本组最后一条工具终点:base + 64600 → 与最早起点(base - 400)相差 65000。
|
||||
const lastEnd = base + 64600;
|
||||
view.rerender(
|
||||
React.createElement(ToolCallGroup, {
|
||||
calls,
|
||||
turnStartedAt: startedAt,
|
||||
turnEndedAt: frozenEnd,
|
||||
active: false,
|
||||
calls: [
|
||||
{ ...calls[0], status: 'completed' as const, updatedAt: lastEnd },
|
||||
{
|
||||
...calls[1],
|
||||
status: 'completed' as const,
|
||||
updatedAt: lastEnd - 400,
|
||||
},
|
||||
],
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
expect(group.getAttribute('data-duration-ms')).toBe('65000');
|
||||
expect(head.textContent).toContain('总耗时 1分钟 5.0秒');
|
||||
expect(head.textContent).toContain('本组用时 1分钟 5.0秒');
|
||||
expect(head.textContent).not.toContain('进行中');
|
||||
view.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps every group of the same turn on the same boundary and freezes finished tools', () => {
|
||||
/**
|
||||
* 两组各自读自己的边界:起点不同、前组跑完立刻冻结,后组继续增长(这是本轮修掉的 bug ——
|
||||
* 之前两组共享同一个整轮边界,前组明明跑完却仍显示"进行中 / 同一个总耗时")。
|
||||
*/
|
||||
it('reads each group own boundary: a finished group stays fixed while a later group keeps running', () => {
|
||||
vi.useFakeTimers();
|
||||
const startedAt = Date.now();
|
||||
const runningCall = toolCall({
|
||||
id: 'running-call',
|
||||
kind: 'command',
|
||||
summary: 'npm run build',
|
||||
status: 'running',
|
||||
startedAt: startedAt,
|
||||
});
|
||||
const base = Date.now();
|
||||
const finishedCall = toolCall({
|
||||
id: 'finished-call',
|
||||
kind: 'command',
|
||||
summary: 'npm run build',
|
||||
// 先跑的那一组:130 秒前开始,2 秒后结束。
|
||||
startedAt: base - 130000,
|
||||
updatedAt: base - 128000,
|
||||
});
|
||||
const laterRunningCall = toolCall({
|
||||
id: 'later-running-call',
|
||||
kind: 'file_change',
|
||||
summary: 'game/src/hero.ts',
|
||||
startedAt: startedAt,
|
||||
updatedAt: startedAt + 1000,
|
||||
status: 'running',
|
||||
// 另一组起点完全不同(5 秒前):两组用时必须各算各的。
|
||||
startedAt: base - 5000,
|
||||
});
|
||||
// 同一轮的第二个工具块(中间夹了思考):共用整轮边界,各自从头读同一个起点。
|
||||
const firstGroup = render(
|
||||
React.createElement(ToolCallGroup, {
|
||||
calls: [runningCall],
|
||||
turnStartedAt: startedAt,
|
||||
calls: [finishedCall],
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
const secondGroup = render(
|
||||
React.createElement(ToolCallGroup, {
|
||||
calls: [finishedCall],
|
||||
turnStartedAt: startedAt,
|
||||
calls: [laterRunningCall],
|
||||
active: true,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
const first = firstGroup.container.querySelector(
|
||||
'[data-testid="agent-tool-call-group"]',
|
||||
) as HTMLElement;
|
||||
const second = secondGroup.container.querySelector(
|
||||
'[data-testid="agent-tool-call-group"]',
|
||||
) as HTMLElement;
|
||||
expect(first.getAttribute('data-duration-ms')).toBe('3000');
|
||||
expect(second.getAttribute('data-duration-ms')).toBe('3000');
|
||||
// 已完成的工具立刻冻结:不跟着整轮时钟继续增长。
|
||||
fireEvent.click(within(second).getByTestId('agent-tool-call-group-head'));
|
||||
const finishedRow = within(second).getByTestId('agent-tool-call-row');
|
||||
expect(finishedRow.getAttribute('data-duration-ms')).toBe('1000');
|
||||
// 运行中的工具按当前时钟继续增长。
|
||||
fireEvent.click(within(first).getByTestId('agent-tool-call-group-head'));
|
||||
const runningRow = within(first).getByTestId('agent-tool-call-row');
|
||||
expect(runningRow.getAttribute('data-duration-ms')).toBe('3000');
|
||||
// 已跑完的那一组:用时固定 2.0 秒,且不再标"进行中"。
|
||||
const finishedHead = within(first).getByTestId(
|
||||
'agent-tool-call-group-head',
|
||||
);
|
||||
expect(first.getAttribute('data-duration-ms')).toBe('2000');
|
||||
expect(finishedHead.textContent).toContain('本组用时 2.0秒');
|
||||
expect(finishedHead.textContent).not.toContain('进行中');
|
||||
// 后开始的那一组:只算自己的 5 秒起点 → 当前时钟,仍标"进行中"。
|
||||
const runningHead = within(second).getByTestId(
|
||||
'agent-tool-call-group-head',
|
||||
);
|
||||
expect(second.getAttribute('data-duration-ms')).toBe('5000');
|
||||
expect(runningHead.textContent).toContain('本组用时 5.0秒');
|
||||
expect(runningHead.textContent).toContain('进行中');
|
||||
// 行级:已完成的工具冻结在 2.0 秒,运行中的工具按当前时钟增长。
|
||||
fireEvent.click(finishedHead);
|
||||
const finishedRow = within(first).getByTestId('agent-tool-call-row');
|
||||
expect(finishedRow.getAttribute('data-duration-ms')).toBe('2000');
|
||||
fireEvent.click(runningHead);
|
||||
const runningRow = within(second).getByTestId('agent-tool-call-row');
|
||||
expect(runningRow.getAttribute('data-duration-ms')).toBe('5000');
|
||||
// 前一组跑完的组不会因为后一组还在跑而继续增长。
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(125000);
|
||||
});
|
||||
expect(first.getAttribute('data-duration-ms')).toBe('2000');
|
||||
expect(second.getAttribute('data-duration-ms')).toBe('130000');
|
||||
expect(runningRow.getAttribute('data-duration-ms')).toBe('130000');
|
||||
|
||||
firstGroup.unmount();
|
||||
secondGroup.unmount();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
/**
|
||||
* 组用时纯函数:只读本组边界、缺边界隐藏、倒序隐藏、一位小数。
|
||||
*/
|
||||
it('computes the group total from this group only, hiding unknown or reversed boundaries', () => {
|
||||
// 本组自己 min(开始) → max(完成)。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: 10_000,
|
||||
updatedAt: 12_000,
|
||||
}),
|
||||
toolCall({
|
||||
id: 'b',
|
||||
kind: 'web_search',
|
||||
startedAt: 11_000,
|
||||
updatedAt: 42_000,
|
||||
}),
|
||||
]),
|
||||
).toEqual({ durationMs: 32_000, durationText: '32.0秒', running: false });
|
||||
// 缺任何开始边界就整组隐藏(已知的单行耗时不受影响)。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: 10_000,
|
||||
updatedAt: 12_000,
|
||||
}),
|
||||
toolCall({ id: 'b', kind: 'web_search', updatedAt: 42_000 }),
|
||||
]),
|
||||
).toEqual({ durationMs: null, durationText: null, running: false });
|
||||
// 已结束却没有终态时间 → 隐藏(不用别组 / 整轮边界补)。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: 10_000,
|
||||
updatedAt: 12_000,
|
||||
}),
|
||||
toolCall({ id: 'b', kind: 'web_search', startedAt: 11_000 }),
|
||||
]),
|
||||
).toEqual({ durationMs: null, durationText: null, running: false });
|
||||
// 倒序 / 非有限值 → 隐藏,不伪造 0.0 秒。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: 12_000,
|
||||
updatedAt: 10_000,
|
||||
}),
|
||||
]).durationText,
|
||||
).toBeNull();
|
||||
// 单条边界先判:一条正常的工具不能把同组另一条倒序的工具掩盖成合法区间。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'ok',
|
||||
kind: 'command',
|
||||
startedAt: 10_000,
|
||||
updatedAt: 12_000,
|
||||
}),
|
||||
toolCall({
|
||||
id: 'reversed',
|
||||
kind: 'web_search',
|
||||
startedAt: 11_000,
|
||||
updatedAt: 10_500,
|
||||
}),
|
||||
]),
|
||||
).toEqual({ durationMs: null, durationText: null, running: false });
|
||||
// 亚 100ms 倒序(1049 → 1001)不能被展示量化抹成 `0.0秒`。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'sub-tick-reversed',
|
||||
kind: 'command',
|
||||
startedAt: 1049,
|
||||
updatedAt: 1001,
|
||||
}),
|
||||
]).durationText,
|
||||
).toBeNull();
|
||||
// 运行中的工具:`now` 早于它自己的起点时整组隐藏,哪怕同组另一条正常工具区间合法。
|
||||
expect(
|
||||
resolveToolGroupTiming(
|
||||
[
|
||||
toolCall({
|
||||
id: 'ok',
|
||||
kind: 'command',
|
||||
startedAt: 9_000,
|
||||
updatedAt: 20_000,
|
||||
}),
|
||||
toolCall({
|
||||
id: 'running-behind-now',
|
||||
kind: 'web_search',
|
||||
status: 'running',
|
||||
startedAt: 10_000,
|
||||
}),
|
||||
],
|
||||
{ running: true, now: 9_500 },
|
||||
),
|
||||
).toEqual({ durationMs: null, durationText: null, running: false });
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: Number.NaN,
|
||||
updatedAt: 10_000,
|
||||
}),
|
||||
]).durationText,
|
||||
).toBeNull();
|
||||
// 运行中的工具:回合在跑时用当前时钟当终点;回合已结束时留着 running 快照也不编造。
|
||||
const running = toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
status: 'running',
|
||||
startedAt: 10_000,
|
||||
});
|
||||
expect(
|
||||
resolveToolGroupTiming([running], { running: true, now: 13_400 }),
|
||||
).toEqual({ durationMs: 3400, durationText: '3.4秒', running: true });
|
||||
expect(
|
||||
resolveToolGroupTiming([running], { running: false, now: 13_400 }),
|
||||
).toEqual({ durationMs: null, durationText: null, running: false });
|
||||
// 合法的同一时刻显示 0.0 秒。
|
||||
expect(
|
||||
resolveToolGroupTiming([
|
||||
toolCall({
|
||||
id: 'a',
|
||||
kind: 'command',
|
||||
startedAt: 10_000,
|
||||
updatedAt: 10_000,
|
||||
}),
|
||||
]).durationText,
|
||||
).toBe('0.0秒');
|
||||
expect(resolveToolGroupTiming([])).toEqual({
|
||||
durationMs: null,
|
||||
durationText: null,
|
||||
running: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
3. 前端事件 reducer 保留工具开始、完成边界以及本轮终态时间;完成边界可附在本轮聊天条目展示元数据上,沿用现有用户条目划分回合,不新增 turnId 或第二套生命周期。无时间旧历史不编造准确总耗时。
|
||||
- 整轮起点:该轮用户消息的实际发送时间优先,缺失才取 `turn.started.at`;与用户要求的“发送到完成”一致,不能改为第一条工具的起点。终点只取明确终态事件,不能取最后一条正文的创建时间或最后工具更新时间。
|
||||
- 只有完成没有开始事件的工具隐藏未知耗时;缺失、0、非有限或倒序边界隐藏,正常运行的零时长显示 `0.0`。
|
||||
4. 视图传递同轮起止时间到各工具块,以共享或局部有界的 100ms 时钟驱动展示,不把整棵 App 状态每 100ms 重建。只在有运行中计时目标时订阅,结束/切项目/卸载清理。
|
||||
4. 回合状态/小结消费整轮起止时间,各工具块仅消费本组工具阶段时间,以局部有界的 100ms 时钟驱动展示,不把整棵 App 状态每 100ms 重建。只在该层有运行中计时目标时订阅,结束/切项目/卸载清理。
|
||||
5. 测试先覆盖静止事件输入下推进时钟、工具完成但回合仍运行、终态与重复事件;补阶段字段与回放时间测试。独立 review 后主 Agent 核对并返修。
|
||||
6. 同视图的追加局部修复:初始消息占位判定应读取实际 Direct 用户条目/乐观用户条目和分页状态,不只检查旧 conversationMessages;增加正式气泡只出现一次、合法同文消息不被去重的回归。
|
||||
|
||||
@@ -34,6 +34,12 @@
|
||||
|
||||
## PR 补审返修(2026-09-18)
|
||||
|
||||
### 用户截图澄清:工具组必须独立计时
|
||||
|
||||
上一版把整轮总耗时复用到各组,导致已完成的前组仍显示进行中并与后组同步增长。按最新主规范返修:整轮总耗时留在回合状态/小结一处;ToolCallGroup 只消费自身 calls 的阶段边界,显示“本组用时”,完成组停止 ticker,运行组与内部运行工具按 100ms 更新。删除不再使用的回合时间 props,更新原先锁定“各组相同总耗时”的错误测试,补前组已完成/后组运行、LLM 仍运行、并行、缺失/倒序边界与独立冻结回归。此为用户明确修正表现语义,不改变原生生命周期或真实发送时间修复。
|
||||
|
||||
已完成并经独立复核:组内每条原始时间边界先校验再聚合,防止单条倒序被其他工具或舍入掩盖。Edge 真实浏览器中前组固定 5.0 秒且无进行中,后组从 2.1 秒增长至 3.0 秒、完成后固定;整轮总耗时仅在回合状态/小结显示。合入主线 `b137f1759` 后 appSurface 463 项通过、17 项跳过,聊天/插件 45 项及发布构建脚本 27 项通过,Rust all-targets check 通过。全量中曾有一条精确用时断言依赖等待 IPC 后重新取 Date.now,已改为读取同身份用户气泡真实发送时间,单独回归通过,不放宽断言。
|
||||
|
||||
用户确认修复补审发现:保留同身份乐观消息的真实发送时间;live 为空时仍向明确的本轮用户条目保存终态边界。先补失败回归,再最小修改原 reducer/回合投影与必要的 App 接线,不新增持久化 schema 或平行回合状态源。取消后迟到终态只补可验证测试,不能仅凭时间大小改变现役事件顺序合同。独立复核后运行定向与集成验证。本次 fix 不自动授权新的远程写入。
|
||||
|
||||
补审返修中已复现 bootstrap 只有生命周期事件、用户消息经 history slice 回读且 live 恒为空的缺口。前端无法证明归属,因此批准原生在 turn.started/turn.completed 上透传现有用户条目 userItemId;沿用原事件与已有消息身份,不另造 turnId。前端按精确 itemId 补时间边界,移除按历史尾项或时间戳相等猜测用户归属的兜底;完成先于历史到达时,在历史回读后同样按该身份补齐。原生与 TypeScript 绑定同批更新并验证重放。
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
|
||||
## 目标与边界
|
||||
|
||||
工具组显示整轮请求总耗时,单条显示自身调用耗时,运行中均以 100 毫秒粒度动态增长并保留一位小数。沿用 Thread Manager,不新增业务状态源或计时账本。保留既有画布功能和本地未提交锁文件;用户后续已授权更新 Issue/PR 与推送本分支,不合并 PR、不发布、不写飞书、不调用真实付费 Provider。
|
||||
整轮总耗时只在回合状态/小结显示一次,工具组显示本组历时,单条显示自身调用耗时;运行中均以 100 毫秒粒度动态增长并保留一位小数,各自完成后固定。沿用 Thread Manager,不新增业务状态源或计时账本。保留既有画布功能和本地未提交锁文件;不自动合并 PR、发布、写飞书或调用真实付费 Provider。
|
||||
|
||||
## 验收
|
||||
|
||||
前置独立评审已完成:事件级时间与条目展示时间分离、缺失/倒序判据、章节结构及格式示例已明确。主 Agent 决定整轮起点以用户发送为优先、原生 turn.started 为缺失兜底,不采用会排除发送后准备时间的起点替换。
|
||||
|
||||
- [x] 总耗时从用户发送到回合终态,跨多个工具块共享边界,包含无工具运行时的 LLM 等待。
|
||||
- [x] 总耗时从用户发送到回合终态,仅显示一次并包含无工具运行时的 LLM 等待;各工具组单独计时,前组完成后不得跟随后组增长(组件回归与 Edge 模拟事件验证)。
|
||||
- [x] 工具运行中无事件亦增长,已完成工具与完成回合均冻结,失败/终止通过既有明确收口路径冻结。
|
||||
- [x] 原生开始/完成时间不混用,事件重放不重新取当前时间。
|
||||
- [x] 重复、迟到条目不抬高已冻结用时;缺失/倒序时间不伪造耗时。同秒新回合仍按事件顺序正常开始。
|
||||
|
||||
@@ -11,21 +11,22 @@
|
||||
|
||||
## 一句话交付
|
||||
|
||||
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,顶部显示整轮请求总耗时,内部工具显示各自耗时;运行中的计时动态增长并保留一位小数。
|
||||
把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」改成可折叠卡片,整轮总耗时只在回合状态/完成小结显示一处,工具组与内部工具分别显示各自耗时;运行中的计时动态增长并保留一位小数。
|
||||
|
||||
## 总耗时与动态工具计时
|
||||
|
||||
- 工具块顶部的“总耗时”表示同一轮用户请求从发送到回合终态的墙钟跨度,包含 LLM 推理、工具调用和等待;不是工具耗时之和,也不是当前工具块的时间跨度。思考或正文将工具拆成多组时,各组读取同一轮的时间边界,不能各自重置起点。
|
||||
- 回合仍在运行时,即使当前工具块内的工具已全部结束,总耗时仍持续增加。成功、失败或终止到达后按实际终态时间固定;随后打开折叠块、翻页或其它回合的新事件不得继续改变该轮用时。
|
||||
- “总耗时”表示同一轮用户请求从发送到回合终态的墙钟跨度,包含 LLM 推理、工具调用和等待;只在本轮运行状态或完成小结显示,不重复放进各工具组。
|
||||
- 工具组顶部显示“本组用时”,范围是本组首个工具开始到最后一个工具完成,包含组内等待但不是工具耗时之和。不同工具组独立计时;本组全部终态后立即固定,即使本轮或后面的工具组仍运行,也不能显示“进行中”或继续增加。组头不再展示容易与整轮混淆的发送/结束时刻,仅展示本组用时;不能沿用用户发送时间计算本组。
|
||||
- 回合仍运行但全部工具暂时结束时,只增长本轮总耗时。成功、失败或终止到达后整轮按实际终态时间固定;随后打开折叠块、翻页或其它回合的新事件不得改变已完成的组或回合用时。
|
||||
- 单条工具从其实际开始到完成计时。运行中的工具按当前时间持续增长,不能把最近一次快照更新时间当作当前时间;已经结束的工具必须立即固定,即使同组其它工具或 LLM 仍在运行。
|
||||
- 两种耗时均每 100 毫秒刷新,始终保留一位小数(例如 `0.0秒`、`5.1秒`、`1分钟 2.3秒`,单条可沿用 `5.1s` / `1m 2.3s` 的紧凑格式)。以时间戳计算而不是按 tick 累加,避免后台节流后的累计漂移;缺失或倒序边界不伪造 `0.0`。
|
||||
- 时间范围与总耗时使用同一轮的起止边界。运行中显示“进行中”;完成后如果展示起止时间,其精度不得造成范围差与总耗时矛盾。
|
||||
- 各层时间范围与对应耗时使用相同的起止边界。运行标记也按本层状态判定;完成后如果展示起止时间,其精度不得造成范围差与用时矛盾。组内存在缺失或倒序的工具边界时,不能用其他组或整轮的时间补造本组耗时。
|
||||
- 原生事件保留各阶段的时间语义:开始与完成不能都优先折叠成开始时间。优先采用上游明确提供的阶段时间或实际调用时长,缺失时使用宿主观察该阶段的时间;重放沿用原事件时间,不能在前端收到或重放时重新取当前时间。
|
||||
- 工具开始/完成的权威边界是事件级 `at`,不是条目展示字段 `item.at`。整轮起点优先采用该轮实际用户消息的发送时间(与气泡一致,不取所有条目的最小时间),缺失时采用原生 `turn.started.at`;终点只采用 `turn.completed.at` 或明确的终止/失败收口事件。首次补到更早的真实发送时间可以校正起点,但旧历史或重复事件不能覆盖已经固定的终点。
|
||||
- 回合阶段使用宿主观测阶段的毫秒时间,或上游明确的毫秒边界/可靠时长;不把上游已截断的整秒时间冒充十分之一秒精度。回合是否开始以 Thread Manager 的事件顺序为准,不能用时间戳大小拒绝取消后同一秒内的新请求。回合完成的展示时间在当前会话中冻结;重新打开后若历史未保存完整生命周期边界,则隐藏未知用时而非补造。
|
||||
- 无效边界包括缺字段、非有限值、时间戳为 0、结束早于开始;这些情况隐藏不能证明的耗时。合法开始时刻的运行中 `0.0秒` / `0.0s` 则正常显示。只有完成快照而没有开始事件的工具不猜测开始时间。
|
||||
- 沿用 Thread Manager 的生命周期与条目身份,不引入第二套回合状态源、计时账本、远程 API 或数据库字段。前端仅保存原事件的展示时间边界;旧历史缺少完整边界时不声称知道精确总耗时或工具耗时,不为补齐计时启动模型请求。
|
||||
- 必须覆盖:两组工具共用整轮起点、无新事件仍增长、工具完成后 LLM 继续、并行工具分别计时、完成/失败/终止冻结、同身份重放不改终态时间、切项目与卸载停止计时、缺失与倒序时间、0.0 / 59.9 / 60.0 秒边界。真实 Provider 验证与模拟事件的客户端验证分开报告。
|
||||
- 必须覆盖:前组已完成而后组运行时各自时间独立、整轮总耗时仅出现一次、无新事件仍增长、工具完成后 LLM 继续、并行工具分别计时、完成/失败/终止冻结、同身份重放不改终态时间、切项目与卸载停止计时、缺失与倒序时间、0.0 / 59.9 / 60.0 秒边界。真实 Provider 验证与模拟事件的客户端验证分开报告。
|
||||
- Direct 对话的初始消息占位仅在正式用户消息尚未进入当前显示链路时显示;正式条目或乐观用户条目出现后撤下占位,不在消息列表外额外保留一份。只控制占位是否显示,不按文本合并或删除用户真实重复发送的消息;分页已有更早历史时不把初始占位补到当前页。
|
||||
- 同一用户消息从本地发送转为正式条目时必须保留原发送时间,不能因去重丢掉该时间而改用启动应答后的观测时间。回合明确结束时,即使当前 live 集合为空,也应将终态边界关联到已知的本轮用户条目;不得仅按“历史最后一项”猜测或向无关旧回合补时间。
|
||||
- 生命周期事件可携带已有用户条目的 `userItemId`,用于把时间边界精确关联到同一条历史消息(不产生新的回合 ID 或持久化字段)。恢复时该关联随 Thread Manager 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。
|
||||
@@ -91,11 +92,10 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
|
||||
```html
|
||||
<section class="agent-tool-call-group" data-testid="agent-tool-call-group" data-status="completed">
|
||||
<button type="button" class="agent-tool-call-group-head" aria-expanded="false" aria-controls="…"
|
||||
aria-label="已执行 2 个命令、1 个文件变更,总耗时 42.0秒">
|
||||
aria-label="已执行 2 个命令、1 个文件变更,本组用时 42.0秒">
|
||||
<span class="agent-tool-call-group-icon" aria-hidden="true"></span>
|
||||
<span class="agent-tool-call-group-summary">已执行 2 个命令、1 个文件变更</span>
|
||||
<span class="agent-tool-call-group-time">14:20:05.0 → 14:20:47.0</span>
|
||||
<span class="agent-tool-call-group-duration">总耗时 42.0秒</span>
|
||||
<span class="agent-tool-call-group-duration">本组用时 42.0秒</span>
|
||||
<svg class="agent-tool-call-group-chevron" aria-hidden="true"></svg>
|
||||
</button>
|
||||
<div class="agent-tool-call-group-body" hidden>
|
||||
@@ -122,8 +122,8 @@ DirectRuntime 写 `<projectRoot>/.agent/conversations/tool-calls.jsonl`;回读
|
||||
- 文案规则(按 kind,不允许自由发挥):
|
||||
- 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `已执行 5 个命令、2 个文件变更`;空集合不渲染块。
|
||||
- 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”。
|
||||
- 耗时:执行“总耗时与动态工具计时”合同。块头是整轮请求的总耗时,单条是该工具独立耗时,均保留一位小数;运行中每 100 毫秒刷新,终态固定,不按工具更新时间推断回合结束。
|
||||
- 时间:时间范围与总耗时使用同一回合边界,不再把用户发送起点与局部工具组终点混搭。缺失的历史时间不编造。
|
||||
- 耗时:执行“总耗时与动态工具计时”合同。块头是本组用时,单条是该工具独立耗时,整轮总耗时只在本轮状态/小结显示;均保留一位小数,运行中每 100 毫秒刷新,各自终态固定。
|
||||
- 时间:范围与对应层级用时采用同一边界,不把用户发送起点与局部工具组终点混搭。缺失的历史时间不编造。
|
||||
- 回合结束时间与耗时在正文下方右对齐;Direct 对话输入框提示统一为“描述你的想法,或 @ 引用素材”,引用按钮保留输入盒的 12px 内边距,不使用负边距贴边。
|
||||
- 当前 Agent 与策划 Agent 共用 `packages/shared` 的 `AgentMessageContent` 表现组件:正文为 14px / `--platform-text-strong`,思考、中间输出和工具调用为 12px / `--platform-text-soft`。实时与历史思考共用同一个折叠入口;工具输入输出继承过程色,失败状态保留错误色。Markdown 标题、表格及代码高亮在过程区同步弱化,最终回复和文档预览仍保留正常排版,不按 Agent 类型复制样式。输入提示与禁用状态保持原有反馈。
|
||||
- Windows 命令展示:仅 `command` 卡片识别 `pwsh` / `powershell`(含完整路径、`.exe`、常见启动选项)的 `-Command` / `-c` 外层包装,摘要和展开输入只展示脚本正文,并解开单个 shell 参数的引用拼接。摘要优先读取已脱敏的 `detail.command`,再按首行 120 字符截断,避免历史摘要被可执行文件路径占满。无法识别的启动方式、`-File`、`-EncodedCommand`、普通命令和 MCP 输入原样展示;执行参数、持久化原文、脱敏和输出均不改变。
|
||||
|
||||
Reference in New Issue
Block a user