修复回合重进状态并收起已完成执行过程

活动回合快照与Direct事件统一管理生命周期,避免历史终态回放恢复忙碌态

用户消息显示发送时间,历史信封保存时间并通过独立映射回读

完成后统一折叠中间输出和工具调用,最终回复与失败提示保持可见

同步回归用例与规范文档,保留原生实机验收待办
This commit is contained in:
2026-09-16 02:57:27 +08:00
parent 09791eb9e4
commit 7a7a841733
16 changed files with 558 additions and 363 deletions
@@ -5,6 +5,7 @@ use crate::project::{
};
use crate::{LocalConversationMessageRecord, LocalConversationResult};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
@@ -91,6 +92,10 @@ fn record(item: &Value) -> Result<String, String> {
serde_json::to_string(&serde_json::json!({
"type": DIRECT_PROJECT_HISTORY_RECORD_TYPE,
"payload": item,
"recordedAt": std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0),
}))
.map_err(|error| format!("序列化 DirectProject 历史失败:{error}"))
}
@@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st
}
pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Value>, String> {
Ok(read_direct_project_history_entries_at(root)?
.into_iter()
.map(|(item, _)| item)
.collect())
}
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
let path = history_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
return Ok(Vec::new());
@@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
if is_direct_project_internal_context_item(&item) {
continue;
}
items.push(item);
items.push((
item,
parsed
.get("recordedAt")
.and_then(Value::as_u64)
.unwrap_or(0),
));
}
Ok(items)
}
@@ -516,18 +534,30 @@ pub(crate) fn read_direct_project_history_items_slice_at(
root: &Path,
before_item_id: Option<&str>,
limit: usize,
) -> Result<(Vec<Value>, bool), String> {
let items = read_direct_project_history_items_at(root)?;
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
let items = read_direct_project_history_entries_at(root)?;
let end = match before_item_id {
Some(item_id) => items
.iter()
.position(|item| item.get("id").and_then(Value::as_str) == Some(item_id))
.position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id))
.ok_or_else(|| format!("DirectProject 历史中不存在 item{item_id}"))?,
None => items.len(),
};
let bounded_limit = limit.clamp(1, 200);
let start = end.saturating_sub(bounded_limit);
Ok((items[start..end].to_vec(), start > 0))
let slice = &items[start..end];
let timestamps = slice
.iter()
.filter_map(|(item, at)| {
let id = item.get("id").and_then(Value::as_str)?;
(*at > 0).then(|| (id.to_string(), *at))
})
.collect();
Ok((
slice.iter().map(|(item, _)| item.clone()).collect(),
start > 0,
timestamps,
))
}
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
@@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at(
root: &Path,
) -> Result<LocalConversationResult, String> {
let path = history_path(root);
let items = read_direct_project_history_items_at(root)?;
let items = read_direct_project_history_entries_at(root)?;
let messages = items
.into_iter()
.filter_map(|item| {
.filter_map(|(item, recorded_at)| {
let role = item.get("role").and_then(Value::as_str)?;
if !matches!(role, "user" | "assistant") {
return None;
@@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at(
content,
agent_id: None,
message_id: item.get("id").and_then(Value::as_str).map(str::to_string),
updated_at: 0,
updated_at: recorded_at,
})
})
.collect();
@@ -610,6 +640,40 @@ mod tests {
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
#[test]
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
let root = init_history_project("history-time");
let item = json!({
"type": "message", "role": "user", "id": "sent-message",
"content": [{"type": "input_text", "text": "修改游戏"}],
});
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (items, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(items, vec![item.clone()]);
assert!(timestamps["sent-message"] > 0);
append_direct_project_user_message_at(root.path(), &item).unwrap();
let (_, _, reloaded) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert_eq!(timestamps, reloaded);
}
#[test]
fn old_history_without_envelope_time_stays_unknown() {
let root = init_history_project("history-unknown-time");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
let (_, _, timestamps) =
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
assert!(timestamps.is_empty());
assert_eq!(
read_direct_project_chat_history_at(root.path())
.unwrap()
.messages[0]
.updated_at,
0
);
}
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
@@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult {
pub(crate) struct DirectThreadHistorySlice {
pub(crate) items: Vec<Value>,
pub(crate) has_more: bool,
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
}
#[derive(Clone, Debug)]
@@ -5373,12 +5373,16 @@ pub(crate) async fn read_direct_project_history_slice(
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (items, has_more) = read_direct_project_history_items_slice_at(
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
Ok(DirectThreadHistorySlice { items, has_more })
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
})
})
.await
.map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))?
+165 -173
View File
@@ -55,7 +55,6 @@ import type {
DesignClarificationRequest,
DesignEvent,
DesignView,
DirectActiveTurnView,
DirectTurnCancelView,
GameCreatorAgentRuntimeUpdateEvent,
GameCreatorChatAgentReply,
@@ -137,6 +136,7 @@ import {
submitProjectSupervisorRuntimeTask,
taskRowsFromManifest,
} from './features/agent-runtime';
import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns';
import {
type DirectCodexTurnAttachment,
toDirectCodexTurnAttachments,
@@ -242,8 +242,7 @@ import {
directThreadHistoryItemsToMessages,
type DirectThreadHistorySlice,
type DirectThreadSubscriptionBootstrap,
emptyDirectThreadReducerState,
reduceDirectThreadEvents,
isDirectTurnInProgress,
} from './features/project-workspace/directThreadEvents';
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
import {
@@ -321,8 +320,6 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER =
const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000;
const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE =
'该回合已无响应,可在输入盒点「终止」结束它以继续';
const DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL =
'已恢复正在运行的回合,正在等待陶泥儿的最新进度';
// Platform access tokens are short lived. DirectProject can spend several
// minutes in image generation, build and browser validation, so keep the
// client-owned native session current while a turn is running. The singleflight
@@ -414,9 +411,8 @@ function directCodexActivityDetail(
case 'finalizing':
return '正在整理结果';
case 'completed':
return '正在提交回复';
case 'failed':
return '正在记录失败原因';
return '';
default:
return '正在处理任务';
}
@@ -449,11 +445,8 @@ function directCodexProcessDetail({
activity?: string | null;
status: string;
}) {
if (status === 'completed') {
return '正在提交回复';
}
if (status === 'failed') {
return '正在记录失败原因';
if (status === 'completed' || status === 'failed') {
return '';
}
if (status === 'streaming') {
return '正在生成回复';
@@ -960,9 +953,14 @@ export function App({
turnId: string;
lastSequence: number;
receivedDirectUpdate: boolean;
restored?: boolean;
} | null>(null);
const directActiveSnapshotVersionRef = useRef(0);
const directTurnLifecycleRef = useRef<{
reset: () => void;
loadHistory: (projectPath: string) => Promise<void>;
restore: (projectPath: string) => Promise<void>;
} | null>(null);
const directThreadSubscriptionIdRef = useRef<string | null>(null);
const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState());
const lastDirectCodexActivityRef = useRef<string | null>(null);
/**
* Rust
@@ -1069,6 +1067,7 @@ export function App({
}
function resetDirectCodexTurn() {
directActiveSnapshotVersionRef.current += 1;
clearRecoveredDirectCodexTurnWatch();
activeDirectCodexTurnRef.current = null;
lastDirectCodexActivityRef.current = null;
@@ -1128,10 +1127,12 @@ export function App({
* `activeDirectCodexTurnRef`
*
*
*
*
*
*/
async function restoreRunningDirectCodexTurn(projectPath: string) {
async function restoreRunningDirectCodexTurn(
projectPath: string,
reconcile = false,
) {
if (!directCodexProductRuntime || !projectPath) {
return;
}
@@ -1139,50 +1140,87 @@ export function App({
if (!invoke) {
return;
}
// 本组件已经接管这个项目:`/history` 之类的重复读取不能把 lastSequence 归零。
if (activeDirectCodexTurnRef.current?.projectPath === projectPath) {
const owner = activeDirectCodexTurnRef.current;
const sequence = owner?.lastSequence;
const scopeVersion = projectScopeVersionRef.current;
if (!reconcile && owner?.projectPath === projectPath) {
return;
}
let activeView: DirectActiveTurnView | null = null;
const readVersion = ++directActiveSnapshotVersionRef.current;
let turns: GameCreatorDirectActiveTurn[];
try {
activeView = await invoke<DirectActiveTurnView | null>(
'read_direct_codex_active_turn',
{ projectPath },
turns = await invoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
} catch {
// 读取失败不等于没有活动回合。
return;
}
const clientTurnId = activeView?.clientTurnId?.trim();
if (!clientTurnId) {
return;
}
if (!Array.isArray(turns)) return;
if (
localProjectPathRef.current !== projectPath ||
activeDirectCodexTurnRef.current?.projectPath === projectPath
planningV2ActiveRef.current ||
designAgentLaneRef.current ||
projectScopeVersionRef.current !== scopeVersion ||
directActiveSnapshotVersionRef.current !== readVersion ||
activeDirectCodexTurnRef.current !== owner ||
owner?.lastSequence !== sequence
) {
return;
}
activeDirectCodexTurnRef.current = {
projectPath,
turnId: clientTurnId,
// 与下面发起回合的两处赋值同形(`lastSequence: -1`):Rust 侧 emitter 的 sequence
// 从 1 开始,所以恢复后到达的第一批事件不会被 sequence 过滤丢掉。
lastSequence: -1,
receivedDirectUpdate: false,
};
const activeView = turns.find(
(turn) =>
projectPathsMatchForInvalidation(turn.projectPath, projectPath) &&
isDirectTurnInProgress(turn.status),
);
if (!activeView || !isDirectTurnInProgress(activeView.status)) {
// 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。
if (owner && !owner.restored && owner.lastSequence < 0) return;
resetDirectCodexTurn();
setChatAgentBusy(false);
if (owner && reconcile) {
void loadProjectConversation(projectPath, false, 'replace');
}
return;
}
const matchingOwner = owner?.turnId === activeView.turnId ? owner : null;
if (owner && !matchingOwner) {
if (!owner.restored && owner.lastSequence < 0) return;
resetDirectCodexTurn();
}
if (!matchingOwner) {
activeDirectCodexTurnRef.current = {
projectPath,
turnId: activeView.turnId,
lastSequence: -1,
receivedDirectUpdate: false,
restored: true,
};
setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`);
setProjectSupervisorRuntimeError('');
watchRecoveredDirectCodexTurn(projectPath, activeView.turnId);
}
setChatAgentBusy(true);
setDirectCodexStatus('running');
setDirectCodexProcessKey(`${projectPath}\u0000${clientTurnId}`);
setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STARTED_DETAIL);
setDirectCodexProgressUpdatedAt(Date.now());
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
setProjectSupervisorRuntimeError('');
watchRecoveredDirectCodexTurn(projectPath, clientTurnId);
// 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。
if (
!matchingOwner?.receivedDirectUpdate ||
activeView.sequence > matchingOwner.lastSequence
) {
setDirectCodexStatus(activeView.status);
setDirectCodexProgress(
directCodexActivityDetail(activeView.activity, activeView.status),
);
setDirectCodexProgressUpdatedAt(activeView.updatedAt);
}
}
directTurnLifecycleRef.current = {
reset: resetDirectCodexTurn,
loadHistory: (projectPath) =>
loadProjectConversation(projectPath, false, 'replace'),
restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true),
};
// 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态,
// 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。
const [directTurnStream, setDirectTurnStream] = useState<TurnStreamItem[]>(
@@ -2306,15 +2344,10 @@ export function App({
? payload.updatedAt
: Date.now();
const processDetail = directCodexProcessDetail(payload);
if (payload.status === 'failed') {
activeDirectCodexTurnRef.current = null;
lastDirectCodexActivityRef.current = null;
setDirectCodexStatus(payload.status);
setDirectCodexProgress(processDetail);
setDirectCodexProgressUpdatedAt(updatedAt);
setDirectCodexTransientReply('');
setDirectCodexTransientReasoning('');
setDirectCodexTransientReplyUpdatedAt(null);
if (payload.status === 'failed' || payload.status === 'completed') {
directTurnLifecycleRef.current?.reset();
setChatAgentBusy(false);
void directTurnLifecycleRef.current?.loadHistory(payload.projectPath);
return;
}
setDirectCodexStatus(payload.status);
@@ -2374,123 +2407,77 @@ export function App({
useEffect(() => {
const projectPath = localProject?.projectPath ?? null;
const directInvoke = resolveTauriInvoke();
if (!directCodexProductRuntime || !projectPath || !directInvoke) {
directThreadSubscriptionIdRef.current = null;
directThreadReducerStateRef.current = emptyDirectThreadReducerState();
return;
}
if (!directCodexProductRuntime || !projectPath || !directInvoke) return;
let disposed = false;
let cleanup: (() => void) | null = null;
let subscriptionId: string | null = null;
let consuming = false;
let consumeAgain = false;
const applyReducerState = (
state: ReturnType<typeof emptyDirectThreadReducerState>,
) => {
if (disposed) return;
directThreadReducerStateRef.current = state;
const running =
state.status === 'accepted' ||
state.status === 'running' ||
state.status === 'streaming' ||
state.status === 'finalizing';
setChatAgentBusy(running);
setDirectCodexStatus(state.status);
setDirectCodexProgress(state.progress);
setDirectCodexProgressUpdatedAt(Date.now());
if (state.accumulatedText) {
setDirectCodexTransientReply(state.accumulatedText);
directCodexTransientReplyRef.current = state.accumulatedText;
setDirectCodexTransientReplyUpdatedAt(Date.now());
} else {
setDirectCodexTransientReply('');
directCodexTransientReplyRef.current = '';
setDirectCodexTransientReplyUpdatedAt(null);
}
// Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。
const refreshActive = () => {
if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath);
};
const bootstrap = async () => {
try {
const activeTurns = await directInvoke<GameCreatorDirectActiveTurn[]>(
'list_game_creator_direct_active_turns',
);
const activeTurn = activeTurns.find((turn) =>
projectPathsMatchForInvalidation(turn.projectPath, projectPath),
);
if (activeTurn && !activeDirectCodexTurnRef.current) {
activeDirectCodexTurnRef.current = {
projectPath,
turnId: activeTurn.turnId,
lastSequence: -1,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`);
setChatAgentBusy(true);
setDirectCodexStatus('running');
setDirectCodexProgress('正在处理');
setDirectCodexProgressUpdatedAt(Date.now());
}
} catch {
// 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。
}
const result = await directInvoke<DirectThreadSubscriptionBootstrap>(
'subscribe_direct_project_thread',
{ projectPath },
);
if (disposed) return;
directThreadSubscriptionIdRef.current = result.subscriptionId;
const state = reduceDirectThreadEvents(
result.events,
emptyDirectThreadReducerState(),
);
applyReducerState(state);
if (state.turnId && !activeDirectCodexTurnRef.current) {
activeDirectCodexTurnRef.current = {
projectPath,
turnId: state.turnId,
// Thread Manager 的 seq 与回合展示事件的 sequence 是两个独立序列。
lastSequence: -1,
receivedDirectUpdate: true,
};
setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`);
}
subscriptionId = result.subscriptionId;
refreshActive();
};
const consume = async () => {
const subscriptionId = directThreadSubscriptionIdRef.current;
if (!subscriptionId || disposed) return;
if (consuming) {
consumeAgain = true;
return;
}
consuming = true;
try {
const result = await directInvoke<DirectThreadConsumeResult>(
'consume_direct_project_thread',
{ subscriptionId },
);
if (disposed) return;
const state = reduceDirectThreadEvents(
result.events,
directThreadReducerStateRef.current,
);
applyReducerState(state);
do {
consumeAgain = false;
const result = await directInvoke<DirectThreadConsumeResult>(
'consume_direct_project_thread',
{ subscriptionId },
);
if (disposed) return;
if (
result.events.some(
(event) =>
event.type === 'turn.started' ||
event.type === 'turn.completed',
)
) {
refreshActive();
}
if (
result.events.some((event) => event.type === 'turn.completed') &&
!activeDirectCodexTurnRef.current?.receivedDirectUpdate
) {
// 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。
void directTurnLifecycleRef.current?.loadHistory(projectPath);
}
} while (consumeAgain && !disposed);
} catch (error) {
if (String(error).includes('SUBSCRIPTION_EXPIRED')) {
directThreadSubscriptionIdRef.current = null;
if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) {
subscriptionId = null;
try {
await bootstrap();
} catch {
// A later project activation or notification will retry bootstrap.
/* 活动快照轮询仍然有效。 */
}
}
} finally {
consuming = false;
}
};
const setup = async () => {
try {
const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>(
'game-creator-direct-thread-notify',
(event) => {
if (
event.payload.subscriptionId ===
directThreadSubscriptionIdRef.current
) {
void consume();
}
if (event.payload.subscriptionId === subscriptionId) void consume();
},
);
if (disposed) {
@@ -2499,15 +2486,21 @@ export function App({
}
cleanup = unlisten;
await bootstrap();
await consume();
} catch {
// The history view remains usable when the runtime subscription is unavailable.
// 历史仍可使用;订阅失败不伪造忙碌态。
}
};
refreshActive();
const timer = window.setInterval(
refreshActive,
DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS,
);
void setup();
return () => {
disposed = true;
cleanup?.();
directThreadSubscriptionIdRef.current = null;
window.clearInterval(timer);
};
}, [directCodexProductRuntime, localProject?.projectPath]);
@@ -4118,7 +4111,10 @@ export function App({
return {
path: nextProjectPath,
agentId: null,
messages: directThreadHistoryItemsToMessages(slice.items),
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
} satisfies LocalConversationResult;
});
})()
@@ -7211,9 +7207,6 @@ export function App({
setMessages((current) =>
appendDirectAssistantMessage(current, reply),
);
setDirectCodexStatus('finalizing');
setDirectCodexProgress('正在同步项目文件');
setDirectCodexProgressUpdatedAt(Date.now());
await refreshDirectProjectManifest(directProjectPath);
}
} catch (error) {
@@ -7291,21 +7284,19 @@ export function App({
await refreshManifest(directProjectPath);
}
} finally {
setChatAgentBusy(false);
setDirectCodexTurnCancelling(false);
setDirectCodexProgress('');
setDirectCodexStatus(null);
setDirectCodexProgressUpdatedAt(null);
const activeTurn = activeDirectCodexTurnRef.current;
if (
!activeTurn ||
(activeTurn.projectPath === directProjectPath &&
activeTurn.turnId === clientTurnId)
localProjectPathRef.current === directProjectPath &&
(!activeTurn ||
(activeTurn.projectPath === directProjectPath &&
activeTurn.turnId === clientTurnId))
) {
setChatAgentBusy(false);
setDirectCodexTurnCancelling(false);
resetDirectCodexTurn();
// 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。
dispatchNextQueuedChatTurn();
}
// 队列:本回合确实结束后,按 FIFO 自动发出下一条(不丢、不乱序)。
dispatchNextQueuedChatTurn();
}
}
return;
@@ -12492,18 +12483,19 @@ export function App({
if (localProjectPathRef.current !== projectPath) {
return;
}
const older = directThreadHistoryItemsToMessages(slice.items).map(
(message) => ({
role:
message.role === 'user'
? ('user' as const)
: ('assistant' as const),
text: message.content,
runtimeOwned: true,
messageId: message.messageId,
updatedAt: message.updatedAt,
}),
);
const older = directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
).map((message) => ({
role:
message.role === 'user'
? ('user' as const)
: ('assistant' as const),
text: message.content,
runtimeOwned: true,
messageId: message.messageId,
updatedAt: message.updatedAt,
}));
setMessages((current) => [...older, ...current]);
setConversationVisibleCount((current) => current + older.length);
setDirectHistoryHasMore(slice.hasMore);
@@ -58,7 +58,9 @@ import {
} from './DesignAgentSurface';
import {
buildDirectTurnPresentations,
directMessageTimestamp,
type DirectTurnPresentation,
splitDirectTurnContent,
} from './directTurnPresentation';
import { PlanGddSurface } from './GddApprovalCard';
import {
@@ -75,7 +77,10 @@ import {
} from './ResourceReferenceInput';
import type { ChatComposerDraft, ChatReference } from './resourceReferences';
import { ToolCallGroup } from './ToolCallGroup';
import { formatTurnDuration } from './toolCallGroupPresentation';
import {
formatClockTime,
formatTurnDuration,
} from './toolCallGroupPresentation';
/** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */
type TurnStreamToolRun = {
@@ -397,8 +402,7 @@ export function ProjectSupervisorView({
setApprovalOpen(false);
setApprovalNotice('');
}, [settingsOpen]);
const runBusy =
runtimePanelProps.controlBusy || Boolean(directProcessDetail) || submitting;
const runBusy = runtimePanelProps.controlBusy || submitting;
const directTurns = directCodex
? buildDirectTurnPresentations({
messages: conversationMessages,
@@ -433,26 +437,39 @@ export function ProjectSupervisorView({
</p>
);
};
const renderMessage = (message: ChatMessage, index: number) => (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
{showDesignReasoning && message.reasoningText ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{message.reasoningText}</pre>
</details>
) : null}
</div>
);
const renderMessage = (message: ChatMessage, index: number) => {
const sentAt =
message.role === 'user' ? directMessageTimestamp(message.updatedAt) : 0;
return (
<div
key={message.messageId ?? `${message.role}-${index}`}
className={`message message--${message.role}`}
>
<ChatMarkdownMessage
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
{sentAt > 0 ? (
<time
className="message-sent-at"
dateTime={new Date(sentAt).toISOString()}
title={`发送于 ${new Date(sentAt).toLocaleString('zh-CN', { hour12: false })}`}
>
{formatClockTime(sentAt)}
</time>
) : null}
{showDesignReasoning && message.reasoningText ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{message.reasoningText}</pre>
</details>
) : null}
</div>
);
};
const submitButton = (
<button
@@ -559,21 +576,19 @@ export function ProjectSupervisorView({
</div>
) : null}
{directCodex
? directTurns.map((turn) => (
<Fragment key={turn.key}>
{turn.messages
.filter((message) => message.role === 'user')
.map(renderMessage)}
{turn.source === 'stream' ? (
<>
<TurnStreamSequence
items={turn.items}
toolCalls={turn.calls}
active={turn.active}
userSentAt={turn.startedAt}
/>
{turn.notices.map(renderMessage)}
</>
? directTurns.map((turn) => {
const content = splitDirectTurnContent(turn);
const renderStream = (items: TurnStreamItem[]) => (
<TurnStreamSequence
items={items}
toolCalls={turn.calls}
active={turn.active}
userSentAt={turn.startedAt}
/>
);
const process =
turn.source === 'stream' ? (
renderStream(content.processItems)
) : (
<>
{turn.calls.length > 0 ? (
@@ -584,28 +599,59 @@ export function ProjectSupervisorView({
className="message-tool-call"
/>
) : null}
{turn.messages
.filter((message) => message.role !== 'user')
.map(renderMessage)}
{turn.transientReply ? (
<div
className="message message--assistant"
aria-label="陶泥儿实时回复"
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage
role="assistant"
text={turn.transientReply}
streaming
/>
</div>
) : null}
{content.processMessages.map(renderMessage)}
</>
)}
{renderTurnUsage(turn)}
</Fragment>
))
);
const hasProcess =
turn.source === 'stream'
? content.processItems.length > 0
: turn.calls.length > 0 ||
content.processMessages.length > 0;
return (
<Fragment key={turn.key}>
{turn.messages
.filter((message) => message.role === 'user')
.map(renderMessage)}
{turn.active ? (
process
) : hasProcess ? (
<details
className="message-turn-process"
data-testid="turn-process"
data-turn-id={turn.turnId}
>
<summary></summary>
<div className="message-turn-process-body">
{process}
</div>
</details>
) : null}
{turn.source === 'stream' ? (
<>
{renderStream(content.finalItems)}
{turn.notices.map(renderMessage)}
</>
) : (
content.finalMessages.map(renderMessage)
)}
{turn.transientReply ? (
<div
className="message message--assistant"
aria-label="陶泥儿实时回复"
aria-live="polite"
data-runtime-owned="true"
>
<ChatMarkdownMessage
role="assistant"
text={turn.transientReply}
streaming
/>
</div>
) : null}
{renderTurnUsage(turn)}
</Fragment>
);
})
: visibleMessages.map(renderMessage)}
{directCodex && transientReasoning ? (
<details
@@ -719,7 +765,9 @@ export function ProjectSupervisorView({
/>
) : null}
{directCodex &&
(runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? (
directStatus !== 'completed' &&
directStatus !== 'failed' &&
(runtimePanelProps.controlBusy || Boolean(activeTurnId)) ? (
<section
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
aria-label="陶泥儿执行过程"
@@ -21,10 +21,12 @@ export type DirectThreadConsumeResult = {
export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
};
export function directThreadHistoryItemsToMessages(
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
): LocalConversationMessageRecord[] {
return items.flatMap((raw) => {
if (!raw || typeof raw !== 'object') return [];
@@ -51,113 +53,20 @@ export function directThreadHistoryItemsToMessages(
content,
agentId: null,
messageId,
updatedAt: 0,
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
},
];
});
}
export type DirectThreadReducerState = {
lastSeq: number;
turnId: string | null;
status:
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed'
| null;
activeItemIds: Set<string>;
accumulatedText: string;
progress: string;
};
export const emptyDirectThreadReducerState = (): DirectThreadReducerState => ({
lastSeq: 0,
turnId: null,
status: null,
activeItemIds: new Set(),
accumulatedText: '',
progress: '',
});
function itemType(event: DirectThreadRawEvent) {
const value = event.payload.itemType;
return typeof value === 'string' ? value : '';
}
function activityDetail(type: string) {
switch (type) {
case 'mcpToolCall':
return '正在调用工具';
case 'commandExecution':
return '正在执行命令';
case 'fileChange':
return '正在写入文件';
case 'webSearch':
return '正在搜索资料';
case 'contextCompaction':
return '正在整理上下文';
default:
return '正在处理';
}
}
export function reduceDirectThreadEvent(
state: DirectThreadReducerState,
event: DirectThreadRawEvent,
): DirectThreadReducerState {
if (!Number.isSafeInteger(event.seq) || event.seq <= state.lastSeq) {
return state;
}
const next: DirectThreadReducerState = {
...state,
lastSeq: event.seq,
turnId: event.turnId || state.turnId,
activeItemIds: new Set(state.activeItemIds),
};
switch (event.type) {
case 'turn.started':
next.status = 'running';
next.progress = '正在处理';
next.accumulatedText = '';
next.activeItemIds = new Set();
break;
case 'item.started':
if (event.itemId) next.activeItemIds.add(event.itemId);
next.status = 'running';
next.progress = activityDetail(itemType(event));
break;
case 'item.delta': {
const delta = event.payload.delta;
if (typeof delta === 'string' && delta) {
next.accumulatedText += delta;
next.status = 'streaming';
next.progress = '正在生成回复';
}
break;
}
case 'item.completed':
if (event.itemId) next.activeItemIds.delete(event.itemId);
if (next.status === null) next.status = 'running';
break;
case 'turn.completed': {
const status = event.payload.status;
next.status = status === 'completed' ? 'completed' : 'failed';
next.progress =
status === 'completed' ? '正在提交回复' : '正在记录失败原因';
break;
}
default:
break;
}
return next;
}
export function reduceDirectThreadEvents(
events: DirectThreadRawEvent[],
initial = emptyDirectThreadReducerState(),
) {
return events.reduce(reduceDirectThreadEvent, initial);
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
export function isDirectTurnInProgress(
status: string | null | undefined,
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
return (
status === 'accepted' ||
status === 'running' ||
status === 'streaming' ||
status === 'finalizing'
);
}
@@ -26,12 +26,51 @@ export type DirectTurnPresentation = {
endedAt: number;
};
function milliseconds(value: number | undefined) {
return value && Number.isFinite(value) && value > 0
? value < 100_000_000_000
? value * 1000
: value
: 0;
export function directMessageTimestamp(value: number | undefined) {
const timestamp =
value && Number.isFinite(value) && value > 0
? value < 100_000_000_000
? value * 1000
: value
: 0;
return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : 0;
}
/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */
export function splitDirectTurnContent(turn: DirectTurnPresentation) {
const assistants = turn.messages.filter(
(message) => message.role === 'assistant',
);
const isFailureMessage = (message: ChatMessage) =>
Boolean(
message.messageId?.endsWith(':failure') &&
directConversationTurnId(message.messageId),
);
const isFailureItem = (item: TurnStreamItem) =>
item.id === `text:${turn.turnId}:failure`;
const failed =
assistants.some(isFailureMessage) || turn.items.some(isFailureItem);
const lastText =
!turn.active && !failed
? turn.items
.filter((item) => item.kind === 'text' && item.text.trim())
.at(-1)
: undefined;
const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined;
return {
processItems: turn.items.filter(
(item) => item !== lastText && !isFailureItem(item),
),
finalItems: turn.items.filter(
(item) => item === lastText || isFailureItem(item),
),
processMessages: assistants.filter(
(message) => message !== lastMessage && !isFailureMessage(message),
),
finalMessages: assistants.filter(
(message) => message === lastMessage || isFailureMessage(message),
),
};
}
/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */
@@ -207,16 +246,18 @@ export function buildDirectTurnPresentations({
const starts = [
...row.messages
.filter((message) => message.role === 'user')
.map((message) => milliseconds(message.updatedAt)),
...row.items.map((item) => milliseconds(item.at)),
...row.calls.map((call) => milliseconds(call.startedAt)),
.map((message) => directMessageTimestamp(message.updatedAt)),
...row.items.map((item) => directMessageTimestamp(item.at)),
...row.calls.map((call) => directMessageTimestamp(call.startedAt)),
].filter((at) => at > 0);
row.startedAt = starts.length ? Math.min(...starts) : 0;
row.endedAt = Math.max(
0,
...row.messages.map((message) => milliseconds(message.updatedAt)),
...row.items.map((item) => milliseconds(item.updatedAt)),
...row.calls.map((call) => milliseconds(call.updatedAt)),
...row.messages.map((message) =>
directMessageTimestamp(message.updatedAt),
),
...row.items.map((item) => directMessageTimestamp(item.updatedAt)),
...row.calls.map((call) => directMessageTimestamp(call.updatedAt)),
);
}
return [...rows.values()].filter(
@@ -275,6 +275,7 @@ export function formatClockTime(timestamp: number | null | undefined) {
return null;
}
const date = new Date(timestamp);
if (!Number.isFinite(date.getTime())) return null;
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
+28
View File
@@ -12148,6 +12148,34 @@ button.design-workspace-tree__entry:hover,
margin-top: 14px;
}
/* 发送时间与完成后的过程区沿用对话面板的次要信息样式。 */
.message-sent-at {
display: block;
margin-top: 4px;
color: var(--platform-text-soft);
font-size: 11px;
text-align: right;
font-variant-numeric: tabular-nums;
}
.message-turn-process {
min-width: 0;
}
.message-turn-process > summary {
padding: 6px 0;
color: var(--platform-text-soft);
font-size: 12px;
cursor: pointer;
}
.message-turn-process-body {
display: grid;
gap: 12px;
min-width: 0;
padding-top: 8px;
}
/* 整轮会话的结束时间与耗时:比消息本身更轻,属于轮次级信息。 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
directThreadHistoryItemsToMessages,
isDirectTurnInProgress,
} from '../src/features/project-workspace/directThreadEvents';
describe('Direct 回合状态与历史时间', () => {
it('终态和空状态不恢复为活动回合', () => {
for (const status of [
'completed',
'failed',
'interrupted',
null,
undefined,
]) {
expect(isDirectTurnInProgress(status)).toBe(false);
}
for (const status of ['accepted', 'running', 'streaming', 'finalizing']) {
expect(isDirectTurnInProgress(status)).toBe(true);
}
});
it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => {
const items = [
{
type: 'message',
role: 'user',
id: 'direct-codex:turn:user',
content: [{ type: 'input_text', text: '帮我修改游戏' }],
},
];
const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 };
expect(
directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt,
).toBe(1_800_000_000_001);
expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0);
expect(items[0]).not.toHaveProperty('recordedAt');
});
});
@@ -5,7 +5,11 @@ import type {
GameCreatorDirectToolCall,
TurnStreamItem,
} from '../src/app/types';
import { buildDirectTurnPresentations } from '../src/features/project-workspace/directTurnPresentation';
import {
buildDirectTurnPresentations,
directMessageTimestamp,
splitDirectTurnContent,
} from '../src/features/project-workspace/directTurnPresentation';
const user = (turn: string): ChatMessage => ({
role: 'user',
@@ -158,6 +162,56 @@ describe('DirectProject 回合唯一呈现', () => {
const rows = build([user('one')], [last, marker, first, first]);
expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]);
});
it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => {
const marker: TurnStreamItem = {
...text('one', 'unused', 2),
kind: 'tool',
id: 'tool:one:call',
callId: 'call',
};
const row = build(
[user('one')],
[text('one', 'start'), marker, text('one', 'final', 3)],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:start',
'tool:one:call',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']);
const active = splitDirectTurnContent({ ...row, active: true });
expect(active.processItems).toHaveLength(3);
expect(active.finalItems).toEqual([]);
});
it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => {
const row = build(
[user('one'), assistant('direct-codex:one:failure', '失败')],
[text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processItems.map((item) => item.id)).toEqual([
'text:one:progress',
]);
expect(parts.finalItems.map((item) => item.id)).toEqual([
'text:one:failure',
]);
});
it('无流历史只保留最后一条回复,发送时间不从工具推断', () => {
const row = build(
[user('one'), assistant('progress'), assistant('final')],
[],
)[0];
const parts = splitDirectTurnContent(row);
expect(parts.processMessages.map((message) => message.messageId)).toEqual([
'progress',
]);
expect(parts.finalMessages.map((message) => message.messageId)).toEqual([
'final',
]);
expect(directMessageTimestamp(undefined)).toBe(0);
expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0);
expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001);
});
it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => {
expect(
build([user('one')], [], {
@@ -2,6 +2,8 @@
对应:[对话回合唯一投影](./【里程碑】对话回合唯一投影-2026-09-16.md)。
本次增量顺序:先移除 Provider 回放对活动 client 回合的写入,复用活动快照同步并处理过期异步结果;再为历史信封及切片增加可选时间映射;最后在现有回合投影中分离最终回复和可折叠过程,复用 details 与工具组件。新增恢复、时间、折叠边界用例,只运行静态检查,不运行测试;按最新授权本地提交,不推送。
1. 前端抽取纯回合呈现投影,完整历史关联后分页;删除旧的消息锚定/实时/未归属独立渲染分支。
2. Rust 按 item 完成边界冲刷,段切换返回全部快照;收尾等待写任务,修正 upsert 与跨回合裁剪。
3. 核对 MCP 输入输出与同状态更新,保留现有脱敏。
@@ -1,13 +1,13 @@
# 对话回合唯一投影
- Version: 1
- Version: 2
- Status: implemented-awaiting-runtime-acceptance
- Date: 2026-09-16
- Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
## 范围与评审
单里程碑修复回合展示和流写入的一致性;不改工具执行、鉴权或原始历史格式。结构评审确认:唯一 turn owner、item 顺序、历史身份与分页、失败保留、工具详情更新均有明确归属;无须新增数据库或迁移历史。没有身份的旧记录不得做位置猜配。
单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。
## 验收
@@ -15,6 +15,8 @@
2. item 增量、完成、持久化和回读保持相同身份与固定顺序。
3. 工具输入输出保留,重复快照不重复渲染。
4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。
5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。
6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。
依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。
@@ -24,3 +26,4 @@
- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。
- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。
- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。
- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。
@@ -52,6 +52,7 @@ SpacetimeDB crate、SDK、CLI / standalone 与生成 bindings 按 `2.8.3` 对齐
## AGC DirectProject 与 UI workflow
- DirectProject 对话先在完整历史中按回合/原始 item 身份关联,再分页渲染;每个回合只有一个呈现入口。有流按 item `seq` 交替文本和工具,无流采用历史正文;禁止位置猜配或同时展示累计回复与 item 正文。流写入单调归并,收尾等待落盘任务,不按磁盘“最后一段”猜最终回复位置。详见 AGC 实施计划的“DirectProject 回合展示唯一归属”。
- 回合生命周期只由活动 client 回合快照和 Direct 事件恢复;Provider 的历史终态通知不能创建活动 client 回合。消息发送时间保存在历史信封,原始 item 不混入宿主字段;完成后的中间文本和工具默认收进“执行过程”,最终回复及失败提示保持可见。
- AGC 安装产品名统一为“陶泥儿”,由 Tauri `productName` 控制安装项、快捷方式与 EXE 产品描述;Windows 内置 Codex 安装到顶层 `coding-agent/win-x64/`,打包资源映射与运行时查找路径必须一致。内部可执行文件名与应用 identifier 保持稳定。
@@ -1416,8 +1416,15 @@ DirectProject、Agent Runtime、Provider、app-server、内置 MCP、命令执
## 2026-09-15 Direct 回合跨页面生命周期与运行中项目可见性
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,前端先读取同一份只读活动回合快照,再通过 Thread Manager 订阅 bootstrap 和后续事件恢复忙碌态、进度与未完成回复。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
Direct 回合的所有权属于进程内项目身份锁,不属于当前页面。离开工作台或切换到首页时,正在运行的回合继续执行;重新进入项目时,只读活动回合快照负责恢复 clientTurnId 与运行状态,Direct 回合事件负责更新进度。Thread Manager 的 Provider turnId 与事件序列不得当成 clientTurnId 或展示事件序列;其通知只触发快照和历史同步,回放的终态不能重新创建活动回合或“正在提交回复”。定期复核同一份活动快照以弥补页面切换时丢失的结束事件;失败读取保留已知状态,过期读取不能覆盖新回合。活动回合结束后移除快照并解除发送阻断;没有活动回合的项目保持原有发送行为。
壳层左上角的“正在运行”面板只呈现活动 Direct 回合快照,按开始时间排序,显示项目名、状态、活动时长并允许进入对应项目。快照读取失败只显示读取失败并保留上一份结果,不得改写成权限、审批或业务失败;面板不建立第二份运行真相。应用重启后的恢复、取消入口和非 Direct Agent 项目不在本合同内。
活动回合快照命令是进程内 Tauri 只读命令,不进入公共 API 或持久化协议;字段包含 `projectPath / projectName / turnId / status / activity / startedAt / updatedAt / sequence`,状态和序号与既有 Direct 回合进度事件一致。
### 消息时间与完成回合的过程折叠
- 用户消息显示发送时刻,精确到秒。新历史信封记录接收时刻 `recordedAt`Unix 毫秒),原始 Codex item 不增加宿主字段;历史切片通过独立 `itemTimestamps` 映射返回。幂等重复写不刷新时刻,缺时间的旧记录保持未知,不使用打开页面时刻或工具开始时刻补造。
- 运行中的正文和工具按原有唯一回合流实时显示;完成后,除最终回复和失败提示外,中间文本与所有工具调用统一放入默认收起的“执行过程”,允许手动展开,刷新或重新进入仍默认收起。
- 最终回复沿用 Runtime 的最后一个 assistant item 合同,不按文本长度或相似度判断。失败回合不把最后一句过程输出伪装成最终回复。无流历史按同一用户消息边界划分,只保留最后一条 assistant 回复在外;用户消息与失败提示始终保留。
- 验收覆盖已完成回合重进、真实活动回合恢复、跨项目迟到快照、运行到完成自动收起、历史无流、失败、发送时间刷新和旧记录时间缺失。不改变实际工具执行、鉴权、数据库或用户项目内容。
@@ -58,6 +58,7 @@ toolCalls?: DirectTurnToolCall[] | null;
### 4. 前端合并与渲染(回合唯一归属,连续工具成块)
- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。
- 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。
- 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”。
- 块 DOM 与交互(对齐 Codex):