把 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:
2026-09-16 18:45:13 +08:00
parent 0ec1179bf1
commit 656c89e4b2
13 changed files with 1108 additions and 795 deletions
@@ -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,
})
})
+17 -22
View File
@@ -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,
},
];
});
@@ -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');
});
});