修复DirectProject对话历史分页卡页
聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口 工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出 隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重 补充原生和真实App回归测试,增加人工只读日志重放入口 更新B05状态、规范与PR验收记录,不提交用户原始日志
This commit is contained in:
@@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
read_direct_project_history_entries_filtered_at(root, None, false)
|
||||
}
|
||||
|
||||
fn is_direct_project_chat_message(item: &Value) -> bool {
|
||||
matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("user" | "assistant")
|
||||
) && item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
|
||||
fn read_direct_project_history_entries_filtered_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
messages_only: bool,
|
||||
) -> 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());
|
||||
return if before_item_id.is_some() {
|
||||
Err("DirectProject 历史游标对应的文件已不存在".to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
@@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
|
||||
return Ok(items);
|
||||
}
|
||||
if messages_only && !is_direct_project_chat_message(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
match before_item_id {
|
||||
Some(item_id) => Err(format!("DirectProject 历史中不存在 item:{item_id}")),
|
||||
None => Ok(items),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
|
||||
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
|
||||
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
|
||||
while start > 0
|
||||
&& entries[start]
|
||||
.0
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let timestamps = entries[start..]
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.map(|(item, _)| item)
|
||||
.collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
@@ -640,6 +713,165 @@ 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":"已完成"}]}}"#;
|
||||
|
||||
fn write_items(root: &std::path::Path, items: &[Value]) {
|
||||
let lines = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
|
||||
let root = init_history_project("message-pages");
|
||||
let mut raw = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
for n in 0..44 {
|
||||
let item = json!({
|
||||
"id": format!("message-{n}"), "type": "message",
|
||||
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
|
||||
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
|
||||
});
|
||||
expected.push(item.clone());
|
||||
raw.push(item);
|
||||
for tool in 0..25 {
|
||||
raw.push(json!({
|
||||
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
|
||||
"output": "工具结果不应占聊天页名额",
|
||||
}));
|
||||
}
|
||||
}
|
||||
write_items(root.path(), &raw);
|
||||
let path = history_path(root.path());
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let (old_page, _, _) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(old_page
|
||||
.iter()
|
||||
.all(|item| item["type"] == "function_call_output"));
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut sizes = Vec::new();
|
||||
loop {
|
||||
let (mut page, more, timestamps) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.unwrap();
|
||||
sizes.push(page.len());
|
||||
for item in &page {
|
||||
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
|
||||
assert_eq!(
|
||||
timestamps[item["id"].as_str().unwrap()],
|
||||
1000 + index as u64
|
||||
);
|
||||
}
|
||||
let next = page
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
page.append(&mut all);
|
||||
all = page;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert_ne!(next, cursor);
|
||||
cursor = next;
|
||||
assert!(sizes.len() < 10);
|
||||
}
|
||||
assert_eq!(sizes, vec![20, 20, 4]);
|
||||
assert_eq!(all, expected);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
|
||||
let root = init_history_project("message-page-boundary");
|
||||
write_items(
|
||||
root.path(),
|
||||
&[
|
||||
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
|
||||
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
|
||||
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
|
||||
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
|
||||
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
|
||||
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
|
||||
],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
|
||||
assert_eq!(page[0]["id"], "a");
|
||||
assert!(more);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[0]["id"], "u");
|
||||
assert!(page[1].get("id").is_none());
|
||||
assert!(!more);
|
||||
assert!(
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
|
||||
.is_err()
|
||||
);
|
||||
write_items(
|
||||
root.path(),
|
||||
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(page.is_empty());
|
||||
assert!(!more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
|
||||
fn replay_external_chat_history_pages_without_mutating_source() {
|
||||
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
|
||||
let before = std::fs::read(&source).expect("read source");
|
||||
let root = init_history_project("external-history-replay");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, &before).unwrap();
|
||||
let expected =
|
||||
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
|
||||
.expect("read messages");
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut pages = 0;
|
||||
loop {
|
||||
let (mut items, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.expect("read page");
|
||||
let next = items
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
items.append(&mut all);
|
||||
all = items;
|
||||
pages += 1;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert!(next.is_some() && next != cursor, "cursor must advance");
|
||||
assert!(pages <= expected.len() + 1, "pagination must terminate");
|
||||
cursor = next;
|
||||
}
|
||||
assert!(
|
||||
all.iter().eq(expected.iter().map(|(item, _)| item)),
|
||||
"message order and content must match"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read(&source).unwrap() == before,
|
||||
"source must remain unchanged"
|
||||
);
|
||||
eprintln!(
|
||||
"history replay: messages={}, pages={pages}, users={}",
|
||||
all.len(),
|
||||
all.iter().filter(|item| item["role"] == "user").count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
pub(crate) oldest_item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -5404,19 +5404,29 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
project_path: String,
|
||||
before_item_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
messages_only: Option<bool>,
|
||||
) -> Result<DirectThreadHistorySlice, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
let read_slice = if messages_only.unwrap_or(false) {
|
||||
read_direct_project_chat_items_slice_at
|
||||
} else {
|
||||
read_direct_project_history_items_slice_at
|
||||
};
|
||||
let (items, has_more, item_timestamps) =
|
||||
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
|
||||
let oldest_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
oldest_item_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
|
||||
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
||||
import {
|
||||
type DirectThreadConsumeResult,
|
||||
directThreadHistoryItemsToMessages,
|
||||
directThreadHistoryPage,
|
||||
type DirectThreadHistorySlice,
|
||||
type DirectThreadSubscriptionBootstrap,
|
||||
isDirectTurnInProgress,
|
||||
prependDirectHistoryMessages,
|
||||
} from './features/project-workspace/directThreadEvents';
|
||||
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
|
||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||
@@ -1958,7 +1959,7 @@ export function App({
|
||||
);
|
||||
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
||||
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
||||
const directHistoryLoadingRef = useRef(false);
|
||||
const directHistoryLoadingRef = useRef<number | null>(null);
|
||||
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
||||
null,
|
||||
);
|
||||
@@ -2151,6 +2152,7 @@ export function App({
|
||||
|
||||
function resetProjectSupervisorState() {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
directHistoryLoadingRef.current = null;
|
||||
projectSupervisorRuntimeResumeProjectPathRef.current = null;
|
||||
projectSupervisorSessionIdRef.current = null;
|
||||
projectSupervisorRuntimeRef.current = null;
|
||||
@@ -4009,6 +4011,8 @@ export function App({
|
||||
}
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
|
||||
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
|
||||
if (directCodexProductRuntime)
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
try {
|
||||
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
|
||||
// V2 authority first; a missing V2 session returns null and preserves the
|
||||
@@ -4105,6 +4109,7 @@ export function App({
|
||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||
let runtimeError = '';
|
||||
let loadedDirectHistoryHasMore = false;
|
||||
let loadedDirectHistoryCursor: string | null = null;
|
||||
const projectConversation = directCodexProductRuntime
|
||||
? (() => {
|
||||
return invoke<DirectThreadHistorySlice>(
|
||||
@@ -4112,16 +4117,16 @@ export function App({
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
messagesOnly: true,
|
||||
},
|
||||
).then((slice) => {
|
||||
loadedDirectHistoryHasMore = slice.hasMore;
|
||||
const page = directThreadHistoryPage(slice);
|
||||
loadedDirectHistoryHasMore = page.hasMore;
|
||||
loadedDirectHistoryCursor = page.cursor;
|
||||
return {
|
||||
path: nextProjectPath,
|
||||
agentId: null,
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
messages: page.messages,
|
||||
} satisfies LocalConversationResult;
|
||||
});
|
||||
})()
|
||||
@@ -4218,9 +4223,7 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||
if (directCodexProductRuntime) {
|
||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
conversationMessages.find((message) => message.messageId)
|
||||
?.messageId ?? null;
|
||||
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
|
||||
}
|
||||
setMessages((current) => {
|
||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||
@@ -4281,6 +4284,10 @@ export function App({
|
||||
: workspaceStatus,
|
||||
);
|
||||
// Keep the default greeting when history is missing or blocked.
|
||||
} finally {
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4396,6 +4403,9 @@ export function App({
|
||||
setAgentRunHistoryFiles([]);
|
||||
setAgentRuntimeById({});
|
||||
setMessages(conversationMessages);
|
||||
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
|
||||
directHistoryOldestItemIdRef.current = null;
|
||||
setDirectHistoryHasMore(false);
|
||||
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||
savedConversationProjectPathRef.current = openedProject.projectPath;
|
||||
savedConversationCountRef.current = conversationMessages.length;
|
||||
@@ -12480,27 +12490,38 @@ export function App({
|
||||
: null;
|
||||
|
||||
async function showEarlierConversationMessages() {
|
||||
if (hiddenConversationCount > 0) {
|
||||
setConversationVisibleCount((current) =>
|
||||
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (directCodexProductRuntime && directHistoryHasMore) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const projectPath = localProject?.projectPath;
|
||||
if (invoke && projectPath && !directHistoryLoadingRef.current) {
|
||||
directHistoryLoadingRef.current = true;
|
||||
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
|
||||
const beforeItemId = directHistoryOldestItemIdRef.current;
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
const isCurrentLoad = () =>
|
||||
manifestRefreshMountedRef.current &&
|
||||
localProjectPathRef.current === projectPath &&
|
||||
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
|
||||
try {
|
||||
const slice = await invoke<DirectThreadHistorySlice>(
|
||||
'read_direct_project_history_slice',
|
||||
{
|
||||
projectPath,
|
||||
beforeItemId: directHistoryOldestItemIdRef.current,
|
||||
beforeItemId,
|
||||
limit: CONVERSATION_VISIBLE_STEP,
|
||||
messagesOnly: true,
|
||||
},
|
||||
);
|
||||
if (localProjectPathRef.current !== projectPath) {
|
||||
if (!isCurrentLoad()) {
|
||||
return;
|
||||
}
|
||||
const older = directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
).map((message) => ({
|
||||
const page = directThreadHistoryPage(slice, beforeItemId);
|
||||
const older = page.messages.map((message) => ({
|
||||
role:
|
||||
message.role === 'user'
|
||||
? ('user' as const)
|
||||
@@ -12510,18 +12531,21 @@ export function App({
|
||||
messageId: message.messageId,
|
||||
updatedAt: message.updatedAt,
|
||||
}));
|
||||
setMessages((current) => [...older, ...current]);
|
||||
setMessages((current) =>
|
||||
prependDirectHistoryMessages(current, older),
|
||||
);
|
||||
setConversationVisibleCount((current) => current + older.length);
|
||||
setDirectHistoryHasMore(slice.hasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
older.find((message) => message.messageId)?.messageId ??
|
||||
directHistoryOldestItemIdRef.current;
|
||||
setDirectHistoryHasMore(page.hasMore);
|
||||
directHistoryOldestItemIdRef.current = page.cursor;
|
||||
} catch (error) {
|
||||
if (!isCurrentLoad()) return;
|
||||
setWorkspaceStatus(
|
||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
directHistoryLoadingRef.current = false;
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { LocalConversationMessageRecord } from '../../app/types';
|
||||
import type {
|
||||
ChatMessage,
|
||||
LocalConversationMessageRecord,
|
||||
} from '../../app/types';
|
||||
|
||||
export type DirectThreadRawEvent = {
|
||||
seq: number;
|
||||
@@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = {
|
||||
items: unknown[];
|
||||
hasMore: boolean;
|
||||
itemTimestamps?: Record<string, number>;
|
||||
oldestItemId?: string | null;
|
||||
};
|
||||
|
||||
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
|
||||
export function directThreadHistoryPage(
|
||||
slice: DirectThreadHistorySlice,
|
||||
previousCursor: string | null = null,
|
||||
) {
|
||||
const first = slice.items[0];
|
||||
const firstId =
|
||||
first && typeof first === 'object' && 'id' in first
|
||||
? (first as { id?: unknown }).id
|
||||
: null;
|
||||
const cursor =
|
||||
slice.oldestItemId ??
|
||||
(typeof firstId === 'string' && firstId ? firstId : null);
|
||||
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
|
||||
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
|
||||
}
|
||||
return {
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
hasMore: slice.hasMore,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
|
||||
export function prependDirectHistoryMessages(
|
||||
current: readonly ChatMessage[],
|
||||
older: readonly ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
const ids = new Set(
|
||||
current.flatMap((message) =>
|
||||
message.messageId ? [message.messageId] : [],
|
||||
),
|
||||
);
|
||||
const additions = older.filter((message) => {
|
||||
if (!message.messageId) return true;
|
||||
if (ids.has(message.messageId)) return false;
|
||||
ids.add(message.messageId);
|
||||
return true;
|
||||
});
|
||||
return [...additions, ...current];
|
||||
}
|
||||
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: unknown[],
|
||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||
|
||||
@@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() {
|
||||
};
|
||||
}
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args).toEqual({ projectPath, limit: 20 });
|
||||
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||
return { items: [...persistedMessages], hasMore: false };
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
@@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args).toEqual({ projectPath, limit: 20 });
|
||||
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||
return { items: [...persistedMessages], hasMore: false };
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
|
||||
@@ -9133,6 +9133,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||
projectPath,
|
||||
limit: 20,
|
||||
messagesOnly: true,
|
||||
}),
|
||||
);
|
||||
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents';
|
||||
import {
|
||||
act,
|
||||
App,
|
||||
createGameCreationAppManifest,
|
||||
fireEvent,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
setComposerText,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const projectPath = '/tmp/direct-message-pages';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'direct-message-pages',
|
||||
'历史分页',
|
||||
);
|
||||
const messages = Array.from({ length: 26 }, (_, index) => ({
|
||||
id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`,
|
||||
type: 'message',
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
content: [
|
||||
{
|
||||
type: index % 2 ? 'output_text' : 'input_text',
|
||||
text: `历史正文 ${index}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
function page(
|
||||
items: typeof messages,
|
||||
hasMore: boolean,
|
||||
): DirectThreadHistorySlice {
|
||||
return { items, hasMore, oldestItemId: items[0]?.id ?? null };
|
||||
}
|
||||
function deferred() {
|
||||
let resolve!: (value: DirectThreadHistorySlice) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<DirectThreadHistorySlice>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function install(
|
||||
read: (
|
||||
args: Record<string, unknown>,
|
||||
) => DirectThreadHistorySlice | Promise<DirectThreadHistorySlice>,
|
||||
) {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args?.messagesOnly).toBe(true);
|
||||
return read(args!);
|
||||
}
|
||||
if (command === 'read_project_permission_policy')
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
if (command === 'get_local_game_manifest') return manifest;
|
||||
if (command === 'read_game_creator_app_config')
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: 'quality',
|
||||
selectedModelIsDefault: true,
|
||||
llm: { customEnabled: false },
|
||||
},
|
||||
};
|
||||
if (
|
||||
command === 'read_direct_tool_calls' ||
|
||||
command === 'read_direct_turn_stream' ||
|
||||
command === 'list_game_creator_direct_active_turns'
|
||||
)
|
||||
return [];
|
||||
return null;
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
||||
return invoke;
|
||||
}
|
||||
function mount(path = projectPath) {
|
||||
return render(
|
||||
<App
|
||||
initialProjectPath={path}
|
||||
initialProjectManifest={manifest}
|
||||
projectSupervisorOnly
|
||||
orchestrationMode="single-supervisor"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
const earlier = () => screen.getByRole('button', { name: /显示更早/ });
|
||||
afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
describe('Direct 聊天历史分页集成', () => {
|
||||
it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => {
|
||||
const invoke = install((args) =>
|
||||
args.beforeItemId
|
||||
? page(messages.slice(0, 6), false)
|
||||
: page(messages.slice(6), true),
|
||||
);
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
expect(screen.queryByText('历史正文 0')).toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
await screen.findByText('历史正文 0');
|
||||
expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||
projectPath,
|
||||
beforeItemId: messages[6]!.id,
|
||||
limit: 20,
|
||||
messagesOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => {
|
||||
const pending = deferred();
|
||||
let attempts = 0;
|
||||
const invoke = install((args) => {
|
||||
if (!args.beforeItemId) return page(messages.slice(6), true);
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? pending.promise
|
||||
: page(messages.slice(0, 8), false);
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
fireEvent.click(earlier());
|
||||
expect(attempts).toBe(1);
|
||||
await act(async () => pending.reject(new Error('模拟读取失败')));
|
||||
expect(screen.getByText('历史正文 25')).not.toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
await screen.findByText('历史正文 0');
|
||||
expect(screen.getAllByText('历史正文 6')).toHaveLength(1);
|
||||
expect(screen.getAllByText('历史正文 7')).toHaveLength(1);
|
||||
const loads = invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'read_direct_project_history_slice' && args?.beforeItemId,
|
||||
);
|
||||
expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([
|
||||
messages[6]!.id,
|
||||
messages[6]!.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => {
|
||||
const old = deferred();
|
||||
const fresh = deferred();
|
||||
let fullLoads = 0;
|
||||
let olderLoads = 0;
|
||||
const recent = messages.slice(6).map((item) => ({
|
||||
...item,
|
||||
content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }],
|
||||
}));
|
||||
install((args) => {
|
||||
if (!args.beforeItemId) {
|
||||
fullLoads += 1;
|
||||
return page(fullLoads === 1 ? messages.slice(6) : recent, true);
|
||||
}
|
||||
olderLoads += 1;
|
||||
return olderLoads === 1 ? old.promise : fresh.promise;
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await screen.findByText('重读 历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
await act(async () =>
|
||||
old.resolve(
|
||||
page(
|
||||
[
|
||||
{
|
||||
...messages[0]!,
|
||||
id: 'stale',
|
||||
content: [{ type: 'input_text', text: '过期消息' }],
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText('过期消息')).toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
expect(olderLoads).toBe(2);
|
||||
await act(async () => fresh.resolve(page(messages.slice(0, 6), false)));
|
||||
await screen.findByText('历史正文 0');
|
||||
});
|
||||
|
||||
it('离开项目并重进后,旧请求不能污染新实例', async () => {
|
||||
const old = deferred();
|
||||
const invoke = install((args) =>
|
||||
args.beforeItemId ? old.promise : page(messages.slice(6), true),
|
||||
);
|
||||
const first = mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
first.unmount();
|
||||
const other = mount('/tmp/another-project');
|
||||
await screen.findByText('历史正文 25');
|
||||
other.unmount();
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
await act(async () =>
|
||||
old.resolve(
|
||||
page(
|
||||
[
|
||||
{
|
||||
...messages[0]!,
|
||||
id: 'stale',
|
||||
content: [{ type: 'input_text', text: '旧项目迟到消息' }],
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText('旧项目迟到消息')).toBeNull();
|
||||
expect(earlier()).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_direct_project_history_slice',
|
||||
),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
directThreadHistoryItemsToMessages,
|
||||
directThreadHistoryPage,
|
||||
isDirectTurnInProgress,
|
||||
prependDirectHistoryMessages,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
|
||||
describe('Direct 回合状态与历史时间', () => {
|
||||
it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => {
|
||||
const page = directThreadHistoryPage(
|
||||
{
|
||||
items: [{ id: 'tool-older', type: 'function_call_output' }],
|
||||
hasMore: true,
|
||||
oldestItemId: 'tool-older',
|
||||
},
|
||||
'message-newer',
|
||||
);
|
||||
expect(page.messages).toEqual([]);
|
||||
expect(page.cursor).toBe('tool-older');
|
||||
});
|
||||
it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => {
|
||||
expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({
|
||||
messages: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow(
|
||||
'游标未前进',
|
||||
);
|
||||
expect(() =>
|
||||
directThreadHistoryPage(
|
||||
{
|
||||
items: [{ id: 'same' }],
|
||||
hasMore: true,
|
||||
oldestItemId: 'same',
|
||||
},
|
||||
'same',
|
||||
),
|
||||
).toThrow('游标未前进');
|
||||
});
|
||||
it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => {
|
||||
const current = [
|
||||
{ role: 'assistant' as const, text: '完整正文', messageId: 'a' },
|
||||
];
|
||||
const old = { role: 'user' as const, text: '用户输入', messageId: 'u' };
|
||||
expect(
|
||||
prependDirectHistoryMessages(current, [
|
||||
old,
|
||||
old,
|
||||
{ role: 'assistant', text: '旧快照', messageId: 'a' },
|
||||
{ role: 'assistant', text: '无身份旧消息' },
|
||||
]),
|
||||
).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]);
|
||||
});
|
||||
|
||||
it('终态和空状态不恢复为活动回合', () => {
|
||||
for (const status of [
|
||||
'completed',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# AGC 对话历史分页恢复实施计划
|
||||
|
||||
- Date: 2026-09-16
|
||||
- Status: awaiting-runtime-acceptance
|
||||
- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md)
|
||||
|
||||
## 实施
|
||||
|
||||
1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。
|
||||
2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。
|
||||
3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。
|
||||
4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。
|
||||
|
||||
## 边界与停止条件
|
||||
|
||||
不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。
|
||||
|
||||
## 验收证据
|
||||
|
||||
- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。
|
||||
- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。
|
||||
- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。
|
||||
- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。
|
||||
- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。
|
||||
- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。
|
||||
@@ -31,7 +31,7 @@
|
||||
| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 |
|
||||
| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 |
|
||||
| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 |
|
||||
| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 |
|
||||
| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 |
|
||||
| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 |
|
||||
| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 |
|
||||
|
||||
@@ -40,4 +40,4 @@
|
||||
- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。
|
||||
- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。
|
||||
- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。
|
||||
- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。
|
||||
- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页与 JSON 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# AGC 对话历史分页恢复
|
||||
|
||||
- Version: 1
|
||||
- Status: reviewed
|
||||
- Date: 2026-09-16
|
||||
- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
|
||||
|
||||
## 范围与评审
|
||||
|
||||
用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。
|
||||
|
||||
采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。
|
||||
2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。
|
||||
3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。
|
||||
4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。
|
||||
@@ -1,5 +1,9 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## DirectProject 历史不能按工具条目切页再按消息推进游标
|
||||
|
||||
原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。
|
||||
|
||||
## JSON 卡片显示与 UI 编辑能力必须同源
|
||||
|
||||
JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
|
||||
## 2026-09-16 DirectProject 回合展示唯一归属
|
||||
|
||||
- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。
|
||||
- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。
|
||||
- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。
|
||||
- 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。
|
||||
- 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。
|
||||
- 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。
|
||||
|
||||
Reference in New Issue
Block a user