修复对话动态计时与初始消息重复并补齐游戏居中指引
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 5m45s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 5m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m4s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m38s
Project CI / Repository checks (pull_request) Successful in 5m39s
Project CI / Frontend tests (pull_request) Successful in 7m45s
Project CI / Backend tests (pull_request) Successful in 10m3s
Project CI / Native shell tests (pull_request) Successful in 8m54s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m31s

区分整轮总耗时与工具实际阶段时间,以百毫秒刷新一位小数并冻结终态。
修复正式与乐观用户消息到达时初始占位重复,保留真实同文多次发送。
在开发 Agent 系统提示与 Skill 明确禁止 Phaser 和 CSS 双重居中。
补齐原生事件、前端回归与验收文档,保留未完成项和远程 CI 风险。
This commit is contained in:
2026-09-18 12:17:28 +08:00
parent 1bd153d7f2
commit 2ffccb844c
27 changed files with 2010 additions and 361 deletions
@@ -14,6 +14,9 @@ Implement the user's actual game request in the current project as an npm-manage
3. Build with the project's npm script before previewing. The playable entry is the package directory's `dist/index.html`; never report an unbuilt bare-module page as playable. Import assets or configure public assets so all runtime media is included in dist; preview and exports cannot read outside it.
4. Build a complete playable loop: visible objective, responsive input, meaningful state changes, success or failure feedback, and a reliable restart path where the game needs one.
5. Fit the active game scene to desktop and mobile viewports without accidental page scrollbars. Reserve deliberate safe space for HUD elements instead of covering interactive content.
- **画布居中只能由一处负责。** 使用 `Phaser.Scale.FIT``autoCenter: Phaser.Scale.CENTER_BOTH` 时,canvas 的直接父容器应使用尺寸明确的普通块布局,不再对同一 canvas 叠加 Grid/Flex 居中、`place-items: center`、自动外边距或居中 transform。Phaser 自动计算的 margin 与 CSS 居中叠加会使竖屏画面向右偏移。
- 若决定由 CSS 居中,则显式使用 `autoCenter: Phaser.Scale.NO_CENTER`,由 CSS 独立完成定位;外围页面可以继续使用 Grid/Flex,限制只针对同一 canvas 的重复定位。
- 出现偏移先检查游戏自身的 CSS 与 Phaser scale 配置,不添加 AGC 预览容器固定偏移补偿。修改布局后重新构建 dist,在桌面、移动及窗口 resize 后检查 canvas 相对游戏父容器居中(误差不超过 1 CSS px)、画面完整且无意外滚动条;不能仅凭 build 成功宣称布局通过。
6. Invoke `taonier-art-assets` for every new game brief that needs visual assets. First reuse suitable registered Taonier art; when the brief's required visual elements are missing or unsuitable, call the reviewed `agc_tools` generation/edit workflow in the same task. After the tool returns, wire its relative paths into the game and verify the rendered result. A game with unused generated assets or placeholder emoji/CSS where requested art should appear is not complete. Load media defensively only for genuinely optional effects, and never relabel a local placeholder as platform art.
7. Let Phaser own the render loop and input dispatch. Avoid duplicate scenes, stale event listeners, and state that survives restart unintentionally.
8. After a meaningful game change, use the browser playtest Skill and fix issues shown by real evidence before reporting completion.
@@ -7,5 +7,7 @@
- Score, steps, health, timer, or other core state updates consistently.
- Restart restores all state and does not duplicate timers, animation loops, or event listeners.
- Desktop and mobile layouts keep the core scene visible without accidental document scrolling.
- 画布的缩放与居中由 Phaser 或 CSS 中的一方独立负责。`FIT + CENTER_BOTH` 不与同一 canvas 父容器的 Grid/Flex 居中、自动外边距或居中 transform 叠加;使用 CSS 居中时关闭 Phaser 自动居中(`NO_CENTER`)。
- 在构建后的实际页面检查桌面、移动和 resize:比较 canvas 与游戏父容器的中心,预期居中时水平/垂直误差不超过 1 CSS px,并检查画面没有溢出或意外滚动条。偏移先修游戏 CSS/scale 配置,不用修改 AGC 预览位置掩盖。
- HUD and overlays reserve space and do not cover essential interactive content.
- Requested Taonier art is visibly integrated into the core experience when available.
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.24",
"version": "2026-08-26.25",
"skills": [
{
"name": "agc-game-production-workflow",
@@ -80,7 +80,7 @@
"agents/openai.yaml",
"references/game-quality-checklist.md"
],
"sha256": "05b5cfbf7a40fd303717491f5cea84ff339a73359c9678b283fd54d2b5c45efd"
"sha256": "e122d8f3a6d986b594b95c971754d68197bf7896912fa8267d44a7aa129a57ba"
},
{
"name": "agc-browser-playtest",
@@ -3046,13 +3046,24 @@ impl CodexAppServerConnection {
};
turn_start_guard.armed = false;
let direct_thread_id = direct_thread_id_for_project(history_root);
// 回合边界的阶段时间:Turn 上游只有**秒**级 `startedAt` / `completedAt`,秒级截断
// 撑不起前端 0.1 秒粒度的展示,也可能让完成时刻落进该轮用户消息的同一秒、落在真实
// 发送时间之前,被判成无效边界后整轮新回合被吞掉。因此这里只在宿主处理对应阶段时取
// 毫秒钟(与条目侧"没有原生阶段时间就用宿主钟"同一口径),不再读上游秒字段。
let direct_turn_started_at_ms = direct_tool_call_now_ms();
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_started(direct_turn_started_at_ms),
);
if let Some(user_item) = direct_persisted_user_item.as_ref() {
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
// 用户消息由 AGC 自己落盘,条目时间就是真实发送时间:事件级 `at` 直接
// 复用这一条条目的时间,不另取宿主钟。
let user_item_at = entry_item.at();
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
DirectThreadEvent::item_completed(entry_item, user_item_at),
);
}
}
@@ -3191,9 +3202,14 @@ impl CodexAppServerConnection {
.map_err(platform_llm::LlmError::InvalidRequest)?;
direct_project_history.complete_item(&item);
if let Some(entry_item) = entry_item {
// `rawResponseItem/completed` 不带阶段时间,宿主处理到这条
// 通知的钟就是该阶段唯一可证明的时间。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_completed(entry_item),
DirectThreadEvent::item_completed(
entry_item,
direct_tool_call_now_ms(),
),
);
}
}
@@ -3322,9 +3338,19 @@ impl CodexAppServerConnection {
if let Some(entry_item) =
direct_thread_visible_item(history_root, item)
{
// `item/started` 的通知层带 `startedAtMs`:这是工具真正
// 开始的阶段时间,优先于条目展示时间与宿主钟。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::item_started(entry_item),
DirectThreadEvent::item_started(
entry_item,
direct_thread_item_event_at_ms(
&params,
item,
false,
direct_tool_call_now_ms(),
),
),
);
}
}
@@ -3367,9 +3393,18 @@ impl CodexAppServerConnection {
&& matches!(status, "completed" | "interrupted" | "failed")
{
terminal_recorded = true;
// 终态时间:`durationMs` 与宿主记下的毫秒起点都可靠时才派生,
// 否则取宿主处理这条终态的钟;上游秒级 `completedAt` 一律不用。
append_direct_thread_event(
&direct_thread_id,
DirectThreadEvent::turn_completed(status.to_string()),
DirectThreadEvent::turn_completed(
status.to_string(),
direct_thread_turn_completed_at_ms(
turn,
Some(direct_turn_started_at_ms),
direct_tool_call_now_ms(),
),
),
);
}
match status {
@@ -3426,6 +3461,9 @@ impl CodexAppServerConnection {
"failed"
}
.to_string(),
// 这条兜底终态没有对应的 app-server 终态载荷,只能取宿主处理它的钟,
// 不能拿最后一次正文或工具更新时间当回合终点。
direct_tool_call_now_ms(),
),
);
}
@@ -3680,7 +3718,7 @@ pub(crate) fn cancel_direct_codex_turn_at(
// 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。
append_direct_thread_event(
&direct_thread_id_for_project(root),
DirectThreadEvent::turn_completed("aborted".to_string()),
DirectThreadEvent::turn_completed("aborted".to_string(), direct_tool_call_now_ms()),
);
Ok(DirectTurnCancelView {
outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(),
@@ -5006,6 +5044,80 @@ mod tests {
));
}
/// 阶段时间取自**通知层**字段,形状照抄 codex-cli 0.147 / 0.155 的 v2 协议 schema
/// `item/started` 带 `startedAtMs`、`item/completed` 带 `completedAtMs`(毫秒),
/// `turn/completed` 带 `turn.startedAt` / `turn.completedAt`(秒)与 `turn.durationMs`(毫秒)。
/// 分类函数把 params 原样交给事件级 `at` 的投影函数,所以字段位置必须在这里钉住;
/// 回合边界的秒字段按"不用"锁在这里,避免以后有人再把秒级截断当 0.1 秒精度。
#[test]
fn direct_lifecycle_stage_times_come_from_notification_params() {
let started = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"startedAtMs": 1_700_000_000_123u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
let completed = serde_json::json!({
"threadId": "thread-1",
"turnId": "turn-1",
"completedAtMs": 1_700_000_001_500u64,
"item": {"id": "call-1", "type": "commandExecution", "command": "ls"},
});
for (method, params, expected_at_ms) in [
("item/started", &started, 1_700_000_000_123u64),
("item/completed", &completed, 1_700_000_001_500u64),
] {
let Some(CodexTurnEvent::Item {
completed,
params: event_params,
}) = direct_codex_notification_event(method, params, None, None, "turn-1")
else {
panic!("{method} 必须分类成条目生命周期事件");
};
let item = event_params.get("item").expect("item payload");
assert_eq!(
direct_thread_item_event_at_ms(&event_params, item, completed, 9_999),
expected_at_ms,
"{method} 必须用通知层的阶段时间,而不是宿主钟"
);
}
let terminal = serde_json::json!({
"threadId": "thread-1",
"turn": {
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
},
});
let Some(CodexTurnEvent::Terminal(params)) =
direct_codex_notification_event("turn/completed", &terminal, None, None, "turn-1")
else {
panic!("turn/completed 必须分类成终态事件");
};
let turn = params.get("turn").unwrap_or(&params);
assert_eq!(
direct_thread_turn_completed_at_ms(turn, Some(1_700_000_000_500), 9_999),
9_999,
"上游只有秒级 completedAt:不采用,取宿主处理终态的毫秒钟"
);
let with_duration = serde_json::json!({
"id": "turn-1",
"items": [],
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
"durationMs": 42_500u64,
});
assert_eq!(
direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999),
1_700_000_043_000,
"durationMs + 宿主高精度起点才派生结束"
);
}
fn test_llm() -> GameCreatorLlmConfig {
GameCreatorLlmConfig {
custom_enabled: false,
@@ -6744,8 +6856,8 @@ done
let mut assistant_items = Vec::new();
for event in &consumed.events {
let item = match event {
DirectThreadEvent::ItemStarted { item }
| DirectThreadEvent::ItemCompleted { item } => item,
DirectThreadEvent::ItemStarted { item, .. }
| DirectThreadEvent::ItemCompleted { item, .. } => item,
_ => continue,
};
match item {
File diff suppressed because one or more lines are too long
@@ -427,12 +427,16 @@ mod tests {
}
}
/// 事件级阶段时间只在重放稳定性用例里逐个指定;其余用例用一个固定值即可,
/// 它们断言的是队列 / 游标语义,不是时间本身。
const FIXED_AT_MS: u64 = 1_000;
fn item_started(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_started(message(item_id))
DirectThreadEvent::item_started(message(item_id), FIXED_AT_MS)
}
fn item_completed(item_id: &str) -> DirectThreadEvent {
DirectThreadEvent::item_completed(message(item_id))
DirectThreadEvent::item_completed(message(item_id), FIXED_AT_MS)
}
fn item_delta(item_id: &str) -> DirectThreadEvent {
@@ -450,7 +454,7 @@ mod tests {
#[test]
fn subscribers_have_independent_cursors_on_one_global_queue() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started());
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
let first = manager.subscribe("thread-1");
let second = manager.subscribe("thread-1");
manager.append("thread-1", item_started("item-1"));
@@ -474,7 +478,7 @@ mod tests {
#[test]
fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started());
manager.append("thread-1", DirectThreadEvent::turn_started(FIXED_AT_MS));
manager.append("thread-1", item_started("item-1"));
manager.append("thread-1", item_delta("item-1"));
manager.append("thread-1", item_completed("item-1"));
@@ -484,7 +488,7 @@ mod tests {
assert!(matches!(
bootstrap.events.as_slice(),
[
DirectThreadEvent::TurnStarted {},
DirectThreadEvent::TurnStarted { .. },
DirectThreadEvent::ItemStarted { item, .. },
] if item.item_id() == "item-2"
));
@@ -589,15 +593,71 @@ mod tests {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append(
"thread-1",
DirectThreadEvent::turn_completed("completed".to_string()),
DirectThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS),
);
let bootstrap = manager.subscribe("thread-1");
assert!(matches!(
bootstrap.events.as_slice(),
[DirectThreadEvent::TurnCompleted { status }] if status == "completed"
[DirectThreadEvent::TurnCompleted { status, at }]
if status == "completed" && *at == Some(FIXED_AT_MS)
));
}
/// 阶段时间必须随事件一起进队列:bootstrap 与重复订阅都拿到**原值**,
/// 重放不得重新取钟(否则每次重连都会把已固定的起止时间改掉)。
#[test]
fn replayed_events_keep_their_original_stage_time() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
manager.append("thread-1", DirectThreadEvent::turn_started(1_000));
manager.append(
"thread-1",
DirectThreadEvent::item_started(message("item-1"), 2_000),
);
let first = manager.subscribe("thread-1");
assert_eq!(
first
.events
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(1_000), Some(2_000)]
);
// 第二个订阅看到的是同一份事件,时间不因"又取了一次当前时间"而漂移。
let second = manager.subscribe("thread-1");
assert_eq!(second.events, first.events);
manager.append(
"thread-1",
DirectThreadEvent::item_completed(message("item-1"), 3_000),
);
let completion = manager
.consume(&first.subscription_id)
.expect("consume completion")
.events;
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
// 重复消费不产生新事件,也不改写已下发过的时间。
assert!(manager
.consume(&first.subscription_id)
.expect("empty consume")
.events
.is_empty());
assert_eq!(
completion
.iter()
.map(DirectThreadEvent::at)
.collect::<Vec<_>>(),
vec![Some(3_000)]
);
}
#[test]
fn queue_cleanup_only_removes_a_cleanable_prefix() {
let mut manager = DirectThreadManager::with_limits(100, 100_000);
@@ -157,6 +157,23 @@ impl DirectThreadItem {
| Self::Other { item_id, .. } => item_id,
}
}
/// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界;
/// 那两类边界用事件级 `at`。
pub(crate) fn at(&self) -> u64 {
match self {
Self::Message { at, .. }
| Self::Reasoning { at, .. }
| Self::FunctionCall { at, .. }
| Self::FunctionCallOutput { at, .. }
| Self::CommandExecution { at, .. }
| Self::FileChange { at, .. }
| Self::McpToolCall { at, .. }
| Self::WebSearch { at, .. }
| Self::ContextCompaction { at, .. }
| Self::Other { at, .. } => *at,
}
}
}
/// 增量正文属于哪类条目。
@@ -201,18 +218,50 @@ impl DirectThreadRequestKind {
///
/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由
/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。
///
/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`
/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的
/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。
/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟;
/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主
/// 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。
/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值,
/// 不能在前端收到或重放时重新取当前时间。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) enum DirectThreadEvent {
#[serde(rename = "turn.started")]
TurnStarted,
TurnStarted {
/// 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
},
#[serde(rename = "turn.completed")]
TurnCompleted { status: String },
TurnCompleted {
status: String,
/// 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
},
#[serde(rename = "item.started")]
ItemStarted { item: DirectThreadItem },
ItemStarted {
item: DirectThreadItem,
/// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
},
#[serde(rename = "item.completed")]
ItemCompleted { item: DirectThreadItem },
ItemCompleted {
item: DirectThreadItem,
/// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional, as = "Option<f64>")]
at: Option<u64>,
},
#[serde(rename = "item.delta")]
ItemDelta {
item_id: String,
@@ -228,20 +277,23 @@ pub(crate) enum DirectThreadEvent {
}
impl DirectThreadEvent {
pub(crate) fn turn_started() -> Self {
Self::TurnStarted
pub(crate) fn turn_started(at: u64) -> Self {
Self::TurnStarted { at: Some(at) }
}
pub(crate) fn turn_completed(status: String) -> Self {
Self::TurnCompleted { status }
pub(crate) fn turn_completed(status: String, at: u64) -> Self {
Self::TurnCompleted {
status,
at: Some(at),
}
}
pub(crate) fn item_started(item: DirectThreadItem) -> Self {
Self::ItemStarted { item }
pub(crate) fn item_started(item: DirectThreadItem, at: u64) -> Self {
Self::ItemStarted { item, at: Some(at) }
}
pub(crate) fn item_completed(item: DirectThreadItem) -> Self {
Self::ItemCompleted { item }
pub(crate) fn item_completed(item: DirectThreadItem, at: u64) -> Self {
Self::ItemCompleted { item, at: Some(at) }
}
pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self {
@@ -256,6 +308,19 @@ impl DirectThreadEvent {
Self::Request { kind, request_id }
}
/// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。
///
/// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。
pub(crate) fn at(&self) -> Option<u64> {
match self {
Self::TurnStarted { at }
| Self::TurnCompleted { at, .. }
| Self::ItemStarted { at, .. }
| Self::ItemCompleted { at, .. } => *at,
Self::ItemDelta { .. } | Self::Request { .. } => None,
}
}
/// 事件关联的条目身份:只有 item 事件有。
pub(crate) fn item_id(&self) -> Option<&str> {
match self {
@@ -392,6 +457,63 @@ fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 {
observed_at_ms
}
/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。
fn json_ms(container: &Value, key: &str) -> Option<u64> {
container
.get(key)
.and_then(Value::as_u64)
.filter(|value| *value > 0)
}
/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。
///
/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`
/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时
/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按
/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。
pub(crate) fn direct_thread_item_event_at_ms(
params: &Value,
item: &Value,
completed: bool,
observed_at_ms: u64,
) -> u64 {
let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs"));
if !completed {
return started_ms().unwrap_or(observed_at_ms);
}
if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) {
return at;
}
let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs"));
match (started_ms(), duration_ms) {
(Some(started), Some(duration)) => started.saturating_add(duration),
_ => observed_at_ms,
}
}
/// `turn.completed` 的事件级阶段时间(毫秒)。
///
/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带
/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端
/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的
/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被
/// 整轮吞掉。因此这里不采用任何秒字段:
/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束;
/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。
///
/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started`
/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。
pub(crate) fn direct_thread_turn_completed_at_ms(
turn: &Value,
high_precision_started_at_ms: Option<u64>,
observed_at_ms: u64,
) -> u64 {
match (high_precision_started_at_ms, json_ms(turn, "durationMs")) {
(Some(started), Some(duration)) => started.saturating_add(duration),
_ => observed_at_ms,
}
}
/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。
pub(crate) fn direct_thread_item_identity(item: &Value) -> Option<String> {
let call_id = item
@@ -869,4 +991,231 @@ mod tests {
DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan"
));
}
/// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界,
/// 后者只说明条目什么时候被看到。
#[test]
fn event_stage_time_is_independent_from_item_display_time() {
let params = json!({
"completedAtMs": 2_000u64,
"item": {
"id": "call-1",
"type": "commandExecution",
"command": "ls",
"startedAtMs": 1_000u64,
},
});
let item = direct_thread_item_from_value(root(), &params["item"], 7_777).expect("item");
// 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。
assert_eq!(item.at(), 1_000);
assert_eq!(
direct_thread_item_event_at_ms(&params, &params["item"], true, 7_777),
2_000
);
}
#[test]
fn item_started_event_at_uses_notification_stage_time() {
// 通知层 `startedAtMs` 优先于条目自带的同名字段。
let params = json!({
"threadId": "thread-1",
"turnId": "turn-1",
"startedAtMs": 1_700_000_000_123u64,
"item": {
"id": "call-1",
"type": "commandExecution",
"startedAtMs": 1_700_000_000_000u64,
},
});
assert_eq!(
direct_thread_item_event_at_ms(&params, &params["item"], false, 9_999),
1_700_000_000_123
);
}
#[test]
fn item_event_at_falls_back_to_nested_item_then_host_clock() {
let nested = json!({
"item": {
"id": "call-1",
"type": "commandExecution",
"startedAtMs": 1_700_000_000_500u64,
},
});
assert_eq!(
direct_thread_item_event_at_ms(&nested, &nested["item"], false, 9_999),
1_700_000_000_500
);
// 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。
let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}});
assert_eq!(
direct_thread_item_event_at_ms(&bare, &bare["item"], false, 9_999),
9_999
);
assert_eq!(
direct_thread_item_event_at_ms(&bare, &bare["item"], true, 9_999),
9_999
);
}
#[test]
fn item_completed_event_at_prefers_completion_over_start() {
// 通知层两个字段都在时必须取完成时间,不能退回开始时间。
let params = json!({
"startedAtMs": 1_000u64,
"completedAtMs": 2_000u64,
"durationMs": 1_000u64,
"item": {"id": "call-1", "type": "commandExecution"},
});
assert_eq!(
direct_thread_item_event_at_ms(&params, &params["item"], true, 9_999),
2_000
);
// 完成时间只在条目里:同样取完成时间。
let nested = json!({
"item": {
"id": "call-1",
"type": "commandExecution",
"startedAtMs": 1_000u64,
"completedAtMs": 2_500u64,
},
});
assert_eq!(
direct_thread_item_event_at_ms(&nested, &nested["item"], true, 9_999),
2_500
);
}
#[test]
fn item_completed_event_at_derives_end_only_with_reliable_start() {
let with_start = json!({
"item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64},
});
assert_eq!(
direct_thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999),
1_250
);
// 只有时长不足以证明结束时刻:回落到宿主钟。
let duration_only = json!({
"item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64},
});
assert_eq!(
direct_thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999),
9_999
);
}
/// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示,
/// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。
/// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。
#[test]
fn turn_completed_at_ignores_second_truncated_upstream_fields() {
let seconds_only = json!({
"id": "turn-1",
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
});
assert_eq!(
direct_thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999),
9_999,
"没有 durationMs 时用宿主钟,不换算秒字段"
);
assert_eq!(
direct_thread_turn_completed_at_ms(&seconds_only, None, 9_999),
9_999
);
}
#[test]
fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() {
let with_duration = json!({
"id": "turn-1",
"status": "completed",
"startedAt": 1_700_000_000i64,
"completedAt": 1_700_000_042i64,
"durationMs": 42_500u64,
});
// 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。
assert_eq!(
direct_thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999),
1_700_000_043_000
);
// 起点不可证明时不派生。
assert_eq!(
direct_thread_turn_completed_at_ms(&with_duration, None, 9_999),
9_999
);
// 时长为 0 同样按缺失处理。
let zero_duration = json!({"durationMs": 0u64});
assert_eq!(
direct_thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999),
9_999
);
}
/// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时
/// 反序列化仍成立,且不会序列化出 `at: null`。
#[test]
fn lifecycle_events_serialize_event_level_at_as_optional_number() {
let started = serde_json::to_value(DirectThreadEvent::turn_started(1_700_000_000_123))
.expect("serialize turn.started");
assert_eq!(
started,
json!({"type": "turn.started", "at": 1_700_000_000_123u64})
);
assert_eq!(
serde_json::from_value::<DirectThreadEvent>(started).expect("round trip"),
DirectThreadEvent::turn_started(1_700_000_000_123)
);
let completed = serde_json::to_value(DirectThreadEvent::turn_completed(
"completed".to_string(),
2_000,
))
.expect("serialize turn.completed");
assert_eq!(
completed,
json!({"type": "turn.completed", "status": "completed", "at": 2_000u64})
);
let item = DirectThreadItem::CommandExecution {
item_id: "call-1".to_string(),
command: "ls".to_string(),
output: None,
status: Some("completed".to_string()),
exit_code: None,
at: 1_500,
};
let item_started =
serde_json::to_value(DirectThreadEvent::item_started(item.clone(), 1_000))
.expect("serialize item.started");
assert_eq!(item_started["at"], json!(1_000u64));
// 事件级 `at` 不动条目自己的展示时间。
assert_eq!(item_started["item"]["at"], json!(1_500u64));
let item_completed = serde_json::to_value(DirectThreadEvent::item_completed(item, 2_000))
.expect("serialize item.completed");
assert_eq!(item_completed["at"], json!(2_000u64));
// 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。
let legacy: DirectThreadEvent = serde_json::from_value(json!({"type": "turn.started"}))
.expect("legacy turn.started without at");
assert_eq!(legacy, DirectThreadEvent::TurnStarted { at: None });
assert_eq!(legacy.at(), None);
assert_eq!(
serde_json::to_value(legacy).expect("serialize legacy"),
json!({"type": "turn.started"})
);
assert_eq!(
serde_json::to_value(DirectThreadEvent::request(
DirectThreadRequestKind::RequestResolved,
None,
))
.expect("serialize request"),
json!({"type": "request", "kind": "request.resolved", "requestId": null})
);
}
}
+8 -2
View File
@@ -242,6 +242,7 @@ import {
applyDirectThreadConsumeResult,
type DirectThreadChatState,
emptyDirectThreadChatState,
finishDirectThreadTurn,
mergeDirectHistoryItems,
resolveDirectThreadBootstrap,
selectDirectChatEntries,
@@ -12185,8 +12186,12 @@ export function App({
const message = result?.message?.trim();
if (result?.outcome === 'released') {
// 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了
// 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。
setDirectThreadChat((state) => ({ ...state, turnRunning: false }));
// 守卫并补了终态事件;这里按同一个收口函数同步把界面复位,不等 IPC 通知。
// 时刻取宿主观测到的这一刻:终止返回就是这一轮的终态,原生随后补的事件若先到,
// 收口已经是冻结值,不会被抬高,也不会复活成"永远运行中"。
setDirectThreadChat((state) =>
finishDirectThreadTurn(state, Date.now()),
);
setChatAgentBusy(false);
setProjectSupervisorRuntimeError('');
setChatComposerNotice(
@@ -12396,6 +12401,7 @@ export function App({
chatProjectAssets={chatProjectAssets}
directCodex={directCodexProductRuntime}
directTurnRunning={directCodexProductRuntime && directTurnRunning}
directTurnStartedAt={directThreadChat.turnStartedAt}
directEntries={
directCodexProductRuntime
? selectDirectChatEntries(directThreadChat)
@@ -81,8 +81,28 @@ import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import {
formatClockTime,
formatTurnDuration,
resolveTurnTiming,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
/**
* 运行中整轮总耗时:自带 100ms 时钟的小叶子。
*
* 时钟只驱动这一行文字(`useLiveNow`),不带着整个对话面板每 100ms 重建。
* 起点拿不到时不渲染:不编造不能证明的耗时。
*/
function TurnElapsedTotal({ startedAt }: { startedAt: number }) {
const now = useLiveNow(startedAt > 0);
const timing = resolveTurnTiming({ startedAt, running: true, now });
if (!timing.durationText) {
return null;
}
return (
<em className="project-supervisor-process-elapsed">
{`总耗时 ${timing.durationText}`}
</em>
);
}
/** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */
function AgentReasoning({
@@ -125,6 +145,8 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
directEntries?: DirectChatEntry[];
/** 最新回合是否还在跑;只由生命周期事件决定。 */
directTurnRunning?: boolean;
/** 最新回合的原生起点(`turn.started.at`):用户发送时间缺失时兜底,0 = 缺失。 */
directTurnStartedAt?: number;
hiddenConversationCount: number;
hasEarlierConversationMessages?: boolean;
messagesRef: RefObject<HTMLDivElement | null>;
@@ -195,6 +217,7 @@ export function ProjectSupervisorView({
directCodex = false,
directEntries = [],
directTurnRunning = false,
directTurnStartedAt = 0,
hiddenConversationCount,
hasEarlierConversationMessages = false,
messagesRef,
@@ -246,8 +269,6 @@ export function ProjectSupervisorView({
const planningSurfaceActive =
planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime);
const [settingsOpen, setSettingsOpen] = useState(false);
// 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。
const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now());
// 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。
const [voiceNotice, setVoiceNotice] = useState('');
const [approvalOpen, setApprovalOpen] = useState(false);
@@ -267,41 +288,55 @@ export function ProjectSupervisorView({
const [modelValidating, setModelValidating] = useState(false);
const modelSelectRef = useRef<ConversationModelSelectHandle>(null);
const modelValidateInFlightRef = useRef(false);
useEffect(() => {
if (!directTurnRunning) {
return;
}
setTurnUsageNow(Date.now());
const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [directTurnRunning]);
const runBusy = runtimePanelProps.controlBusy || submitting;
const directTurns = directCodex
? buildDirectChatTurns({
entries: directEntries,
localMessages: conversationMessages,
turnRunning: directTurnRunning,
turnStartedAt: directTurnStartedAt,
})
: [];
const activeTurnStartedAt =
directTurns.find((turn) => turn.active)?.startedAt ?? 0;
// 初始占位气泡只是"最初那条消息还没有正式条目"时的顶位,两种情况下不再渲染:
// - 已经翻出更早的历史(`hasEarlierConversationMessages`):这里不是对话开头,不补占位;
// - Direct 模式已经有了正式用户条目:正式气泡自己会显示,占位再渲染就是同一条消息出现两次。
// 这里按**结构**判断(存在正式用户条目),不按文本去重真实消息,也不影响合法的连续重复发送。
const initialSupervisorText = initialSupervisorMessage.trim();
const initialSupervisorPlaceholderSuperseded =
hasEarlierConversationMessages ||
(directCodex
? directEntries.some(
(entry) => entry.kind === 'message' && entry.role === 'user',
) || conversationMessages.some((message) => message.role === 'user')
: conversationMessages.some(
(message) =>
message.role === 'user' &&
message.text.trim() === initialSupervisorText,
));
const clockTimeWithSeconds = (timestamp: number) => {
const date = new Date(timestamp);
const pad = (value: number) => String(value).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
};
/**
* 回合结束后的一行小结:时间范围与总耗时读同一组边界,两者都取不到就不渲染——
* 旧历史没有完整边界时不猜"这一轮跑了多久"。终态不会再变,这里不用时钟。
*/
const renderTurnUsage = (turn: DirectChatTurn) => {
if (turn.active || !turn.startedAt) return null;
const endedAt = Math.max(turn.endedAt, turn.startedAt);
if (turn.active) return null;
const timing = resolveTurnTiming({
startedAt: turn.startedAt,
endedAt: turn.endedAt,
});
if (!timing.durationText) return null;
const endedLabel = formatClockTime(turn.endedAt, { tenths: true });
return (
<p
className="message-turn-usage"
data-testid="turn-usage"
data-turn-running="false"
>
{`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`}
{endedLabel
? `本轮结束于 ${endedLabel} · 总耗时 ${timing.durationText}`
: `总耗时 ${timing.durationText}`}
</p>
);
};
@@ -435,12 +470,7 @@ export function ProjectSupervisorView({
: '显示更早的对话'}
</button>
) : null}
{initialSupervisorMessage.trim() &&
!conversationMessages.some(
(message) =>
message.role === 'user' &&
message.text.trim() === initialSupervisorMessage.trim(),
) ? (
{initialSupervisorText && !initialSupervisorPlaceholderSuperseded ? (
<div className="message message--user" data-runtime-owned="true">
<ChatMarkdownMessage
role="user"
@@ -465,7 +495,8 @@ export function ProjectSupervisorView({
<ToolCallGroup
key={block.key}
calls={block.calls}
userSentAt={turn.startedAt}
turnStartedAt={turn.startedAt}
turnEndedAt={turn.endedAt}
active={turn.active}
className="message-tool-call"
/>
@@ -651,15 +682,7 @@ export function ProjectSupervisorView({
<header>
<span aria-hidden="true" />
<strong></strong>
{activeTurnStartedAt > 0 ? (
<em className="project-supervisor-process-elapsed">
{`已耗时 ${
formatTurnDuration(
Math.max(0, turnUsageNow - activeTurnStartedAt),
) ?? '0秒'
}`}
</em>
) : null}
<TurnElapsedTotal startedAt={activeTurnStartedAt} />
</header>
</AgentMessageContent>
) : null}
@@ -6,26 +6,30 @@ import {
Terminal,
Wrench,
} from 'lucide-react';
import { useEffect, useId, useState } from 'react';
import { useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { DirectChatToolCard } from './directThreadChat';
import {
formatToolCallDuration,
formatTurnDuration,
resolveTurnTiming,
toolCallDurationMs,
toolCallGroupSummary,
toolCallInputText,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
} from './toolCallGroupPresentation';
import { useLiveNow } from './useLiveNow';
/**
* 一回合的工具调用折叠块(Codex 风格):
* 块头一行汇总 + 该回合总用时 + 结束时间,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 块头一行汇总 + 整轮总耗时 + 时间范围,展开态每行一条工具(行可二级展开看命令 / 文件明细 / 输出)。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`。
*
* 计时口径(总耗时与单条耗时各一套,都是 100ms 粒度、始终一位小数):
* - 总耗时属于**整轮**:从用户实际发送到明确终态,跨多个工具块共用同一组边界,包含
* 没有工具的 LLM 等待;不是本块工具的时间跨度,本块工具全部结束后只要回合还在跑就继续增长。
* - 单条耗时只认该工具的事件级边界(`item.started` → `item.completed`),不看条目展示时间。
*
* 无障碍:块头与每一行都是 `<button aria-expanded>` + `hidden` 控制正文,
* 所以 Tab 可达、Enter/Space 可切换、读屏能读到展开状态与 `aria-label`。
* 文案与耗时规则(纯函数)在 `toolCallGroupPresentation.ts`。
@@ -34,13 +38,22 @@ import {
/** 一回合的工具调用折叠块;空集合不渲染。 */
export function ToolCallGroup({
calls,
userSentAt = 0,
turnStartedAt = 0,
turnEndedAt = 0,
active = false,
className,
}: {
calls: DirectChatToolCard[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
/**
* 本轮起点:该轮用户消息的实际发送时间优先,缺失时用原生 `turn.started.at`。
* 拿不到就传 0(或省略),此时隐藏不能证明的耗时。
*/
turnStartedAt?: number | null;
/**
* 本轮明确终态时间(`turn.completed.at`)。运行中、或终态时间缺失时传 0;
* 只有明确终态才冻结总耗时,不按最后一次工具更新时间推断回合结束。
*/
turnEndedAt?: number | null;
/**
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中"
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
@@ -50,56 +63,42 @@ export function ToolCallGroup({
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [tick, setTick] = useState(0);
const bodyId = useId();
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
const running = active && hasRunningSnapshot;
// 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。
useEffect(() => {
if (!running) {
return undefined;
}
const timer = setInterval(() => setTick((current) => current + 1), 1000);
return () => clearInterval(timer);
}, [running]);
if (calls.length === 0) {
return null;
}
void tick;
const orderedCalls = [...calls].sort(
(left, right) => left.startedAt - right.startedAt,
);
const summary = toolCallGroupSummary(orderedCalls, running);
const startedAt =
orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0;
// 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的
// min(startedAt) → max(updatedAt),不掺入本地时钟
const totalDurationMs = running
? startedAt > 0
? (() => {
const latestUpdatedAt = Math.max(
...orderedCalls.map((call) => call.updatedAt || 0),
);
// A running snapshot can still carry a monotonic update time. Use
// that delta when available; fall back to wall-clock elapsed time
// for live events that have no update timestamp yet.
return latestUpdatedAt > startedAt
? latestUpdatedAt - startedAt
: Math.max(0, Date.now() - startedAt);
})()
: null
: turnToolCallDurationMs(orderedCalls);
const durationText = formatTurnDuration(totalDurationMs);
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
const status = running
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
// 行级"执行中"与 `data-status` 看**工具**是否还在跑;块头右侧的总耗时看**整轮**:
// 回合还在跑就一直标"进行中"(哪怕组内工具都结束了,LLM 还在推理)。
const toolRunning = active && hasRunningSnapshot;
// 该行是否需要跟着墙上时钟走:整轮总耗时(回合还没收口且起点已知)或本块里还在跑的工具
const ticking =
active &&
((turnStartedAt ?? 0) > 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 summary = toolCallGroupSummary(orderedCalls);
const status = toolRunning
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const headLabel = [
summary,
running ? '进行中' : '',
durationText ? `${durationText}` : '',
active ? '进行中' : (timing.timeLabel ?? ''),
timing.durationText ? `总耗${timing.durationText}` : '',
]
.filter(Boolean)
.join('');
@@ -115,7 +114,7 @@ export function ToolCallGroup({
data-testid="agent-tool-call-group"
aria-label="陶泥儿执行过程"
data-status={status}
data-duration-ms={totalDurationMs ?? ''}
data-duration-ms={timing.durationMs ?? ''}
>
<button
type="button"
@@ -132,14 +131,16 @@ export function ToolCallGroup({
</span>
<span className="agent-tool-call-group-summary">{summary}</span>
<span className="agent-tool-call-group-meta">
{running ? (
{active ? (
<span className="agent-tool-call-group-running"></span>
) : timeLabel ? (
<span className="agent-tool-call-group-time">{timeLabel}</span>
) : timing.timeLabel ? (
<span className="agent-tool-call-group-time">
{timing.timeLabel}
</span>
) : null}
{durationText ? (
{timing.durationText ? (
<span className="agent-tool-call-group-duration">
{`${durationText}`}
{`总耗${timing.durationText}`}
</span>
) : null}
</span>
@@ -156,7 +157,7 @@ export function ToolCallGroup({
>
<ul className="agent-tool-call-group-rows">
{orderedCalls.map((call) => (
<ToolCallRow key={call.id} call={call} active={active} />
<ToolCallRow key={call.id} call={call} active={active} now={now} />
))}
</ul>
</div>
@@ -168,14 +169,18 @@ export function ToolCallGroup({
function ToolCallRow({
call,
active,
now,
}: {
call: DirectChatToolCard;
active: boolean;
/** 所属回合的运行时钟;只在"该工具仍在跑"时用得上。 */
now: number;
}) {
const [expanded, setExpanded] = useState(false);
const detailId = useId();
const text = toolCallRowText(call);
const durationMs = toolCallDurationMs(call);
const liveRunning = active && call.status === 'running';
const durationMs = toolCallDurationMs(call, { now, running: liveRunning });
const durationText = formatToolCallDuration(durationMs);
// "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理,
// 否则用户会看到一条永远停在"执行中"的记录。
@@ -28,12 +28,30 @@ export type DirectChatEntry = {
role?: 'user' | 'assistant' | null;
text?: string | null;
toolCall?: DirectChatToolCard | null;
/** 条目展示时间(`item.at`);只用于气泡 / 条目的时间显示,不作任何计时的起止边界。 */
at?: number;
/**
* 本轮起点的展示元数据:原生 `turn.started.at`。
*
* 只在该轮结束时盖到本轮条目上:回合收口后运行态清空,条目上的这组边界是本次会话里
* 唯一还记得终态的载体。它是**本次会话的展示缓存**(不落盘、不进历史文件),
* 页面刷新后旧历史仍然没有边界、也不会因此显示出总耗时。不是第二套生命周期。
*/
turnStartedAt?: number;
/**
* 本轮明确终态时间的展示元数据:`turn.completed.at`(或宿主终止收口的观测时间)。
* 缺失时不写,不能拿最后一条工具 / 正文的时间顶替。
*/
turnEndedAt?: number;
};
export type DirectThreadChatState = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
turnRunning: boolean;
/** 原生 `turn.started.at`:本轮用户实际发送时间缺失时的起点兜底;0 = 缺失。 */
turnStartedAt: number;
/** 本轮明确终态时间;只写一次,0 = 还没有可证明的终态时间。 */
turnEndedAt: number;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
@@ -43,11 +61,31 @@ export type DirectThreadChatState = {
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
turnRunning: false,
turnStartedAt: 0,
turnEndedAt: 0,
history: [],
live: [],
};
}
/** 时间戳合法性:缺失 / 0 / 非有限都算没有这个边界,不用它计任何耗时。 */
function validBoundaryAt(value: number | null | undefined): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: 0;
}
/**
* 事件级阶段时间(毫秒):原生在 `turn.started` / `turn.completed` / `item.started` /
* `item.completed` 上给出的 `at`。
*
* 它与条目里的 `item.at` 是两件事:后者是条目展示时间,不作工具计时的起止边界。
* 字段由生成绑定声明(ts-rs),重放沿用原值,因此这里只读不取当前时间。
*/
export function readDirectThreadEventAt(event: DirectThreadEvent): number {
return validBoundaryAt('at' in event ? event.at : 0);
}
function longerText(
left: string | null | undefined,
right: string | null | undefined,
@@ -67,6 +105,29 @@ function mergeToolStatus(
return left;
}
/** 工具的终态:完成 / 失败一旦抵达就不再被后续快照改写。 */
function isTerminalToolStatus(
status: DirectChatToolCard['status'] | null | undefined,
) {
return status === 'completed' || status === 'failed';
}
/**
* 工具终点只写一次:卡片已经拿到终态和终点时间之后,迟到 / 重放的事件不得把它抬高,
* 也不得把它抹掉;只有"还没冻结"时才允许用后到的、更晚的时间补上终点。
*/
function mergeToolCardEndAt(
left: DirectChatToolCard,
right: DirectChatToolCard,
): number {
const leftUpdatedAt = Number.isFinite(left.updatedAt) ? left.updatedAt : 0;
if (isTerminalToolStatus(left.status) && leftUpdatedAt > 0) {
return leftUpdatedAt;
}
const rightUpdatedAt = Number.isFinite(right.updatedAt) ? right.updatedAt : 0;
return Math.max(leftUpdatedAt, rightUpdatedAt);
}
function mergeToolCard(
left: DirectChatToolCard | null,
right: DirectChatToolCard | null,
@@ -87,7 +148,7 @@ function mergeToolCard(
: right.detail.changes,
},
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
updatedAt: Math.max(left.updatedAt, right.updatedAt),
updatedAt: mergeToolCardEndAt(left, right),
};
}
@@ -114,6 +175,9 @@ export function mergeDirectChatEntry(
incoming.toolCall ?? null,
),
at: existing.at || incoming.at,
// 展示元数据先到先用:后到的重放 / 历史切片不得覆盖已经确定的边界。
turnStartedAt: existing.turnStartedAt || incoming.turnStartedAt,
turnEndedAt: existing.turnEndedAt || incoming.turnEndedAt,
};
}
@@ -157,22 +221,60 @@ export function reduceDirectThreadEvent(
event: DirectThreadEvent,
): DirectThreadChatState {
switch (event.type) {
case 'turn.started':
return { ...state, turnRunning: true };
case 'turn.completed':
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
case 'turn.started': {
const eventAt = readDirectThreadEventAt(event);
// 回合身份只认**事件流顺序**,不拿时间戳大小当身份:原生回合时间是秒级精度、
// 宿主收口时间可能带毫秒,"上一轮结束之后又来一条 turn.started"就是新回合,
// 哪怕它落在同一秒。同一轮内部的重复开始事件(真正重放)在流里表现为
// "还在跑时又收到 turn.started",那种情况保留第一次的起点。
const turnStartedAt =
state.turnRunning && state.turnStartedAt > 0
? state.turnStartedAt
: eventAt;
return {
...state,
turnRunning: false,
history: mergeHistoryEntries(state.history, state.live),
live: [],
turnRunning: true,
turnStartedAt,
turnEndedAt: 0,
};
}
case 'turn.completed': {
const eventAt = readDirectThreadEventAt(event);
// 已经收口、而且没有新的运行态条目:重复 / 迟到的终态事件不改动时间,也不复活运行态。
if (!state.turnRunning && state.live.length === 0) {
return state;
}
return finishDirectThreadTurn(state, eventAt);
}
case 'item.delta':
return appendLiveText(state, event);
case 'item.started':
case 'item.completed': {
const entry = projectDirectThreadItem(event.item);
return entry ? upsertLiveEntry(state, entry) : state;
const eventAt = readDirectThreadEventAt(event);
const entry = projectDirectThreadItem(
event.item,
event.type === 'item.started'
? { startedAt: eventAt }
: { completedAt: eventAt },
);
if (!entry) {
return state;
}
// 身份已经落盘过(同一个 itemId 只属于一个回合):这是迟到 / 重放的条目,
// 直接补进历史里同一条目(只补空字段、不改冻结的终点),不塞进运行态——
// 否则它会挂到已经收口的那一轮之后的运行态里,被算进还没开始的新回合。
const historyIndex = state.history.findIndex(
(existing) => existing.itemId === entry.itemId,
);
if (historyIndex >= 0) {
const history = [...state.history];
const existing = history[historyIndex];
if (existing) {
history[historyIndex] = mergeDirectChatEntry(existing, entry);
return { ...state, history };
}
}
return upsertLiveEntry(state, entry);
}
case 'request':
// 审批 / 提问只影响面板交互,不并入聊天条目。
@@ -182,6 +284,61 @@ export function reduceDirectThreadEvent(
}
}
/**
* 回合收口:把运行态条目并入历史、清空运行态,并固定本轮终态时间。
*
* `endedAt` 只接受明确的终态时间(`turn.completed.at`,或宿主终止收口时观测到的时刻):
* 缺失就是缺失,宁可不显示总耗时,也不用最后一条工具 / 正文的时间顶替。
* 已经冻结的终态时间不会被后来的调用抬高;开始时间只记原生值,用户实际发送时间的优先级
* 由投影层决定(条目上的 `at` 才是气泡时间)。
*/
export function finishDirectThreadTurn(
state: DirectThreadChatState,
endedAt: number | null | undefined,
): DirectThreadChatState {
const turnEndedAt =
state.turnEndedAt > 0 ? state.turnEndedAt : validBoundaryAt(endedAt);
const turnStartedAt = state.turnStartedAt;
const boundary = {
...(turnStartedAt > 0 ? { turnStartedAt } : {}),
...(turnEndedAt > 0 ? { turnEndedAt } : {}),
};
const stamped = state.live.map((entry) => ({ ...entry, ...boundary }));
// 本轮的开口条目是 live 里那条用户消息;bootstrap 可能已经把用户消息当历史锚点发过,
// 这时 live 里只有过程条目。同一份边界只补到"历史最后一条、且是用户条目"上:那种位置
// 只可能是本轮的开口条目(后面还没有任何内容),不会命中上一轮已经写完正文的开口条目。
const history =
state.live.length > 0 &&
!state.live.some(
(entry) => entry.kind === 'message' && entry.role === 'user',
) &&
Object.keys(boundary).length > 0
? stampTrailingTurnOpener(state.history, boundary)
: state.history;
return {
...state,
turnRunning: false,
turnStartedAt,
turnEndedAt,
history: mergeHistoryEntries(history, stamped),
live: [],
};
}
/** 历史最后一条正是本轮的开口条目时,补上同一份边界;否则原样返回。 */
function stampTrailingTurnOpener(
history: DirectChatEntry[],
boundary: Pick<DirectChatEntry, 'turnStartedAt' | 'turnEndedAt'>,
): DirectChatEntry[] {
const last = history[history.length - 1];
if (!last || last.kind !== 'message' || last.role !== 'user') {
return history;
}
const next = [...history];
next[next.length - 1] = { ...last, ...boundary };
return next;
}
export function reduceDirectThreadEvents(
state: DirectThreadChatState,
events: readonly DirectThreadEvent[],
@@ -99,7 +99,6 @@ function fileChanges(
type ToolCardInput = {
itemId: string;
at: number;
kind: GameCreatorDirectToolCallKind;
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
outputOnly?: boolean;
@@ -110,7 +109,62 @@ type ToolCardInput = {
changes?: GameCreatorDirectToolCallChange[];
};
function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
/**
* 条目的事件级阶段时间:`item.started` / `item.completed` 事件上的 `at`。
*
* 工具卡片的时间边界**只认这里**:条目自身的 `item.at` 是展示时间,不能当起止。
* 缺失(0)就留空:这个边界不知道,由展示层隐藏不能证明的耗时,不猜时间。
*/
export type DirectItemPhaseTime = {
/** 收到 `item.started` 时该事件的时间;其它事件与历史切片不传。 */
startedAt?: number;
/** 收到 `item.completed` 时该事件的时间;其它事件与历史切片不传。 */
completedAt?: number;
};
/** 阶段时间合法性:缺失 / 0 / 非有限一律算"没有这个边界"。 */
function phaseTime(value: number | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: 0;
}
/**
* 阶段优先的状态:只有 `item.started` 阶段、而且条目本身没有终态信息时,工具就是"运行中"。
*
* 有些条目(文件变更 / 联网检索 / 上下文整理 / 没有状态字段的 MCP 调用)type 上不带状态,
* 收到开始事件时不能一律当"已完成",否则它们在块里永远不会动态增长;真正结束由同身份的
* `item.completed` 事件给出。条目自带的失败 / 完成状态优先,不被阶段改写。
*/
function toolStatusForPhase(
status: GameCreatorDirectToolCallStatus,
phase: DirectItemPhaseTime,
): GameCreatorDirectToolCallStatus {
if (
phaseTime(phase.startedAt) > 0 &&
phaseTime(phase.completedAt) === 0 &&
status === 'completed'
) {
return 'running';
}
return status;
}
/**
* `function_call` 的时间只表示"这次调用什么时候发起":即使它是随 `item.completed`
* 到达的(调用条目本身完成了),那也不是工具结束——结束由 `function_call_output` 给出。
* 因此这里只认起点,终点留空,不会伪造一个 0 秒的完成。
*/
function callStartPhase(phase: DirectItemPhaseTime): DirectItemPhaseTime {
return {
startedAt: phaseTime(phase.startedAt) || phaseTime(phase.completedAt),
};
}
function buildToolCard(
input: ToolCardInput,
phase: DirectItemPhaseTime,
): DirectChatToolCard | null {
const changes = input.changes ?? [];
const detail: GameCreatorDirectToolCallDetail = {};
if (input.command && !input.outputOnly) detail.command = input.command;
@@ -131,77 +185,99 @@ function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
kind: input.kind,
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
summary: input.outputOnly ? '' : firstLine(summarySource),
status: input.status,
// 输出条目本身就是"结果快照",不因阶段被改成运行中。
status: input.outputOnly
? input.status
: toolStatusForPhase(input.status, phase),
detail,
startedAt: input.at,
updatedAt: input.at,
startedAt: phaseTime(phase.startedAt),
// 终点只有一种来源:明确的完成事件时间。运行中的卡片拿不到终点(0),
// 由展示层用宿主当前时钟算增长,绝不用快照更新时间冒充"现在"。
updatedAt: phaseTime(phase.completedAt),
};
}
function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
function toolCardFromItem(
item: DirectThreadItem,
phase: DirectItemPhaseTime,
): DirectChatToolCard | null {
switch (item.itemType) {
case 'function_call':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: toolKindFromFunctionName(item.name),
tool: item.name,
command: item.arguments,
status: 'running',
});
return buildToolCard(
{
itemId: item.itemId,
kind: toolKindFromFunctionName(item.name),
tool: item.name,
command: item.arguments,
status: 'running',
},
callStartPhase(phase),
);
case 'function_call_output':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'other',
outputOnly: true,
output: item.output,
status: 'completed',
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'other',
outputOnly: true,
output: item.output,
status: 'completed',
},
phase,
);
case 'commandExecution':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'command',
command: item.command,
output: item.output ?? '',
status: toolStatus(item.status, item.exitCode),
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'command',
command: item.command,
output: item.output ?? '',
status: toolStatus(item.status, item.exitCode),
},
phase,
);
case 'fileChange':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'file_change',
changes: fileChanges(item),
status: 'completed',
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'file_change',
changes: fileChanges(item),
status: 'completed',
},
phase,
);
case 'mcpToolCall':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'mcp_tool',
tool: item.tool,
command: item.arguments,
output: item.output ?? '',
status: toolStatus(item.status, null),
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'mcp_tool',
tool: item.tool,
command: item.arguments,
output: item.output ?? '',
status: toolStatus(item.status, null),
},
phase,
);
case 'webSearch':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'web_search',
command: item.query ?? '',
output: item.output ?? '',
status: 'completed',
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'web_search',
command: item.query ?? '',
output: item.output ?? '',
status: 'completed',
},
phase,
);
case 'contextCompaction':
return buildToolCard({
itemId: item.itemId,
at: item.at,
kind: 'context_compaction',
command: '整理上下文',
status: 'completed',
});
return buildToolCard(
{
itemId: item.itemId,
kind: 'context_compaction',
command: '整理上下文',
status: 'completed',
},
phase,
);
default:
return null;
}
@@ -215,6 +291,7 @@ function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
*/
export function projectDirectThreadItem(
item: DirectThreadItem | null | undefined,
phase: DirectItemPhaseTime = {},
): DirectChatEntry | null {
if (!item) return null;
// 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。
@@ -254,7 +331,7 @@ export function projectDirectThreadItem(
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
return null;
default: {
const toolCall = toolCardFromItem(item);
const toolCall = toolCardFromItem(item, phase);
if (!toolCall) return null;
return {
itemId,
@@ -45,7 +45,12 @@ export type DirectChatTurn = {
/** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */
finals: DirectChatBlock[];
active: boolean;
/**
* 本轮起点:该轮**实际用户消息的发送时间**优先(与气泡显示的时间同源),
* 缺失时用原生 `turn.started.at`,都拿不到是 0(此时隐藏不能证明的总耗时)。
*/
startedAt: number;
/** 本轮明确终态时间(`turn.completed.at`);运行中或旧历史没有边界时是 0。 */
endedAt: number;
};
@@ -142,10 +147,16 @@ export function buildDirectChatTurns({
entries,
localMessages = [],
turnRunning = false,
turnStartedAt = 0,
}: {
entries: readonly DirectChatEntry[];
localMessages?: readonly ChatMessage[];
turnRunning?: boolean;
/**
* 当前回合的原生起点(`turn.started.at`):只在该轮用户发送时间缺失时兜底,
* 不会覆盖用户实际发送时间,也不参与已完成回合。
*/
turnStartedAt?: number;
}): DirectChatTurn[] {
const turns: DirectChatTurnEntries[] = [];
let current: DirectChatTurnEntries | null = null;
@@ -223,20 +234,32 @@ export function buildDirectChatTurns({
const block = blockFromLocalMessage(message, index);
if (block) finals.push(block);
});
const times = [
...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)),
...turn.notices.map((message) =>
normalizeDirectTimestamp(message.updatedAt),
),
...users.map((block) => (block.kind === 'user' ? block.at : 0)),
].filter((at) => at > 0);
const startedAt = times.length ? Math.min(...times) : 0;
const endedAt = [
...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)),
...turn.notices.map((message) =>
normalizeDirectTimestamp(message.updatedAt),
),
].reduce((latest, at) => Math.max(latest, at), 0);
// 回合边界只认两件事:该轮用户气泡自己的发送时间(不是所有条目的最小值),
// 以及明确的终态事件时间。回合进行中先给"进行中"的滚动总耗时,结束后冻结。
let userSentAt = 0;
for (const block of users) {
if (block.kind === 'user' && block.at > 0) {
userSentAt = block.at;
break;
}
}
const stampedStart = turn.entries.reduce(
(found, entry) => found || normalizeDirectTimestamp(entry.turnStartedAt),
0,
);
const stampedEnd = turn.entries.reduce(
(found, entry) => found || normalizeDirectTimestamp(entry.turnEndedAt),
0,
);
const startedAt =
userSentAt > 0
? userSentAt
: turn.active
? normalizeDirectTimestamp(turnStartedAt)
: stampedStart;
// 终态只读**本轮条目**上盖的边界:跨轮 fallback 会把最新回合的终点填进所有
// 拿不到时间的旧历史回合,等于给未知耗时编一个值。
const endedAt = turn.active ? 0 : stampedEnd;
return {
key: turn.key,
users,
@@ -11,12 +11,48 @@ import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
*
* 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由
* 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。
*
* 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`
* 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的
* `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。
* 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟;
* 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主
* 在该阶段取的毫秒钟——见 `direct_thread_turn_completed_at_ms` 的说明。
* `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值,
* 不能在前端收到或重放时重新取当前时间。
*/
export type DirectThreadEvent =
| { type: 'turn.started' }
| { type: 'turn.completed'; status: string }
| { type: 'item.started'; item: DirectThreadItem }
| { type: 'item.completed'; item: DirectThreadItem }
| {
type: 'turn.started';
/**
* 本轮开始的阶段时间(毫秒):宿主处理 `turn/start` 的毫秒钟。
*/
at?: number;
}
| {
type: 'turn.completed';
status: string;
/**
* 本轮终态的阶段时间(毫秒):宿主处理终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。
*/
at?: number;
}
| {
type: 'item.started';
item: DirectThreadItem;
/**
* 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。
*/
at?: number;
}
| {
type: 'item.completed';
item: DirectThreadItem;
/**
* 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。
*/
at?: number;
}
| {
type: 'item.delta';
itemId: string;
@@ -173,97 +173,82 @@ function unwrapDisplayArgument(argument: string) {
/**
* 单条工具的耗时(毫秒)。
* `startedAt` 为 0(缺失)或 `updatedAt < startedAt`(时间倒序)时返回 `null`:
* 这两种情况不显示耗时,不显示 `0s` / 负数。
*
* 边界只来自事件级时间:`startedAt` 是 `item.started` 事件的时间,完成态用
* `item.completed` 事件的时间。两者任一缺失(0 / 非有限)或时间倒序时返回 `null`:
* 没有开始事件的完成快照不猜开始时间,缺失与倒序都不显示耗时。
*
* `running` 为真(该工具仍在跑、且所属回合仍在跑)时用 `now` 当终点:以时间戳差值
* 计算,不按 tick 累加,后台节流后回到前台不会累计漂移。合法 0 是有效耗时,照常返回 0。
*/
export function toolCallDurationMs(
call: Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>,
options?: { now?: number; running?: boolean },
): number | null {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt <= 0 || updatedAt < startedAt) {
if (startedAt <= 0) {
return null;
}
return updatedAt - startedAt;
const endAt = options?.running
? Number(options.now)
: Number.isFinite(call.updatedAt)
? call.updatedAt
: 0;
if (!Number.isFinite(endAt) || endAt <= 0 || endAt < startedAt) {
return null;
}
return endAt - startedAt;
}
/**
* 单条工具的耗时文案:`0.4s`<1s/ `12.3s`<60s,整秒省略小数)/ `2m 5s`(≥60s
* 无法计算的耗时(`null` / 0 / 负数)返回 `null`。
* 单条工具的耗时文案:始终一位小数,`0.0s` / `5.0s` / `1m 2.3s`
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0s`。
*/
export function formatToolCallDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
return null;
}
if (ms < 60000) {
const tenths = Math.max(1, Math.round(ms / 100));
if (tenths < 600) {
const value = tenths / 10;
return Number.isInteger(value) ? `${value}s` : `${value.toFixed(1)}s`;
}
const tenths = Math.round(ms / 100);
if (tenths < 600) {
return `${(tenths / 10).toFixed(1)}s`;
}
const totalSeconds = Math.max(60, Math.round(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const restSeconds = totalSeconds % 60;
return restSeconds === 0 ? `${minutes}m` : `${minutes}m ${restSeconds}s`;
const minutes = Math.floor(tenths / 600);
return `${minutes}m ${((tenths - minutes * 600) / 10).toFixed(1)}s`;
}
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
export function turnToolCallDurationMs(
calls: Array<Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>>,
): number | null {
let minStartedAt = Number.POSITIVE_INFINITY;
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
for (const call of calls) {
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
if (startedAt > 0) {
minStartedAt = Math.min(minStartedAt, startedAt);
}
if (updatedAt > 0) {
maxUpdatedAt = Math.max(maxUpdatedAt, updatedAt);
}
}
if (!Number.isFinite(minStartedAt) || !Number.isFinite(maxUpdatedAt)) {
return null;
}
if (maxUpdatedAt < minStartedAt) {
return null;
}
return maxUpdatedAt - minStartedAt;
}
/** 块头总用时文案:`42秒` / `4分钟` / `5分钟 45秒`;无法计算的耗时返回 `null`。 */
/**
* 整轮总耗时文案:始终一位小数,`0.0秒` / `5.0秒` / `1分钟 2.3秒`。
* 无法计算的耗时(`null` / `undefined` / 非有限 / 负数)返回 `null`;合法 0 显示 `0.0秒`。
*/
export function formatTurnDuration(ms: number | null | undefined) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms <= 0) {
if (ms === null || ms === undefined || !Number.isFinite(ms) || ms < 0) {
return null;
}
const seconds = Math.max(1, Math.round(ms / 1000));
if (seconds < 60) {
return `${seconds}`;
const tenths = Math.round(ms / 100);
if (tenths < 600) {
return `${(tenths / 10).toFixed(1)}`;
}
const minutes = Math.floor(seconds / 60);
const restSeconds = seconds % 60;
return restSeconds === 0
? `${minutes}分钟`
: `${minutes}分钟 ${restSeconds}`;
const minutes = Math.floor(tenths / 600);
return `${minutes}分钟 ${((tenths - minutes * 600) / 10).toFixed(1)}`;
}
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
export function turnToolCallEndedAt(
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
/**
* 时间戳取整到展示用的 100ms 网格(非法 / 缺失一律回 0)。
*
* 时间范围与总耗时必须来自同一组取整边界:分别对原始毫秒做四舍五入会让
* `14:20:05.9 → 14:20:06.1` 与 `0.1秒` 这类组合互相矛盾。
*/
export function quantizeDisplayMs(value: number | null | undefined) {
return typeof value === 'number' && Number.isFinite(value) && value > 0
? Math.round(value / 100) * 100
: 0;
}
/** 本地 `HH:mm:ss``tenths` 为真时补一位小数(`HH:mm:ss.S`)。缺失(0 / 非法)返回 `null`。 */
export function formatClockTime(
timestamp: number | null | undefined,
options?: { tenths?: boolean },
) {
let maxUpdatedAt = 0;
for (const call of calls) {
if (Number.isFinite(call.updatedAt) && call.updatedAt > maxUpdatedAt) {
maxUpdatedAt = call.updatedAt;
}
}
return maxUpdatedAt;
}
/** 本地 `HH:mm:ss`;时间戳缺失(0 / 非法)返回 `null`,不编造时间。 */
export function formatClockTime(timestamp: number | null | undefined) {
if (
timestamp === null ||
timestamp === undefined ||
@@ -277,21 +262,91 @@ export function formatClockTime(timestamp: number | null | undefined) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${hours}:${minutes}:${seconds}`;
if (!options?.tenths) {
return `${hours}:${minutes}:${seconds}`;
}
return `${hours}:${minutes}:${seconds}.${Math.floor(date.getMilliseconds() / 100)}`;
}
/**
* 块头时间文案:该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
* 整轮耗时(毫秒)= 本轮起点 → 本轮终态。
*
* 起点是该轮实际用户消息的发送时间,缺失时用原生 `turn.started.at`;终点是明确的
* `turn.completed.at`,运行中则是当前时刻(回合还在跑就持续增长,即使组内工具都结束了)。
* 两端任一缺失、非有限或倒序都返回 `null`:不伪造 `0.0秒`。
*/
export function turnToolCallTimeLabel(
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
userSentAt: number | null | undefined,
) {
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
if (!endLabel) {
export function turnTotalDurationMs({
startedAt,
endedAt,
running = false,
now = 0,
}: {
/** 本轮起点:用户实际发送时间优先,缺失时原生 `turn.started.at`0 = 未知。 */
startedAt: number | null | undefined;
/** 本轮明确终态时间(`turn.completed.at`);运行中忽略。 */
endedAt?: number | null;
/** 本轮是否仍在跑:为真时用 `now` 当终点。 */
running?: boolean;
now?: number;
}): number | null {
const rawEnd = running ? now : endedAt;
if (
typeof startedAt !== 'number' ||
!Number.isFinite(startedAt) ||
startedAt <= 0 ||
typeof rawEnd !== 'number' ||
!Number.isFinite(rawEnd) ||
rawEnd <= 0 ||
rawEnd < startedAt
) {
return null;
}
const sentLabel = formatClockTime(userSentAt ?? 0);
return sentLabel ? `${sentLabel}${endLabel}` : endLabel;
const start = quantizeDisplayMs(startedAt);
const end = quantizeDisplayMs(rawEnd);
if (start <= 0 || end <= 0 || end < start) {
return null;
}
return end - start;
}
export type DirectTurnTiming = {
/** 展示用总耗时(毫秒,已取整到 100ms 网格);null = 边界不完整,隐藏。 */
durationMs: number | null;
/** `总耗时` 后面的文案;null = 隐藏。 */
durationText: string | null;
/** `HH:mm:ss.S → HH:mm:ss.S`;起点未知时只给终点,两端都未知为 null。 */
timeLabel: string | null;
};
/**
* 整轮计时的唯一口径:总耗时与时间范围共用同一组边界。
*
* 只认明确边界:缺字段、非有限、时间戳为 0、结束早于开始一律隐藏耗时(也不会退化成
* `0.0秒`);合法的同一时刻(耗时 0)正常显示 `0.0秒`。
*/
export function resolveTurnTiming({
startedAt,
endedAt,
running = false,
now = 0,
}: {
startedAt: number | null | undefined;
endedAt?: number | null;
running?: boolean;
now?: number;
}): DirectTurnTiming {
const start = quantizeDisplayMs(startedAt);
const end = quantizeDisplayMs(running ? now : endedAt);
const durationMs = turnTotalDurationMs({ startedAt, endedAt, running, now });
const startLabel =
start > 0 ? formatClockTime(start, { tenths: true }) : null;
const endLabel = end > 0 ? formatClockTime(end, { tenths: true }) : null;
return {
durationMs,
durationText: formatTurnDuration(durationMs),
timeLabel:
startLabel && endLabel
? `${startLabel}${endLabel}`
: (endLabel ?? null),
};
}
@@ -0,0 +1,29 @@
import { useEffect, useState } from 'react';
/**
* 动态计时的刷新粒度:总耗时与单条工具耗时都固定 100ms。
*
* 这个 hook 是"有界时钟"的唯一实现:只在 `active` 为真时订阅定时器,停止 / 卸载即清理。
* 调用方必须是叶子节点(一块工具卡片、一行进度),别在整棵 App 或整块面板上订阅,
* 否则每 100ms 会重建整个视图。
*/
export const LIVE_TIMER_TICK_MS = 100;
/**
* 每 100ms 返回一次宿主当前时间;`active` 为假时不订阅、也不再更新。
*
* 读的是时间戳而不是累加 tick:后台被节流后回到前台,耗时按真实时间差补齐,不会漂移。
* 返回的 `now` 在 `active` 为假时是上一次的值,调用方不得在非运行态把它当终点用。
*/
export function useLiveNow(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) {
return undefined;
}
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), LIVE_TIMER_TICK_MS);
return () => clearInterval(timer);
}, [active]);
return now;
}
@@ -1402,6 +1402,7 @@ function createProjectSupervisorRuntimeHarness({
* 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。
*
* 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。
* 事件级 `at` 与条目 `item.at` 同源:原生在两个阶段事件上都给时间,计时只读事件级那一份。
*/
completeDirectThreadTurn({
turnId,
@@ -1419,10 +1420,11 @@ function createProjectSupervisorRuntimeHarness({
// 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。
const events: Array<Record<string, unknown>> = directThreadTurnRunning
? []
: [{ type: 'turn.started' }];
: [{ type: 'turn.started', at }];
if (prompt.trim()) {
events.push({
type: 'item.completed',
at,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:user`,
@@ -1434,6 +1436,7 @@ function createProjectSupervisorRuntimeHarness({
}
events.push({
type: 'item.completed',
at: at + 1,
item: {
itemType: 'message',
itemId: `direct-codex:${turnId}:assistant`,
@@ -1443,7 +1446,8 @@ function createProjectSupervisorRuntimeHarness({
},
});
directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`;
events.push({ type: 'turn.completed', status });
// 终态时间就是调用方给的这一刻:整轮总耗时读它,不读正文条目的展示时间。
events.push({ type: 'turn.completed', status, at });
emitDirectThreadEvents(...events);
},
setDirectThreadHistory(items: Array<Record<string, unknown>>) {
@@ -8062,21 +8062,26 @@ export function registerProjectSupervisorSurfaceTests() {
expect(clientTurnId).not.toBe('');
// 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。
// 事件级 `at` 是计时的唯一边界(原生在 item.started / item.completed 上给出),
// 条目里的 `item.at` 只是展示时间。用一个贴近宿主时钟的基准,运行中的总耗时才是真数值。
const turnSentAt = Date.now();
await act(async () => {
supervisorHarness.emitDirectThreadEvents(
{ type: 'turn.started' },
{ type: 'turn.started', at: turnSentAt },
{
type: 'item.completed',
at: turnSentAt,
item: {
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:user`,
role: 'user',
text: '做一个跑酷游戏',
at: 995,
at: turnSentAt,
},
},
{
type: 'item.started',
at: turnSentAt,
item: {
itemType: 'commandExecution',
itemId: 'item-command',
@@ -8113,6 +8118,7 @@ export function registerProjectSupervisorSurfaceTests() {
supervisorHarness.emitDirectThreadEvents(
{
type: 'item.completed',
at: turnSentAt + 100,
item: {
itemType: 'commandExecution',
itemId: 'item-command',
@@ -8120,11 +8126,12 @@ export function registerProjectSupervisorSurfaceTests() {
output: 'build ok',
status: 'completed',
exitCode: 0,
at: 1100,
at: turnSentAt + 100,
},
},
{
type: 'item.started',
at: turnSentAt + 50,
item: {
itemType: 'fileChange',
itemId: 'item-file',
@@ -8132,11 +8139,12 @@ export function registerProjectSupervisorSurfaceTests() {
{ path: 'game/src/hero.ts', kind: 'update' },
{ path: 'game/src/hero.ts', kind: 'delete' },
],
at: 1050,
at: turnSentAt + 50,
},
},
{
type: 'item.completed',
at: turnSentAt + 100,
item: {
itemType: 'fileChange',
itemId: 'item-file',
@@ -8144,7 +8152,7 @@ export function registerProjectSupervisorSurfaceTests() {
{ path: 'game/src/hero.ts', kind: 'update' },
{ path: 'game/src/hero.ts', kind: 'delete' },
],
at: 1100,
at: turnSentAt + 100,
},
},
);
@@ -8159,8 +8167,11 @@ export function registerProjectSupervisorSurfaceTests() {
)[0] as HTMLElement;
const groupHead = within(group).getByTestId('agent-tool-call-group-head');
expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更');
// 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。
expect(group.getAttribute('data-duration-ms')).toBe('100');
// 块头右侧是**整轮**总耗时(用户发送 → 回合终态),不是本块工具的时间跨度:
// 组内工具都结束了,但回合还在跑,块头仍标"进行中"、耗时继续增长(只断言它是一位小数的数值)。
expect(group.getAttribute('data-status')).toBe('completed');
expect(groupHead.textContent).toContain('进行中');
expect(groupHead.textContent).toMatch(/总耗时 \d+\.\d+秒/);
// 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。
const liveChildren = Array.from((messageList as HTMLElement).children);
expect(
@@ -8228,7 +8239,7 @@ export function registerProjectSupervisorSurfaceTests() {
supervisorHarness.completeDirectThreadTurn({
turnId: clientTurnId,
reply: 'DIRECT_REPLY:做一个跑酷游戏',
at: 1500,
at: turnSentAt + 500,
});
});
await waitFor(() => {
@@ -8259,6 +8270,12 @@ 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(settledHead.textContent).not.toContain('进行中');
const settledBody = settledGroup.querySelector(
`#${settledHead.getAttribute('aria-controls')}`,
);
@@ -8852,22 +8869,26 @@ export function registerProjectSupervisorSurfaceTests() {
);
expect(clientTurnId).not.toBe('');
// 计时只读事件级 `at`:用户发送这一刻就是整轮起点。
const freshTurnSentAt = Date.now();
await act(async () => {
// 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。
supervisorHarness.emitDirectThreadEvents(
{ type: 'turn.started' },
{ type: 'turn.started', at: freshTurnSentAt },
{
type: 'item.completed',
at: freshTurnSentAt,
item: {
itemType: 'message',
itemId: `direct-codex:${clientTurnId}:user`,
role: 'user',
text: '做一个跑酷游戏',
at: 900,
at: freshTurnSentAt,
},
},
{
type: 'item.started',
at: freshTurnSentAt,
item: {
itemType: 'commandExecution',
itemId: 'fresh-turn-command',
@@ -8875,7 +8896,7 @@ export function registerProjectSupervisorSurfaceTests() {
output: null,
status: 'inProgress',
exitCode: null,
at: 1000,
at: freshTurnSentAt,
},
},
);
@@ -8893,6 +8914,9 @@ 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+秒/);
const runningChildren = Array.from((messageList as HTMLElement).children);
expect(
runningChildren.findIndex((node) => node === runningGroup),
@@ -8908,6 +8932,7 @@ export function registerProjectSupervisorSurfaceTests() {
await act(async () => {
supervisorHarness.emitDirectThreadEvents({
type: 'item.completed',
at: freshTurnSentAt + 400,
item: {
itemType: 'commandExecution',
itemId: 'fresh-turn-command',
@@ -8915,7 +8940,7 @@ export function registerProjectSupervisorSurfaceTests() {
output: 'built in 400ms',
status: 'completed',
exitCode: 0,
at: 1400,
at: freshTurnSentAt + 400,
},
});
});
@@ -8927,7 +8952,13 @@ export function registerProjectSupervisorSurfaceTests() {
const settledRunningGroup = within(supervisorSurface).getAllByTestId(
'agent-tool-call-group',
)[0] as HTMLElement;
expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400');
// 工具已经结束(块内没有 running 快照),但回合还在跑:块头仍标"进行中",
// 总耗时按"用户发送 → 现在"滚动(这里不钉具体毫秒,动态增长由 tool-call-group 用例覆盖)。
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'),
);
@@ -8946,7 +8977,7 @@ export function registerProjectSupervisorSurfaceTests() {
supervisorHarness.completeDirectThreadTurn({
turnId: clientTurnId,
reply: 'DIRECT_REPLY:空对话首轮',
at: 1500,
at: freshTurnSentAt + 1000,
});
});
await waitFor(() => {
@@ -8963,6 +8994,160 @@ 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(
within(settledGroup).getByTestId('agent-tool-call-group-head')
.textContent,
).toContain('总耗时 1.0秒');
// 同一次收口也把总耗时写进回合小结,读的是同一组边界。
expect(
within(supervisorSurface).getByTestId('turn-usage').textContent,
).toContain('总耗时 1.0秒');
});
it('keeps the initial supervisor placeholder from duplicating the landed direct user entry', () => {
// 初始占位气泡只在"最初那条消息还没有正式条目"时顶位:
// Direct 模式的正式条目走 `directEntries`、不进 `conversationMessages`
// 正式条目一到就必须撤掉占位,否则同一条消息会上下各出现一次(正式气泡 + 无时间占位)。
const initialText = '做一个跑酷游戏';
const baseProps = {
activeVersionId: null,
chatInput: '',
chatReferences: [],
chatProjectAssets: [],
composerRef: createRef(),
directCodex: true,
directTurnRunning: false,
hiddenConversationCount: 0,
messagesRef: createRef(),
needsUserInput: false,
onCancelConfirmation: vi.fn(),
onCancelPendingCommand: vi.fn(),
onChatInputChange: vi.fn(),
onConfirmConfirmation: vi.fn(),
onConfirmPendingCommand: vi.fn(),
onScroll: vi.fn(),
onShowEarlierMessages: vi.fn(),
onSubmit: vi.fn(),
pendingConfirmation: null,
pendingCommand: null,
projectPath: '/tmp/launcher-codex-placeholder-game',
transientReply: '',
visibleMessages: [],
visibleProfessionalAgentCards: [],
workspaceStatus: '等待指令',
planGddState: createPlanGddStateView(),
planGddHydrateBusy: false,
planGddDecisionBusy: false,
planGddError: null,
onPlanGddRefresh: vi.fn(),
onPlanGddDecision: vi.fn(),
runtime: null,
error: '',
runtimeByAgentId: {},
controlBusy: false,
professionalResultsByAgentId: {},
onToolAction: vi.fn(),
onSupervisorRetry: vi.fn(),
onProfessionalToolAction: vi.fn(),
onProfessionalRetry: vi.fn(),
onUserInput: vi.fn(),
initialSupervisorMessage: initialText,
};
const initialUserBubbles = (surface: HTMLElement) =>
Array.from(surface.querySelectorAll('.message--user')).filter((node) =>
node.textContent?.includes(initialText),
);
// 正式条目还没到:占位顶上,只有一条。
const pending = render(
React.createElement(ProjectSupervisorView, {
...baseProps,
directEntries: [],
}),
);
expect(
initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')),
).toHaveLength(1);
pending.unmount();
// 本地乐观气泡先于原生条目到达,也已接管初始占位。
const optimistic = render(
React.createElement(ProjectSupervisorView, {
...baseProps,
directEntries: [],
conversationMessages: [
{
messageId: 'direct-codex:turn-1:user',
role: 'user' as const,
text: initialText,
updatedAt: 1_789_700_960_000,
},
],
}),
);
expect(
initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')),
).toHaveLength(1);
// 用户真实重复发送同文时仍保留两条,只撤下额外占位。
optimistic.rerender(
React.createElement(ProjectSupervisorView, {
...baseProps,
directEntries: [],
conversationMessages: [
{
messageId: 'direct-codex:turn-1:user',
role: 'user' as const,
text: initialText,
updatedAt: 1_789_700_960_000,
},
{
messageId: 'direct-codex:turn-2:user',
role: 'user' as const,
text: initialText,
updatedAt: 1_789_700_970_000,
},
],
}),
);
expect(
initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')),
).toHaveLength(2);
optimistic.unmount();
// 正式 Direct 条目到了:只剩正式气泡那一条,占位撤掉。
const landed = render(
React.createElement(ProjectSupervisorView, {
...baseProps,
directEntries: [
{
itemId: 'direct-codex:turn-1:user',
kind: 'message' as const,
role: 'user' as const,
text: initialText,
at: 1_789_700_960_000,
},
],
}),
);
expect(
initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')),
).toHaveLength(1);
landed.unmount();
// 已经翻出更早的历史:这里不是对话开头,不补最初那条消息。
render(
React.createElement(ProjectSupervisorView, {
...baseProps,
hasEarlierConversationMessages: true,
directEntries: [],
}),
);
expect(
initialUserBubbles(screen.getByLabelText('陶泥儿项目对话')),
).toHaveLength(0);
});
it.skip('renders the Codex empty state and opens the panel settings overlay in a fresh direct chat', async () => {
@@ -1,13 +1,17 @@
import { act } from '@testing-library/react';
import { vi } from 'vitest';
import type { GameCreatorDirectToolCall } from '../../src/app/types';
import { ToolCallGroup } from '../../src/features/project-workspace/ToolCallGroup';
import {
formatClockTime,
formatToolCallDuration,
formatTurnDuration,
resolveTurnTiming,
toolCallDurationMs,
toolCallGroupSummary,
toolCallRowText,
turnToolCallDurationMs,
turnToolCallTimeLabel,
turnTotalDurationMs,
} from '../../src/features/project-workspace/toolCallGroupPresentation';
import { expect, fireEvent, it, React, render, within } from './harness';
@@ -219,12 +223,11 @@ export function registerToolCallGroupTests() {
});
it('formats durations and turn totals across the documented boundaries', () => {
// 单条工具耗时:`startedAt` 缺失(0/ 0 / 时间倒序 → 不显示耗时。
// 单条工具耗时:开始边界缺失(0/ 终点缺失 / 时间倒序 → 不显示耗时。
expect(
toolCallDurationMs(toolCall({ id: 'a', kind: 'command' })),
).toBeNull();
expect(formatToolCallDuration(null)).toBeNull();
expect(formatToolCallDuration(0)).toBeNull();
expect(
toolCallDurationMs(
toolCall({
@@ -235,45 +238,53 @@ export function registerToolCallGroupTests() {
}),
),
).toBeNull();
// <1s 一位小数;<60s 整秒省略小数;≥60s 用 `Xm Ys`
// 合法 0 显示 `0.0s`;两种耗时都始终一位小数
expect(formatToolCallDuration(0)).toBe('0.0s');
expect(formatToolCallDuration(400)).toBe('0.4s');
expect(formatToolCallDuration(950)).toBe('1s');
expect(formatToolCallDuration(950)).toBe('1.0s');
expect(formatToolCallDuration(12300)).toBe('12.3s');
expect(formatToolCallDuration(12000)).toBe('12s');
expect(formatToolCallDuration(125000)).toBe('2m 5s');
expect(formatToolCallDuration(120000)).toBe('2m');
expect(formatToolCallDuration(12000)).toBe('12.0s');
expect(formatToolCallDuration(59900)).toBe('59.9s');
expect(formatToolCallDuration(60000)).toBe('1m 0.0s');
expect(formatToolCallDuration(125000)).toBe('2m 5.0s');
// 一回合总用时 = min(startedAt) → max(updatedAt);缺时间戳的工具被跳过
const calls = [
toolCall({ id: 'a', kind: 'command', startedAt: 5000, updatedAt: 6000 }),
toolCall({
id: 'b',
kind: 'file_change',
startedAt: 1000,
updatedAt: 9000,
}),
toolCall({ id: 'c', kind: 'web_search' }),
];
expect(turnToolCallDurationMs(calls)).toBe(8000);
expect(formatTurnDuration(8000)).toBe('8秒');
expect(formatTurnDuration(42000)).toBe('42秒');
expect(formatTurnDuration(240000)).toBe('4分钟');
expect(formatTurnDuration(345000)).toBe('5分钟 45秒');
// 整轮总耗时 = 本轮起点 → 本轮终态(不是块内工具的时间跨度)
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 9000 })).toBe(8000);
// 运行中用调用方给的宿主时钟当终点;结束早于开始 / 缺任一端都算不出来。
expect(
turnTotalDurationMs({ startedAt: 1000, running: true, now: 4200 }),
).toBe(3200);
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 900 })).toBeNull();
// 倒序不能被 100ms 展示舍入掩盖成合法 0.0 秒。
expect(turnTotalDurationMs({ startedAt: 1049, endedAt: 1001 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 0, endedAt: 9000 })).toBeNull();
expect(turnTotalDurationMs({ startedAt: 1000, endedAt: 0 })).toBeNull();
// 总耗时文案:合法 0 显示 `0.0秒`,始终一位小数。
expect(formatTurnDuration(0)).toBe('0.0秒');
expect(formatTurnDuration(8000)).toBe('8.0秒');
expect(formatTurnDuration(42000)).toBe('42.0秒');
expect(formatTurnDuration(59900)).toBe('59.9秒');
expect(formatTurnDuration(60000)).toBe('1分钟 0.0秒');
expect(formatTurnDuration(240000)).toBe('4分钟 0.0秒');
expect(formatTurnDuration(345000)).toBe('5分钟 45.0秒');
expect(formatTurnDuration(null)).toBeNull();
expect(formatTurnDuration(0)).toBeNull();
// 全部没有时间戳时算不出总用时。
expect(
turnToolCallDurationMs([toolCall({ id: 'd', kind: 'command' })]),
).toBe(null);
expect(formatTurnDuration(undefined)).toBeNull();
// 块头时间:取得到用户消息时间就是「发送 → 结束」,取不到只显示结束时间,都取不到就不显示
expect(turnToolCallTimeLabel(calls, 1000)).toMatch(
/^\d{2}:\d{2}:01 → \d{2}:\d{2}:09$/,
);
expect(turnToolCallTimeLabel(calls, 0)).toMatch(/^\d{2}:\d{2}:09$/);
// 时间范围与总耗时同一组边界:起点 / 终点都取整到 100ms 网格,精度不会互相矛盾
const timing = resolveTurnTiming({ startedAt: 1000, endedAt: 42000 });
expect(timing.durationMs).toBe(41000);
expect(timing.durationText).toBe('41.0秒');
expect(timing.timeLabel).toMatch(/^\d{2}:\d{2}:01\.0 → \d{2}:\d{2}:42\.0$/);
// 起点未知:只显示终点时间,耗时隐藏(不编造)。
const noStart = resolveTurnTiming({ startedAt: 0, endedAt: 42000 });
expect(noStart.durationMs).toBeNull();
expect(noStart.durationText).toBeNull();
expect(noStart.timeLabel).toMatch(/^\d{2}:\d{2}:42\.0$/);
// 两端都没有:整块不显示时间和耗时。
expect(
turnToolCallTimeLabel([toolCall({ id: 'e', kind: 'command' })], 0),
).toBe(null);
resolveTurnTiming({ startedAt: 0, endedAt: 0 }).timeLabel,
).toBeNull();
expect(formatClockTime(0)).toBeNull();
});
it('renders per-row durations plus the turn total, and nothing when timestamps are missing', () => {
@@ -303,23 +314,25 @@ export function registerToolCallGroupTests() {
updatedAt: 17900,
}),
],
userSentAt: 1000,
turnStartedAt: 1000,
turnEndedAt: 17900,
}),
);
const group = container.querySelector(
'[data-testid="agent-tool-call-group"]',
) as HTMLElement;
// 总用时1000 → 17900,块头显示「时 17秒」,`data-duration-ms` 暴露原始毫秒。
// 总耗时读**整轮**边界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('时 17秒');
expect(head.getAttribute('aria-label')).toBe(
'已执行 1 个命令、1 个文件变更、1 个联网搜索,用时 17秒',
expect(head.textContent).toContain('总耗时 16.9秒');
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秒$/,
);
// 时间戳不写死时区:`HH:mm:ss → HH:mm:ss`(发送 → 结束)。
// 时间戳不写死时区:`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{2}:\d{2}:\d{2}$/);
).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');
@@ -327,9 +340,9 @@ export function registerToolCallGroupTests() {
expect(within(rows[0] as HTMLElement).getByText('0.4s')).not.toBeNull();
expect(rows[1]?.getAttribute('data-duration-ms')).toBe('16500');
expect(within(rows[1] as HTMLElement).getByText('16.5s')).not.toBeNull();
// startedAt === updatedAt耗时为 0 —— `data-duration-ms` 如实暴露 0,但行上不显示 `0s`。
// startedAt === updatedAt合法 0,按契约显示 `0.0s`。
expect(rows[2]?.getAttribute('data-duration-ms')).toBe('0');
expect(within(rows[2] as HTMLElement).queryByText('0s')).toBeNull();
expect(within(rows[2] as HTMLElement).getByText('0.0s')).not.toBeNull();
// 时间只显示在块头,展开后不重复追加块尾时间。
expect(
within(group).queryByTestId('agent-tool-call-group-end-time'),
@@ -361,4 +374,132 @@ export function registerToolCallGroupTests() {
missingGroup.querySelector('.agent-tool-call-group-end-time'),
).toBeNull();
});
/**
* 动态计时:两套耗时都每 100ms 刷新、始终一位小数。
*
* 假时钟同时接管 `Date.now()``useLiveNow` 读时间戳算差值,不按 tick 累加。
*/
it('grows the turn total every 100ms with one decimal and freezes it when the turn ends', () => {
vi.useFakeTimers();
const startedAt = Date.now();
const calls = [
toolCall({
id: 'live-a',
kind: 'command',
summary: 'npm run build',
status: 'running',
startedAt,
}),
toolCall({
id: 'live-b',
kind: 'web_search',
summary: '玩法调研',
status: 'running',
startedAt: startedAt + 400,
}),
];
const view = render(
React.createElement(ToolCallGroup, {
calls,
turnStartedAt: startedAt,
active: true,
}),
);
const group = view.container.querySelector(
'[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');
// 没有新事件,时间推进总耗时也增长;5000ms 后是 `5.0秒`。
act(() => {
vi.advanceTimersByTime(5000);
});
expect(group.getAttribute('data-duration-ms')).toBe('5000');
expect(head.textContent).toContain('总耗时 5.0秒');
// 分钟进位:65000ms → `1分钟 5.0秒`。
act(() => {
vi.advanceTimersByTime(60000);
});
expect(head.textContent).toContain('总耗时 1分钟 5.0秒');
// 回合收口:总耗时冻结在终态时间上,继续推进时钟不再变化。
const frozenEnd = startedAt + 65000;
view.rerender(
React.createElement(ToolCallGroup, {
calls,
turnStartedAt: startedAt,
turnEndedAt: frozenEnd,
active: false,
}),
);
act(() => {
vi.advanceTimersByTime(30000);
});
expect(group.getAttribute('data-duration-ms')).toBe('65000');
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', () => {
vi.useFakeTimers();
const startedAt = Date.now();
const runningCall = toolCall({
id: 'running-call',
kind: 'command',
summary: 'npm run build',
status: 'running',
startedAt: startedAt,
});
const finishedCall = toolCall({
id: 'finished-call',
kind: 'file_change',
summary: 'game/src/hero.ts',
startedAt: startedAt,
updatedAt: startedAt + 1000,
});
// 同一轮的第二个工具块(中间夹了思考):共用整轮边界,各自从头读同一个起点。
const firstGroup = render(
React.createElement(ToolCallGroup, {
calls: [runningCall],
turnStartedAt: startedAt,
active: true,
}),
);
const secondGroup = render(
React.createElement(ToolCallGroup, {
calls: [finishedCall],
turnStartedAt: startedAt,
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');
firstGroup.unmount();
secondGroup.unmount();
vi.useRealTimers();
});
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
emptyDirectThreadChatState,
finishDirectThreadTurn,
mergeDirectHistoryItems,
reduceDirectThreadEvents,
resolveDirectThreadBootstrap,
@@ -211,4 +212,204 @@ describe('DirectProject 聊天 reducer', () => {
expect(entries[0]?.toolCall?.status).toBe('completed');
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
});
describe('事件级计时边界', () => {
/** 工具的开始 / 完成只读事件级 `at`,条目 `item.at` 不作起止。 */
it('工具耗时只读事件级 at,不把 item.at 当开始或完成', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({
type: 'item.started',
at: 1_000_100,
item: toolStarted({ at: 999_999 }),
}),
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput({ at: 1_200_000 }),
}),
]);
const call = selectDirectChatEntries(state)[0]?.toolCall;
expect(call?.startedAt).toBe(1_000_100);
expect(call?.updatedAt).toBe(1_000_700);
});
it('只有完成事件、没有开始事件的工具不猜开始时间', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
const call = selectDirectChatEntries(state)[0]?.toolCall;
expect(call?.startedAt).toBe(0);
expect(call?.updatedAt).toBe(1_000_700);
});
it('内部工具的阶段状态:开始事件就是运行中,正式完成事件才结束', () => {
const fileChange = {
itemType: 'fileChange',
itemId: 'file-1',
changes: [{ path: 'game/src/hero.ts', kind: 'update' }],
at: 0,
} as const;
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.started', at: 1_000_100, item: fileChange }),
]);
const startedCall = selectDirectChatEntries(started)[0]?.toolCall;
expect(startedCall?.status).toBe('running');
expect(startedCall?.startedAt).toBe(1_000_100);
expect(startedCall?.updatedAt).toBe(0);
const finished = reduceDirectThreadEvents(started, [
event({ type: 'item.completed', at: 1_000_900, item: fileChange }),
]);
const finishedCall = selectDirectChatEntries(finished)[0]?.toolCall;
expect(finishedCall?.status).toBe('completed');
expect(finishedCall?.updatedAt).toBe(1_000_900);
});
it('function_call 完成快照不等于工具结束:一直运行到 output 到达', () => {
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
// 同一个 function_call 又以完成快照重放一次:不是工具结束。
event({ type: 'item.completed', at: 1_000_200, item: toolStarted() }),
]);
const running = selectDirectChatEntries(state)[0]?.toolCall;
expect(running?.status).toBe('running');
// 完成快照不改开始边界,也不产生终点。
expect(running?.startedAt).toBe(1_000_100);
expect(running?.updatedAt).toBe(0);
const done = reduceDirectThreadEvents(state, [
event({ type: 'item.completed', at: 1_000_900, item: toolOutput() }),
]);
const finished = selectDirectChatEntries(done)[0]?.toolCall;
expect(finished?.status).toBe('completed');
expect(finished?.updatedAt).toBe(1_000_900);
});
it('收口后重复 / 迟到的终态事件不抬高冻结终点、不复活运行态', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput(),
}),
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
]);
expect(done.turnRunning).toBe(false);
expect(done.turnEndedAt).toBe(1_000_900);
expect(done.history[0]?.turnEndedAt).toBe(1_000_900);
expect(done.history[0]?.turnStartedAt).toBe(1_000_000);
const replayed = reduceDirectThreadEvents(done, [
// 重复的完成事件 + 迟到的事件不得抬高已经冻结的终点。
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
event({ type: 'item.completed', at: 1_009_900, item: toolOutput() }),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(1_000_900);
expect(replayed.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
});
it('回合身份只认事件顺序:同一秒内开始的下一轮也照常接上', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
// 宿主收口的毫秒时间:原生回合时间是秒级精度,"上一轮结束之后又来一条开始事件"
// 就是新回合,不能因为时间不比终点晚就把它当重放吞掉。
event({ type: 'turn.completed', status: 'cancelled', at: 1_000_900 }),
]);
const next = reduceDirectThreadEvents(done, [
event({ type: 'turn.started', at: 1_000_000 }),
]);
expect(next.turnRunning).toBe(true);
expect(next.turnStartedAt).toBe(1_000_000);
expect(next.turnEndedAt).toBe(0);
});
it('同一轮内的重复开始事件保留第一次的起点', () => {
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'turn.started', at: 1_000_500 }),
]);
expect(started.turnRunning).toBe(true);
expect(started.turnStartedAt).toBe(1_000_000);
});
it('终态之后同身份的迟到条目补进历史,不挂到下一轮运行态', () => {
const done = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.started', at: 1_000_100, item: toolStarted() }),
event({ type: 'turn.completed', status: 'completed', at: 1_000_900 }),
]);
// 迟到的事件:同身份的完成快照在回合收口之后才到。
const late = reduceDirectThreadEvents(done, [
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
expect(late.live).toHaveLength(0);
expect(late.history).toHaveLength(1);
expect(late.history[0]?.toolCall?.status).toBe('completed');
expect(late.history[0]?.toolCall?.detail.output).toBe('assets\ngame');
// 冻结的终点不被抬高。
expect(late.history[0]?.toolCall?.updatedAt).toBe(1_000_700);
// 新回合开始后,迟到条目仍然回历史,不会挤进这一轮。
const next = reduceDirectThreadEvents(late, [
event({ type: 'turn.started', at: 1_001_000 }),
event({ type: 'item.completed', at: 1_001_700, item: toolOutput() }),
]);
expect(next.live).toHaveLength(0);
expect(next.history).toHaveLength(1);
});
it('bootstrap 已把用户消息当历史锚点给出时,边界仍落在本轮开口条目上', () => {
const bootstrapped = resolveDirectThreadBootstrap(
mergeDirectHistoryItems(emptyDirectThreadChatState(), [
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图' }),
]),
{
subscriptionId: 'sub-1',
lastCompletedItemId: 'msg-user',
events: [
// 运行态里只有过程条目:开口条目已经在历史里。
event({
type: 'item.completed',
at: 1_000_700,
item: toolOutput(),
}),
event({
type: 'turn.completed',
status: 'completed',
at: 1_000_900,
}),
],
},
);
expect(selectDirectChatEntries(bootstrapped)[0]?.turnEndedAt).toBe(
1_000_900,
);
});
it('宿主终止收口:没有权威终态时间就不写,之后的原生终态事件也不会被抬高', () => {
const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
event({ type: 'turn.started', at: 1_000_000 }),
event({ type: 'item.completed', at: 1_000_700, item: toolOutput() }),
]);
const released = finishDirectThreadTurn(running, Date.now());
expect(released.turnRunning).toBe(false);
expect(released.live).toHaveLength(0);
expect(released.history[0]?.turnEndedAt).toBe(released.turnEndedAt);
const unknown = finishDirectThreadTurn(running, 0);
expect(unknown.turnEndedAt).toBe(0);
expect(unknown.history[0]?.turnEndedAt).toBeUndefined();
// 已经收口:后续事件不再改动终态时间,也不复活运行态。
const replayed = reduceDirectThreadEvents(unknown, [
event({ type: 'turn.completed', status: 'completed', at: 1_009_900 }),
]);
expect(replayed.turnRunning).toBe(false);
expect(replayed.turnEndedAt).toBe(0);
});
});
});
@@ -55,6 +55,31 @@ const toolEntry = (
},
});
/** 工具条目:边界只来自事件级时间,历史切片拿不到就留 0。 */
const liveToolEntry = (
itemId: string,
startedAt: number,
updatedAt: number,
status: 'running' | 'completed' = 'completed',
): DirectChatEntry => ({
itemId,
kind: 'tool',
role: null,
text: null,
at: 0,
toolCall: {
schemaVersion: 'agc-tool-call.v1',
id: itemId,
kind: 'command',
title: '执行命令',
summary: 'npm run build',
status,
detail: { command: 'npm run build' },
startedAt,
updatedAt,
},
});
const reasoningEntry = (
itemId: string,
text = '先看目录',
@@ -104,7 +129,8 @@ describe('DirectProject 聊天分区', () => {
expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' });
expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' });
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
expect(turns[0]?.endedAt).toBe(1_800_000_001_000);
// 终点只认明确终态:旧历史条目里没有 `turnEndedAt` 就隐藏,不拿最后一条正文的时间顶替。
expect(turns[0]?.endedAt).toBe(0);
});
it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => {
@@ -166,6 +192,59 @@ describe('DirectProject 聊天分区', () => {
]);
});
it('整轮边界只落在本轮:旧历史回合没有终态就继续隐藏,不吃最新回合的终点', () => {
const turns = buildDirectChatTurns({
entries: [
// 两个旧回合:历史切片里没有事件级边界。
userEntry('u1', 1_800_000_000_000),
assistantEntry('a1', '第一轮答复', 1_800_000_001_000),
userEntry('u2', 1_800_000_010_000),
assistantEntry('a2', '第二轮答复', 1_800_000_011_000),
// 当前回合:用户发送时间 + 明确终态(收口时盖在本轮条目上)。
{
...userEntry('u3', 1_800_000_020_000),
turnEndedAt: 1_800_000_022_500,
},
liveToolEntry('t3', 1_800_000_021_000, 1_800_000_022_000),
{
...assistantEntry('a3', '第三轮答复', 1_800_000_022_400),
turnStartedAt: 1_800_000_020_000,
turnEndedAt: 1_800_000_022_500,
},
],
});
expect(turns.map((turn) => turn.endedAt)).toEqual([
0, 0, 1_800_000_022_500,
]);
// 旧历史回合并不会因为"拿不到时间"被填上最新回合的终点。
expect(turns[0]?.startedAt).toBe(1_800_000_000_000);
expect(turns[2]?.startedAt).toBe(1_800_000_020_000);
});
it('运行中的整轮起点:优先用户实际发送时间,缺失才用原生 turn.started.at', () => {
const liveTurn = buildDirectChatTurns({
entries: [
{ ...userEntry('u1', 0), at: 0 },
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
],
turnRunning: true,
turnStartedAt: 1_800_000_000_100,
});
// 用户条目没有发送时间:用原生 turn.started.at 兜底。
expect(liveTurn[0]?.startedAt).toBe(1_800_000_000_100);
const withUserTime = buildDirectChatTurns({
entries: [
userEntry('u1', 1_800_000_000_050),
liveToolEntry('t1', 1_800_000_000_500, 0, 'running'),
],
turnRunning: true,
turnStartedAt: 1_800_000_000_100,
});
// 用户发送时间更早且是真实发送:以它为准,不取所有条目的最小时间。
expect(withUserTime[0]?.startedAt).toBe(1_800_000_000_050);
});
it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => {
const turns = buildDirectChatTurns({
entries: [userEntry('u1'), assistantEntry('a1', '正文')],
@@ -0,0 +1,33 @@
# 【实施计划】对话总耗时与动态工具计时
| 字段 | 值 |
| --- | --- |
| Status | implemented-awaiting-runtime-acceptance |
| Milestone | docs/project-memory/plans/【里程碑】对话总耗时与动态工具计时-2026-09-18.md |
## 修改边界与顺序
1. 独立评审主规范与里程碑后进入实现。当前问题是工具组窗口、整轮范围和运行快照混用;不能只把 setInterval 从 1000 改成 100。
2. 原生在现有生命周期事件上提供阶段时间:`turn.started` / `turn.completed` 的事件级 `at`,以及 `item.started` / `item.completed` 的事件级 `at`,不是更改条目嵌套的 `item.at`。工具计时只消费阶段事件时间,嵌套 `item.at` 继续承载条目展示时间,不当作真实开始/完成。开始/完成事件优先读取对应上游阶段字段或时长,缺失时在宿主事件产生时取钟。TypeScript 可选数值字段处理既有历史/无时间夹具,真实宿主始终提供时间;ts-rs 生成绑定,按仓库 u64→number 约定处理,不手改。
3. 前端事件 reducer 保留工具开始、完成边界以及本轮终态时间;完成边界可附在本轮聊天条目展示元数据上,沿用现有用户条目划分回合,不新增 turnId 或第二套生命周期。无时间旧历史不编造准确总耗时。
- 整轮起点:该轮用户消息的实际发送时间优先,缺失才取 `turn.started.at`;与用户要求的“发送到完成”一致,不能改为第一条工具的起点。终点只取明确终态事件,不能取最后一条正文的创建时间或最后工具更新时间。
- 只有完成没有开始事件的工具隐藏未知耗时;缺失、0、非有限或倒序边界隐藏,正常运行的零时长显示 `0.0`
4. 视图传递同轮起止时间到各工具块,以共享或局部有界的 100ms 时钟驱动展示,不把整棵 App 状态每 100ms 重建。只在有运行中计时目标时订阅,结束/切项目/卸载清理。
5. 测试先覆盖静止事件输入下推进时钟、工具完成但回合仍运行、终态与重复事件;补阶段字段与回放时间测试。独立 review 后主 Agent 核对并返修。
6. 同视图的追加局部修复:初始消息占位判定应读取实际 Direct 用户条目/乐观用户条目和分页状态,不只检查旧 conversationMessages;增加正式气泡只出现一次、合法同文消息不被去重的回归。
## 并行写入边界
- 原生实现者:`src-tauri/src/agent/direct_thread_wire.rs``codex_app_server/mod.rs`、必要的 `direct_thread_manager.rs` 模式匹配/测试及 ts-rs 生成绑定。
- 前端实现者:`directThreadChat.ts``directThreadItemProjection.ts``directTurnPresentation.ts``ToolCallGroup.tsx``toolCallGroupPresentation.ts``ProjectSupervisorView.tsx`、必要的 App 接线与对应测试。不得修改原生或生成绑定。
- 主 Agent:文档、集成验证、独立 review 决策与浏览器 smoke;不得覆盖其他人的未提交文件。
## 验证与风险
前端使用已有 `.app/merge-deps` 隔离配置,不修改共享 node_modules。运行定向 Vitest、AGC 类型/skill/config 检查、Rust wire/manager 相关测试与 all-targets offline check、编码/文档索引/diff 检查。历史缺少完整时间时隐藏无法证明的用时,不迁移历史文件。原生协议变更需重新启动客户端才能生效,不擅自重启用户正在运行的回合。
回滚点为本次改动前的 `1bd153d7f`;若失败只回退本次文件变更,不动用户锁文件和其它分支。
## 收口
已完成本轮实现、独立 review 返修与自动化/模拟浏览器验证,证据见对应里程碑。用户已授权更新 Issue/PR 并推送当前功能分支;不得据此合并 PR、发布或恢复定时任务。真实客户端端到端验收尚待完成,因此保留计划,卡片全面重设计和画布内任务浮层不计入本轮已交付内容。
@@ -0,0 +1,35 @@
# 【里程碑】对话总耗时与动态工具计时
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | implemented-awaiting-runtime-acceptance |
| Date | 2026-09-18 |
| Parent Spec | docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md |
## 目标与边界
工具组显示整轮请求总耗时,单条显示自身调用耗时,运行中均以 100 毫秒粒度动态增长并保留一位小数。沿用 Thread Manager,不新增业务状态源或计时账本。保留既有画布功能和本地未提交锁文件;用户后续已授权更新 Issue/PR 与推送本分支,不合并 PR、不发布、不写飞书、不调用真实付费 Provider。
## 验收
前置独立评审已完成:事件级时间与条目展示时间分离、缺失/倒序判据、章节结构及格式示例已明确。主 Agent 决定整轮起点以用户发送为优先、原生 turn.started 为缺失兜底,不采用会排除发送后准备时间的起点替换。
- [x] 总耗时从用户发送到回合终态,跨多个工具块共享边界,包含无工具运行时的 LLM 等待。
- [x] 工具运行中无事件亦增长,已完成工具与完成回合均冻结,失败/终止通过既有明确收口路径冻结。
- [x] 原生开始/完成时间不混用,事件重放不重新取当前时间。
- [x] 重复、迟到条目不抬高已冻结用时;缺失/倒序时间不伪造耗时。同秒新回合仍按事件顺序正常开始。
- [x] 一位小数、分钟进位、并行调用与卸载清理有定向/浏览器证据。
- [x] 类型检查、Rust 定向测试与 all-targets check、编码/文档索引及 diff 检查通过。
- [x] 模拟事件的实际浏览器验证动态增长与终态冻结;未调用真实 Provider。
- [x] 正式 Direct 用户条目或本地乐观条目出现后撤下初始占位,真实同文重复发送仍保留。
- [ ] 真实客户端切项目、取消后立即重发、重开历史及真实 Provider 通知链路验收。
## 验证与剩余风险
- appSurface 与 reducer/回合投影三文件合计 493 项通过、17 项跳过;最终初始占位与计时边界补丁另跑 5 项通过。
- 隔离依赖配置下 AGC TypeScript、skill/config、ESLint、编码与文档索引检查通过;Rust all-targets locked offline check 通过。
- 原生实现者执行 direct_thread 35 项、direct_ 312 项(1 跳过)、codex_app_server 58 项(1 跳过)及系统工程提示定向用例通过,过滤集合有重叠,不能相加作为独立用例总数。
- Edge 无头真实浏览器使用生产组件与 reducer、模拟生命周期事件:无新事件仍每 100ms 增长,单工具完成后冻结,另一工具与整轮继续增长,回合完成及重复完成事件不改变最终值。
- 无上游精确阶段时间时使用宿主观测毫秒,不宣称测得模型内部物理耗时;旧历史缺少完整生命周期时间时隐藏未知用时。
- 此证据不代表远程 CI 全绿。此前 PR 当前已推送提交的 run 2473 有两条 Rust shard 2 失败,仍需单独排查及后续 SHA 的远程结果。
@@ -1,5 +1,11 @@
# 踩坑与排障记录
## Phaser 与 CSS 双重居中导致游戏画面偏移
Phaser `Scale.FIT``autoCenter: CENTER_BOTH` 会给 canvas 计算定位外边距。若 canvas 的直接父容器同时使用 `display: grid; place-items: center` 或另一套 CSS 居中,浏览器再次定位带 margin 的元素,竖屏游戏会相对预览区域偏右。应只保留一个居中责任方:Phaser 居中时直接父容器使用尺寸明确的普通块布局;CSS 居中时设置 `autoCenter: NO_CENTER`。不禁止外围页面的 Grid/Flex 布局,不通过修改 AGC iframe 的固定偏移掩盖项目 CSS 问题。
开发 Agent 的实际系统工程提示和 `agc-web-game-development` Skill 均包含此规则。布局修改后重新构建 dist,分别在桌面、移动与 resize 后测量 canvas 相对游戏父容器的中心误差(预期居中时不超过 1 CSS px),同时检查无溢出和意外滚动条;构建成功不等于视觉验收通过。
## AGC 空快照测试必须等待请求完成
`waitFor(() => expect(activeTurns).toEqual([]))` 在 Hook 初始状态就能成功,不能证明首次异步读取已经完成。引用稳定性回归应显式控制 Promise 完成,并同时检查首次空响应与禁用后的引用;快照签名初值必须与初始空数组一致。窗口同步测试应验证未变化状态不重复发布,不能依赖一次多余的空态更新。
@@ -16,6 +16,8 @@
## 开发中
- Direct 对话计时区分条目展示时间与生命周期事件时间:整轮用用户发送到明确终态的跨度,工具用各自开始/完成边界;运行时用 100ms 叶子时钟刷新一位小数,终态冻结,旧历史缺边界不推测。不得用整秒时间的大小比较取代 Thread Manager 的事件顺序判定新回合。
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。

Some files were not shown because too many files have changed in this diff Show More