把 DirectProject 聊天条目的投影从前端收口,Rust 只搬运脱敏原始条目
新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要 删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产 历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output 新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成 directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则 DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId
This commit is contained in:
@@ -14,7 +14,6 @@ mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
mod direct_chat_entry;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_user_item;
|
||||
@@ -22,6 +21,7 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_raw_item;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
@@ -49,7 +49,6 @@ pub(crate) use codex_cli::{
|
||||
};
|
||||
pub(crate) use codex_provider_proxy::*;
|
||||
pub(crate) use design_runtime::*;
|
||||
pub(crate) use direct_chat_entry::*;
|
||||
pub(crate) use direct_codex_attachments::*;
|
||||
pub(crate) use direct_codex_audit::*;
|
||||
pub(crate) use direct_codex_user_item::*;
|
||||
@@ -57,6 +56,7 @@ pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_thread_raw_item::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
|
||||
@@ -744,16 +744,18 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
|
||||
/// Project an app-server item into the small public payload carried by the
|
||||
/// DirectProject event queue. Full item contents are persisted in JSONL and
|
||||
/// must not be forwarded through the runtime event stream.
|
||||
/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。
|
||||
fn direct_chat_entry_payload(
|
||||
/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。
|
||||
///
|
||||
/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。
|
||||
fn direct_thread_raw_item_payload(
|
||||
root: &std::path::Path,
|
||||
item: &serde_json::Value,
|
||||
turn_id: Option<&str>,
|
||||
completed: bool,
|
||||
now_ms: u64,
|
||||
) -> serde_json::Value {
|
||||
direct_chat_entry_from_item(root, item, turn_id, completed, now_ms)
|
||||
.and_then(|entry| serde_json::to_value(entry).ok())
|
||||
direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms)
|
||||
.and_then(|raw| serde_json::to_value(raw).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
}
|
||||
|
||||
@@ -3062,7 +3064,7 @@ impl CodexAppServerConnection {
|
||||
"rawResponseItem/completed 缺少 item".to_string(),
|
||||
));
|
||||
}
|
||||
let entry_payload = direct_chat_entry_payload(
|
||||
let entry_payload = direct_thread_raw_item_payload(
|
||||
history_root,
|
||||
&item,
|
||||
Some(turn_id.as_str()),
|
||||
@@ -3232,7 +3234,7 @@ impl CodexAppServerConnection {
|
||||
turn_id: turn_id.clone(),
|
||||
item_id,
|
||||
call_id: None,
|
||||
payload: direct_chat_entry_payload(
|
||||
payload: direct_thread_raw_item_payload(
|
||||
history_root,
|
||||
item,
|
||||
Some(turn_id.as_str()),
|
||||
@@ -4549,8 +4551,8 @@ mod tests {
|
||||
"result": { "content": "large output" }
|
||||
});
|
||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
||||
// 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。
|
||||
let payload = direct_chat_entry_payload(
|
||||
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
|
||||
let payload = direct_thread_raw_item_payload(
|
||||
std::path::Path::new("."),
|
||||
&item,
|
||||
Some("turn-1"),
|
||||
@@ -4558,8 +4560,8 @@ mod tests {
|
||||
1000,
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("kind").and_then(serde_json::Value::as_str),
|
||||
Some("tool")
|
||||
payload.get("itemType").and_then(serde_json::Value::as_str),
|
||||
Some("mcpToolCall")
|
||||
);
|
||||
assert_eq!(
|
||||
payload.get("itemId").and_then(serde_json::Value::as_str),
|
||||
@@ -4569,7 +4571,17 @@ mod tests {
|
||||
payload.get("turnId").and_then(serde_json::Value::as_str),
|
||||
Some("turn-1")
|
||||
);
|
||||
assert!(payload.get("toolCall").is_some(), "{payload}");
|
||||
// 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。
|
||||
assert!(payload.get("toolCall").is_none(), "{payload}");
|
||||
assert!(payload.get("title").is_none(), "{payload}");
|
||||
assert!(payload.get("summary").is_none(), "{payload}");
|
||||
assert!(payload.get("kind").is_none(), "{payload}");
|
||||
// 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。
|
||||
let arguments = payload
|
||||
.get("arguments")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default();
|
||||
assert!(!arguments.contains("\"secret\""), "{payload}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agent::DirectChatEntry;
|
||||
use crate::agent::DirectThreadRawItem;
|
||||
|
||||
const DEFAULT_MAX_EVENTS: usize = 8_192;
|
||||
const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024;
|
||||
@@ -70,11 +70,9 @@ pub(crate) struct DirectThreadConsumeResult {
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
/// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。
|
||||
pub(crate) items: Vec<DirectThreadRawItem>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
/// 与运行态事件同形的聊天条目(顺序即文件顺序)。
|
||||
pub(crate) entries: Vec<DirectChatEntry>,
|
||||
/// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。
|
||||
pub(crate) first_item_id: Option<String>,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,9 @@
|
||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||
|
||||
use crate::agent::redact_secret_tokens;
|
||||
use crate::agent::sanitize_error_context;
|
||||
use super::direct_thread_raw_item::sanitize_detail_text;
|
||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
||||
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||
use crate::redact_absolute_path_tokens;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
||||
root.join(".agent/conversations/tool-calls.jsonl")
|
||||
}
|
||||
|
||||
/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。
|
||||
fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) {
|
||||
let mut index = start;
|
||||
let mut relative = String::new();
|
||||
while index < value.len() {
|
||||
let character = value[index..].chars().next().unwrap_or_default();
|
||||
if matches!(character, '/' | '\\') {
|
||||
if !relative.is_empty() {
|
||||
relative.push('/');
|
||||
}
|
||||
index += character.len_utf8();
|
||||
continue;
|
||||
}
|
||||
if character.is_whitespace()
|
||||
|| matches!(
|
||||
character,
|
||||
'\'' | '"'
|
||||
| '`'
|
||||
| ','
|
||||
| ';'
|
||||
| '|'
|
||||
| '&'
|
||||
| '('
|
||||
| ')'
|
||||
| '['
|
||||
| ']'
|
||||
| '{'
|
||||
| '}'
|
||||
| '<'
|
||||
| '>'
|
||||
| ':'
|
||||
)
|
||||
{
|
||||
break;
|
||||
}
|
||||
relative.push(character);
|
||||
index += character.len_utf8();
|
||||
}
|
||||
while relative.ends_with('/') {
|
||||
relative.pop();
|
||||
}
|
||||
(index, relative)
|
||||
}
|
||||
|
||||
/// 把项目根目录前缀换成**项目相对路径**(`<root>/game/src/x.ts` → `game/src/x.ts`)。
|
||||
///
|
||||
/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成
|
||||
/// `<absolute-path>`,之后就再也认不出哪些路径在项目内了。
|
||||
/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。
|
||||
fn relativize_project_root_paths(root: &Path, value: &str) -> String {
|
||||
let root_text = root.to_string_lossy();
|
||||
let root_text = root_text.trim_end_matches(['/', '\\']);
|
||||
if root_text.is_empty() {
|
||||
return value.to_string();
|
||||
}
|
||||
let mut needles = [
|
||||
root_text.to_string(),
|
||||
root_text.replace('\\', "/"),
|
||||
root_text.replace('/', "\\"),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|needle| needle.to_ascii_lowercase())
|
||||
.filter(|needle| !needle.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
needles.sort();
|
||||
needles.dedup();
|
||||
let lower = value.to_ascii_lowercase();
|
||||
|
||||
let mut output = String::with_capacity(value.len());
|
||||
let mut cursor = 0usize;
|
||||
while cursor < value.len() {
|
||||
let mut hit: Option<(usize, usize)> = None;
|
||||
for needle in &needles {
|
||||
let mut search = cursor;
|
||||
while let Some(relative) = lower[search..].find(needle.as_str()) {
|
||||
let start = search + relative;
|
||||
let end = start + needle.len();
|
||||
let left_is_boundary = start == 0
|
||||
|| lower[..start].chars().next_back().is_some_and(|character| {
|
||||
!character.is_alphanumeric() && character != '_' && character != '-'
|
||||
});
|
||||
if left_is_boundary && value[end..].starts_with(['/', '\\']) {
|
||||
if hit.is_none_or(|(best_start, _)| start < best_start) {
|
||||
hit = Some((start, end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
search = end;
|
||||
}
|
||||
}
|
||||
let Some((start, end)) = hit else {
|
||||
break;
|
||||
};
|
||||
output.push_str(&value[cursor..start]);
|
||||
let (consumed, relative) = project_relative_path_segment(value, end);
|
||||
if relative.is_empty() {
|
||||
// 只写了项目根目录本身(没有后续路径段):按占位形状处理。
|
||||
output.push_str("<absolute-path>");
|
||||
} else {
|
||||
output.push_str(&relative);
|
||||
}
|
||||
cursor = consumed;
|
||||
}
|
||||
output.push_str(&value[cursor..]);
|
||||
output
|
||||
}
|
||||
|
||||
/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与
|
||||
/// 错误上下文脱敏。
|
||||
///
|
||||
/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录
|
||||
/// 仍然会留下;这里先归一化路径 token,再处理密钥。
|
||||
///
|
||||
/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context`
|
||||
/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` +
|
||||
/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖
|
||||
/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据;
|
||||
/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed
|
||||
/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。
|
||||
///
|
||||
/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。
|
||||
pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String {
|
||||
let without_project_root = relativize_project_root_paths(root, value);
|
||||
let without_absolute = redact_absolute_path_tokens(&without_project_root);
|
||||
let without_secret = redact_secret_tokens(&without_absolute);
|
||||
sanitize_error_context(&without_secret)
|
||||
}
|
||||
|
||||
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
|
||||
@@ -5383,17 +5383,20 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
let entries = direct_chat_entries_from_history(root, &items);
|
||||
let first_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
let items = direct_thread_raw_items_from_history(root, &items, |item| {
|
||||
item.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|id| item_timestamps.get(id).copied())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
entries,
|
||||
first_item_id,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4101,6 +4101,7 @@ export function App({
|
||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||
let runtimeError = '';
|
||||
let loadedDirectHistoryHasMore = false;
|
||||
let loadedDirectHistoryFirstItemId: string | null = null;
|
||||
const projectConversation = directCodexProductRuntime
|
||||
? (() => {
|
||||
return invoke<DirectThreadHistorySlice>(
|
||||
@@ -4111,13 +4112,11 @@ export function App({
|
||||
},
|
||||
).then((slice) => {
|
||||
loadedDirectHistoryHasMore = slice.hasMore;
|
||||
loadedDirectHistoryFirstItemId = slice.firstItemId;
|
||||
return {
|
||||
path: nextProjectPath,
|
||||
agentId: null,
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
messages: directThreadHistoryItemsToMessages(slice.items),
|
||||
} satisfies LocalConversationResult;
|
||||
});
|
||||
})()
|
||||
@@ -4214,9 +4213,7 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||
if (directCodexProductRuntime) {
|
||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
conversationMessages.find((message) => message.messageId)
|
||||
?.messageId ?? null;
|
||||
directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId;
|
||||
}
|
||||
setMessages((current) => {
|
||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||
@@ -12493,25 +12490,23 @@ export function App({
|
||||
if (localProjectPathRef.current !== projectPath) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
}));
|
||||
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,
|
||||
}),
|
||||
);
|
||||
setMessages((current) => [...older, ...current]);
|
||||
setConversationVisibleCount((current) => current + older.length);
|
||||
setDirectHistoryHasMore(slice.hasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
older.find((message) => message.messageId)?.messageId ??
|
||||
directHistoryOldestItemIdRef.current;
|
||||
slice.firstItemId ?? directHistoryOldestItemIdRef.current;
|
||||
} catch (error) {
|
||||
setWorkspaceStatus(
|
||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
|
||||
@@ -10,6 +10,11 @@ import type {
|
||||
DirectThreadRawEvent,
|
||||
DirectThreadSubscriptionBootstrap,
|
||||
} from './directThreadEvents';
|
||||
import {
|
||||
type DirectThreadRawItem,
|
||||
isDirectThreadRawItem,
|
||||
projectDirectThreadRawItem,
|
||||
} from './directThreadItemProjection';
|
||||
|
||||
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
|
||||
|
||||
@@ -123,17 +128,6 @@ function upsertLiveEntry(
|
||||
return { ...state, live };
|
||||
}
|
||||
|
||||
/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */
|
||||
export function directChatEntryFromEventPayload(
|
||||
payload: unknown,
|
||||
): DirectChatEntry | null {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const entry = payload as DirectChatEntry;
|
||||
if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null;
|
||||
if (!entry.kind) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function reduceDirectThreadEvent(
|
||||
state: DirectThreadChatState,
|
||||
event: DirectThreadRawEvent,
|
||||
@@ -168,7 +162,9 @@ function reduceDirectThreadEvent(
|
||||
}
|
||||
case 'item.started':
|
||||
case 'item.completed': {
|
||||
const entry = directChatEntryFromEventPayload(event.payload);
|
||||
const entry = isDirectThreadRawItem(event.payload)
|
||||
? projectDirectThreadRawItem(event.payload)
|
||||
: null;
|
||||
if (!entry) return state;
|
||||
return upsertLiveEntry(state, {
|
||||
...entry,
|
||||
@@ -228,6 +224,24 @@ export function mergeDirectHistoryEntries(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
|
||||
*
|
||||
* `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output`
|
||||
* 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
|
||||
*/
|
||||
export function mergeDirectHistoryItems(
|
||||
state: DirectThreadChatState,
|
||||
items: readonly DirectThreadRawItem[],
|
||||
): DirectThreadChatState {
|
||||
return mergeDirectHistoryEntries(
|
||||
state,
|
||||
items
|
||||
.map((item) => projectDirectThreadRawItem(item))
|
||||
.filter((entry): entry is DirectChatEntry => Boolean(entry)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
|
||||
export function selectDirectChatEntries(
|
||||
state: DirectThreadChatState,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LocalConversationMessageRecord } from '../../app/types';
|
||||
import type { DirectThreadRawItem } from './directThreadItemProjection';
|
||||
|
||||
export type DirectThreadRawEvent = {
|
||||
seq: number;
|
||||
@@ -19,33 +20,36 @@ export type DirectThreadConsumeResult = {
|
||||
};
|
||||
|
||||
export type DirectThreadHistorySlice = {
|
||||
items: unknown[];
|
||||
/** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */
|
||||
items: DirectThreadRawItem[];
|
||||
hasMore: boolean;
|
||||
itemTimestamps?: Record<string, number>;
|
||||
/** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */
|
||||
firstItemId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 历史条目转聊天消息。
|
||||
*
|
||||
* 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序
|
||||
* 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。
|
||||
*/
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: unknown[],
|
||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||
items: readonly DirectThreadRawItem[],
|
||||
): LocalConversationMessageRecord[] {
|
||||
return items.flatMap((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return [];
|
||||
const item = raw as Record<string, unknown>;
|
||||
return items.flatMap((item) => {
|
||||
if (
|
||||
item.itemType !== 'message' &&
|
||||
item.itemType !== 'agentMessage' &&
|
||||
item.itemType !== 'userMessage'
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const role = item.role;
|
||||
if (role !== 'user' && role !== 'assistant') return [];
|
||||
const messageRole = role as 'user' | 'assistant';
|
||||
const content = Array.isArray(item.content)
|
||||
? item.content
|
||||
.map((part) =>
|
||||
part && typeof part === 'object' && 'text' in part
|
||||
? (part as { text?: unknown }).text
|
||||
: null,
|
||||
)
|
||||
.filter((text): text is string => typeof text === 'string')
|
||||
.join('')
|
||||
: '';
|
||||
const content = typeof item.text === 'string' ? item.text : '';
|
||||
if (!content) return [];
|
||||
const messageId = typeof item.id === 'string' ? item.id : undefined;
|
||||
const messageId = item.itemId;
|
||||
return [
|
||||
{
|
||||
schemaVersion: 'agc-direct-project-context.v1',
|
||||
@@ -53,7 +57,7 @@ export function directThreadHistoryItemsToMessages(
|
||||
content,
|
||||
agentId: null,
|
||||
messageId,
|
||||
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
|
||||
updatedAt: item.at ?? 0,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* DirectProject「原始条目 → 聊天条目」投影。
|
||||
*
|
||||
* Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的
|
||||
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片
|
||||
* 走同一个函数,因此实时与回读不可能出现两套口径。
|
||||
*/
|
||||
|
||||
import type {
|
||||
GameCreatorDirectToolCall,
|
||||
GameCreatorDirectToolCallChange,
|
||||
GameCreatorDirectToolCallKind,
|
||||
GameCreatorDirectToolCallStatus,
|
||||
} from '../../app/types';
|
||||
import type { DirectChatEntry } from './directThreadChat';
|
||||
|
||||
/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */
|
||||
export type DirectThreadRawChange = {
|
||||
path: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */
|
||||
export type DirectThreadRawItem = {
|
||||
itemId: string;
|
||||
callId?: string | null;
|
||||
turnId?: string | null;
|
||||
itemType: string;
|
||||
role?: string | null;
|
||||
text?: string | null;
|
||||
name?: string | null;
|
||||
arguments?: string | null;
|
||||
output?: string | null;
|
||||
command?: string | null;
|
||||
tool?: string | null;
|
||||
changes?: DirectThreadRawChange[] | null;
|
||||
itemStatus?: string | null;
|
||||
exitCode?: number | null;
|
||||
success?: boolean | null;
|
||||
completed: boolean;
|
||||
at?: number | null;
|
||||
};
|
||||
|
||||
const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']);
|
||||
const REASONING_ITEM_TYPES = new Set(['reasoning']);
|
||||
const TOOL_ITEM_TYPES = new Set([
|
||||
'function_call',
|
||||
'function_call_output',
|
||||
'commandExecution',
|
||||
'fileChange',
|
||||
'mcpToolCall',
|
||||
'webSearch',
|
||||
'contextCompaction',
|
||||
]);
|
||||
|
||||
const FAILED_ITEM_STATUS = new Set([
|
||||
'failed',
|
||||
'declined',
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'aborted',
|
||||
]);
|
||||
|
||||
/** 折叠态摘要上限,与卡片契约一致。 */
|
||||
const TOOL_SUMMARY_MAX_CHARS = 120;
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function firstLine(value: string): string {
|
||||
const [line = ''] = value.split('\n');
|
||||
const trimmed = line.trim();
|
||||
return trimmed.length > TOOL_SUMMARY_MAX_CHARS
|
||||
? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…`
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind {
|
||||
switch (name) {
|
||||
case 'exec_command':
|
||||
case 'shell':
|
||||
case 'exec':
|
||||
return 'command';
|
||||
case 'apply_patch':
|
||||
case 'write_file':
|
||||
case 'edit_file':
|
||||
case 'create_file':
|
||||
return 'file_change';
|
||||
case 'web_search':
|
||||
case 'web_search_preview':
|
||||
return 'web_search';
|
||||
default:
|
||||
return 'mcp_tool';
|
||||
}
|
||||
}
|
||||
|
||||
function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind {
|
||||
switch (raw.itemType) {
|
||||
case 'function_call':
|
||||
return toolKindFromFunctionName(text(raw.name));
|
||||
// 输出条目本身不携带工具语义:它只负责补上 `output`。
|
||||
case 'function_call_output':
|
||||
return 'other';
|
||||
case 'commandExecution':
|
||||
return 'command';
|
||||
case 'fileChange':
|
||||
return 'file_change';
|
||||
case 'mcpToolCall':
|
||||
return 'mcp_tool';
|
||||
case 'webSearch':
|
||||
return 'web_search';
|
||||
case 'contextCompaction':
|
||||
return 'context_compaction';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus {
|
||||
const itemStatus = text(raw.itemStatus);
|
||||
if (itemStatus === 'completed') return 'completed';
|
||||
if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed';
|
||||
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
|
||||
if (typeof raw.exitCode === 'number') {
|
||||
return raw.exitCode === 0 ? 'completed' : 'failed';
|
||||
}
|
||||
if (typeof raw.success === 'boolean') {
|
||||
return raw.success ? 'completed' : 'failed';
|
||||
}
|
||||
return raw.completed ? 'completed' : 'running';
|
||||
}
|
||||
|
||||
function toolChanges(
|
||||
raw: DirectThreadRawItem,
|
||||
): GameCreatorDirectToolCallChange[] {
|
||||
return (raw.changes ?? [])
|
||||
.filter((change) => text(change?.path).trim().length > 0)
|
||||
.map((change) => ({
|
||||
path: text(change.path),
|
||||
kind: text(change.kind) || 'update',
|
||||
}));
|
||||
}
|
||||
|
||||
function toolTitle(
|
||||
kind: GameCreatorDirectToolCallKind,
|
||||
changes: readonly GameCreatorDirectToolCallChange[],
|
||||
): string {
|
||||
switch (kind) {
|
||||
case 'command':
|
||||
return '执行命令';
|
||||
case 'file_change': {
|
||||
const paths = new Set(changes.map((change) => change.path));
|
||||
return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件';
|
||||
}
|
||||
case 'web_search':
|
||||
return '联网检索';
|
||||
case 'context_compaction':
|
||||
return '整理上下文';
|
||||
default:
|
||||
return '调用工具';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具卡片投影。
|
||||
*
|
||||
* `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许
|
||||
* 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。
|
||||
*/
|
||||
function projectToolCard(
|
||||
raw: DirectThreadRawItem,
|
||||
): GameCreatorDirectToolCall | null {
|
||||
const kind = toolKind(raw);
|
||||
const changes = toolChanges(raw);
|
||||
const command = text(raw.command) || text(raw.arguments);
|
||||
const tool = text(raw.tool);
|
||||
const output = text(raw.output);
|
||||
const isOutputOnly = raw.itemType === 'function_call_output';
|
||||
const detail: GameCreatorDirectToolCall['detail'] = {};
|
||||
if (command && !isOutputOnly) detail.command = command;
|
||||
if (output) detail.output = output;
|
||||
if (changes.length > 0) detail.changes = changes;
|
||||
if (!detail.command && !detail.output && !detail.changes?.length) return null;
|
||||
|
||||
const summarySource =
|
||||
(kind === 'mcp_tool' ? tool : '') ||
|
||||
detail.command ||
|
||||
changes[0]?.path ||
|
||||
tool ||
|
||||
'';
|
||||
const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0;
|
||||
return {
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id: text(raw.callId) || raw.itemId,
|
||||
turnId: text(raw.turnId),
|
||||
kind,
|
||||
title: isOutputOnly ? '' : toolTitle(kind, changes),
|
||||
summary: isOutputOnly ? '' : firstLine(summarySource),
|
||||
status: toolStatus(raw),
|
||||
detail,
|
||||
startedAt: at,
|
||||
updatedAt: at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
|
||||
*
|
||||
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型
|
||||
* 都不进聊天视图。
|
||||
*/
|
||||
export function projectDirectThreadRawItem(
|
||||
raw: DirectThreadRawItem | null | undefined,
|
||||
): DirectChatEntry | null {
|
||||
if (!raw) return null;
|
||||
const itemId = text(raw.itemId).trim();
|
||||
const itemType = text(raw.itemType).trim();
|
||||
if (!itemId || !itemType) return null;
|
||||
|
||||
if (REASONING_ITEM_TYPES.has(itemType)) {
|
||||
const body = text(raw.text);
|
||||
if (!body.trim()) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'reasoning',
|
||||
role: null,
|
||||
text: body,
|
||||
toolCall: null,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (MESSAGE_ITEM_TYPES.has(itemType)) {
|
||||
const role = text(raw.role);
|
||||
if (role !== 'user' && role !== 'assistant') return null;
|
||||
const body = text(raw.text);
|
||||
if (!body) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'message',
|
||||
role,
|
||||
text: body,
|
||||
toolCall: null,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (TOOL_ITEM_TYPES.has(itemType)) {
|
||||
const toolCall = projectToolCard(raw);
|
||||
if (!toolCall) return null;
|
||||
return {
|
||||
itemId,
|
||||
callId: raw.callId ?? null,
|
||||
turnId: raw.turnId ?? null,
|
||||
kind: 'tool',
|
||||
role: null,
|
||||
text: null,
|
||||
toolCall,
|
||||
at: raw.at ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 事件 / 历史切片载荷是否像一条原始条目。 */
|
||||
export function isDirectThreadRawItem(
|
||||
payload: unknown,
|
||||
): payload is DirectThreadRawItem {
|
||||
if (!payload || typeof payload !== 'object') return false;
|
||||
const candidate = payload as Partial<DirectThreadRawItem>;
|
||||
return (
|
||||
typeof candidate.itemId === 'string' &&
|
||||
typeof candidate.itemType === 'string'
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
type DirectChatEntry,
|
||||
directChatEntryIdentity,
|
||||
emptyDirectThreadChatState,
|
||||
mergeDirectHistoryEntries,
|
||||
mergeDirectHistoryItems,
|
||||
reduceDirectThreadEvents,
|
||||
resolveDirectThreadBootstrap,
|
||||
selectDirectChatEntries,
|
||||
} from '../src/features/project-workspace/directThreadChat';
|
||||
import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents';
|
||||
import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||
|
||||
function event(
|
||||
partial: Partial<DirectThreadRawEvent> &
|
||||
@@ -18,26 +18,34 @@ function event(
|
||||
return { seq: 1, payload: {}, ...partial };
|
||||
}
|
||||
|
||||
function toolEntry(
|
||||
overrides: Partial<DirectChatEntry & { status?: string }> = {},
|
||||
): DirectChatEntry {
|
||||
/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */
|
||||
function rawToolStarted(
|
||||
overrides: Partial<DirectThreadRawItem> = {},
|
||||
): DirectThreadRawItem {
|
||||
return {
|
||||
itemId: 'call-1',
|
||||
callId: 'call-1',
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
turnId: 'turn-1',
|
||||
kind: 'tool',
|
||||
toolCall: {
|
||||
schemaVersion: 'agc-tool-call.v1',
|
||||
id: 'call-1',
|
||||
turnId: 'turn-1',
|
||||
kind: 'command',
|
||||
title: '执行命令',
|
||||
summary: 'ls',
|
||||
status: 'running',
|
||||
detail: { command: 'ls', output: undefined, changes: [] },
|
||||
startedAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
itemType: 'function_call',
|
||||
name: 'exec_command',
|
||||
arguments: '{"cmd": "ls"}',
|
||||
completed: false,
|
||||
at: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */
|
||||
function rawToolOutput(
|
||||
overrides: Partial<DirectThreadRawItem> = {},
|
||||
): DirectThreadRawItem {
|
||||
return {
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
turnId: 'turn-1',
|
||||
itemType: 'function_call_output',
|
||||
output: 'assets\ngame',
|
||||
completed: true,
|
||||
at: 2000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -95,10 +103,12 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
payload: {
|
||||
itemId: 'msg-1',
|
||||
turnId: 'turn-1',
|
||||
kind: 'message',
|
||||
itemType: 'message',
|
||||
role: 'assistant',
|
||||
text: '你好,我是陶泥儿。',
|
||||
},
|
||||
completed: true,
|
||||
at: 3000,
|
||||
} satisfies DirectThreadRawItem,
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(done);
|
||||
@@ -106,65 +116,124 @@ describe('DirectProject 聊天 reducer', () => {
|
||||
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
|
||||
});
|
||||
|
||||
it('工具条目 started/completed 归并成一张卡片', () => {
|
||||
it('思考增量按 reasoning 条目累计,不混进助手文本', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.started',
|
||||
type: 'item.delta',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'call-1',
|
||||
payload: toolEntry(),
|
||||
}),
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'call-1',
|
||||
payload: {
|
||||
...toolEntry(),
|
||||
toolCall: {
|
||||
...toolEntry().toolCall!,
|
||||
status: 'completed',
|
||||
detail: { command: 'ls', output: 'assets\ngame', changes: [] },
|
||||
updatedAt: 2000,
|
||||
},
|
||||
},
|
||||
itemId: 'reason-1',
|
||||
payload: { delta: '先看目录', kind: 'reasoning' },
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
|
||||
expect(entries[0]?.toolCall?.detail.command).toBe('ls');
|
||||
expect(entries[0]?.kind).toBe('reasoning');
|
||||
expect(entries[0]?.role).toBeNull();
|
||||
expect(entries[0]?.text).toBe('先看目录');
|
||||
});
|
||||
|
||||
it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => {
|
||||
const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [
|
||||
toolEntry(),
|
||||
it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.started',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
payload: rawToolStarted(),
|
||||
}),
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
payload: rawToolOutput(),
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(directChatEntryIdentity(entries[0]!)).toBe(
|
||||
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
);
|
||||
expect(entries[0]?.toolCall?.kind).toBe('command');
|
||||
expect(entries[0]?.toolCall?.title).toBe('执行命令');
|
||||
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
||||
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
|
||||
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('历史切片搬运层不合并,合并发生在前端投影', () => {
|
||||
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
rawToolStarted(),
|
||||
rawToolOutput(),
|
||||
{
|
||||
itemId: 'msg-user',
|
||||
kind: 'message',
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
text: '做一个拼图游戏',
|
||||
completed: true,
|
||||
at: 0,
|
||||
},
|
||||
]);
|
||||
const entries = selectDirectChatEntries(state);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame');
|
||||
expect(entries[1]?.role).toBe('user');
|
||||
});
|
||||
|
||||
it('系统条目与未识别的 item 类型不进聊天视图', () => {
|
||||
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'sys-1',
|
||||
payload: {
|
||||
itemId: 'sys-1',
|
||||
itemType: 'message',
|
||||
role: 'system',
|
||||
text: '内部指令',
|
||||
completed: true,
|
||||
at: 0,
|
||||
} satisfies DirectThreadRawItem,
|
||||
}),
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'plan-1',
|
||||
payload: {
|
||||
itemId: 'plan-1',
|
||||
itemType: 'plan',
|
||||
completed: true,
|
||||
at: 0,
|
||||
} satisfies DirectThreadRawItem,
|
||||
}),
|
||||
]);
|
||||
expect(selectDirectChatEntries(state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('历史条目与运行态按身份合并,重复条目只出现一次', () => {
|
||||
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||
rawToolStarted(),
|
||||
{
|
||||
itemId: 'msg-user',
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
text: '做一个拼图游戏',
|
||||
completed: true,
|
||||
at: 0,
|
||||
},
|
||||
]);
|
||||
const live = reduceDirectThreadEvents(historical, [
|
||||
event({
|
||||
type: 'item.completed',
|
||||
turnId: 'turn-1',
|
||||
itemId: 'call-1',
|
||||
payload: {
|
||||
...toolEntry(),
|
||||
itemId: 'call-1',
|
||||
toolCall: {
|
||||
...toolEntry().toolCall!,
|
||||
status: 'completed',
|
||||
detail: { command: 'ls', output: 'assets', changes: [] },
|
||||
},
|
||||
},
|
||||
itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76',
|
||||
payload: rawToolOutput(),
|
||||
}),
|
||||
]);
|
||||
const entries = selectDirectChatEntries(live);
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||
expect(directChatEntryIdentity(entries[0]!)).toBe('call-1');
|
||||
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
||||
expect(directChatEntryIdentity(entries[0]!)).toBe(
|
||||
'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,20 +20,29 @@ describe('Direct 回合状态与历史时间', () => {
|
||||
expect(isDirectTurnInProgress(status)).toBe(true);
|
||||
}
|
||||
});
|
||||
it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => {
|
||||
it('条目时间来自搬运后的 at,前端不自己补造时间', () => {
|
||||
const items = [
|
||||
{
|
||||
type: 'message',
|
||||
itemId: 'direct-codex:turn:user',
|
||||
itemType: 'message',
|
||||
role: 'user',
|
||||
id: 'direct-codex:turn:user',
|
||||
content: [{ type: 'input_text', text: '帮我修改游戏' }],
|
||||
text: '帮我修改游戏',
|
||||
completed: true,
|
||||
at: 1_800_000_000_001,
|
||||
},
|
||||
{
|
||||
itemId: 'direct-codex:turn:assistant',
|
||||
itemType: 'message',
|
||||
role: 'assistant',
|
||||
text: '好的',
|
||||
completed: true,
|
||||
},
|
||||
];
|
||||
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(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(
|
||||
1_800_000_000_001,
|
||||
);
|
||||
// Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。
|
||||
expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0);
|
||||
expect(items[0]).not.toHaveProperty('recordedAt');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user