修复对话动态计时与初始消息重复并补齐游戏居中指引
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
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:
+3
@@ -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.
|
||||
|
||||
+2
@@ -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(
|
||||
¶ms,
|
||||
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(¶ms);
|
||||
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(), ¶ms["item"], 7_777).expect("item");
|
||||
// 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。
|
||||
assert_eq!(item.at(), 1_000);
|
||||
assert_eq!(
|
||||
direct_thread_item_event_at_ms(¶ms, ¶ms["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(¶ms, ¶ms["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(¶ms, ¶ms["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})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+59
-36
@@ -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[],
|
||||
|
||||
+139
-62
@@ -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,
|
||||
|
||||
+37
-14
@@ -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,
|
||||
|
||||
+40
-4
@@ -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;
|
||||
|
||||
+133
-78
@@ -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
Reference in New Issue
Block a user