Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed3369a494 | |||
| fdc48fe725 | |||
| 2748468d12 | |||
| 09ad0073fe | |||
| 42b702d362 | |||
| 656c89e4b2 | |||
| 0ec1179bf1 | |||
| 2938a49cac | |||
| ae0f9376c9 | |||
| 29d0cbb4df | |||
| 721e45f01b |
+16
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
|
|||||||
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
|
||||||
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||||
|
|
||||||
|
## 项目开发对话(DirectProject)
|
||||||
|
|
||||||
|
**项目对话历史**:
|
||||||
|
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||||
|
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||||
|
|
||||||
|
**运行态事件**:
|
||||||
|
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
|
||||||
|
_Avoid_: 进度通知、快照轮询、第二套历史
|
||||||
|
|
||||||
|
**聊天投影**:
|
||||||
|
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
|
||||||
|
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
|
||||||
|
|
||||||
## Relationships
|
## Relationships
|
||||||
|
|
||||||
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。
|
||||||
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
|||||||
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
|
||||||
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
|
||||||
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
|
||||||
|
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。
|
||||||
|
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ mod direct_project_history;
|
|||||||
mod direct_project_turn_history;
|
mod direct_project_turn_history;
|
||||||
mod direct_runtime;
|
mod direct_runtime;
|
||||||
mod direct_thread_manager;
|
mod direct_thread_manager;
|
||||||
|
mod direct_thread_wire;
|
||||||
mod direct_tool_bridge;
|
mod direct_tool_bridge;
|
||||||
mod direct_tool_calls;
|
mod direct_tool_calls;
|
||||||
mod direct_tools_mcp;
|
mod direct_tools_mcp;
|
||||||
@@ -55,6 +56,7 @@ pub(crate) use direct_project_history::*;
|
|||||||
pub(crate) use direct_project_turn_history::*;
|
pub(crate) use direct_project_turn_history::*;
|
||||||
pub(crate) use direct_runtime::*;
|
pub(crate) use direct_runtime::*;
|
||||||
pub(crate) use direct_thread_manager::*;
|
pub(crate) use direct_thread_manager::*;
|
||||||
|
pub(crate) use direct_thread_wire::*;
|
||||||
pub(crate) use direct_tool_bridge::*;
|
pub(crate) use direct_tool_bridge::*;
|
||||||
pub(crate) use direct_tool_calls::*;
|
pub(crate) use direct_tool_calls::*;
|
||||||
pub(crate) use direct_tools_mcp::*;
|
pub(crate) use direct_tools_mcp::*;
|
||||||
|
|||||||
@@ -564,6 +564,17 @@ enum CodexTurnEvent {
|
|||||||
item_id: String,
|
item_id: String,
|
||||||
delta: String,
|
delta: String,
|
||||||
},
|
},
|
||||||
|
/// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。
|
||||||
|
///
|
||||||
|
/// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与
|
||||||
|
/// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是
|
||||||
|
/// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed`
|
||||||
|
/// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为
|
||||||
|
/// 活动状态,不下发正文。
|
||||||
|
ReasoningDelta {
|
||||||
|
item_id: String,
|
||||||
|
delta: String,
|
||||||
|
},
|
||||||
IntermediateText(String),
|
IntermediateText(String),
|
||||||
Activity(&'static str),
|
Activity(&'static str),
|
||||||
Item {
|
Item {
|
||||||
@@ -571,7 +582,7 @@ enum CodexTurnEvent {
|
|||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
},
|
},
|
||||||
Request {
|
Request {
|
||||||
event_type: &'static str,
|
kind: DirectThreadRequestKind,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
},
|
},
|
||||||
RawItem(serde_json::Value),
|
RawItem(serde_json::Value),
|
||||||
@@ -741,23 +752,14 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat
|
|||||||
direct_codex_safe_activity_for_item(item_type)
|
direct_codex_safe_activity_for_item(item_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value {
|
fn direct_thread_event_item(
|
||||||
serde_json::json!({
|
root: &std::path::Path,
|
||||||
"itemType": item
|
item: &serde_json::Value,
|
||||||
.get("type")
|
) -> Option<DirectThreadItem> {
|
||||||
.and_then(serde_json::Value::as_str)
|
direct_thread_item_from_value(root, item, direct_tool_call_now_ms())
|
||||||
.unwrap_or("unknown"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn direct_thread_item_id(item: &serde_json::Value) -> Option<String> {
|
|
||||||
item.get("id")
|
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
.filter(|value| !value.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
fn direct_codex_command_is_game_verification(command: &str) -> bool {
|
||||||
@@ -974,19 +976,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direct_codex_request_event_type(method: &str) -> Option<&'static str> {
|
fn direct_codex_request_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||||
match method {
|
match method {
|
||||||
"item/fileChange/requestApproval"
|
"item/fileChange/requestApproval"
|
||||||
| "item/commandExecution/requestApproval"
|
| "item/commandExecution/requestApproval"
|
||||||
| "item/permissions/requestApproval" => Some("approval.requested"),
|
| "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested),
|
||||||
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"),
|
"item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => {
|
||||||
|
Some(DirectThreadRequestKind::AskRequested)
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> {
|
fn direct_codex_resolution_event_type(method: &str) -> Option<DirectThreadRequestKind> {
|
||||||
match method {
|
match method {
|
||||||
"serverRequest/resolved" => Some("request.resolved"),
|
"serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1063,33 @@ fn direct_codex_notification_event(
|
|||||||
intermediate_text: Option<String>,
|
intermediate_text: Option<String>,
|
||||||
safe_activity: Option<&'static str>,
|
safe_activity: Option<&'static str>,
|
||||||
) -> Option<CodexTurnEvent> {
|
) -> Option<CodexTurnEvent> {
|
||||||
|
// 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为
|
||||||
|
// "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。
|
||||||
|
//
|
||||||
|
// 两条通知都下发正文,不下发活动文本:
|
||||||
|
// - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`;
|
||||||
|
// - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`,
|
||||||
|
// 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。
|
||||||
|
// 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围;
|
||||||
|
// 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。
|
||||||
|
if matches!(
|
||||||
|
method,
|
||||||
|
"item/reasoning/summaryTextDelta" | "item/reasoning/textDelta"
|
||||||
|
) {
|
||||||
|
return params
|
||||||
|
.get("delta")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(|delta| CodexTurnEvent::ReasoningDelta {
|
||||||
|
item_id: params
|
||||||
|
.get("itemId")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or_else(|| "direct-missing-item".to_string()),
|
||||||
|
delta: delta.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
|
let (activity, intermediate_text) = match (&intermediate_text, safe_activity) {
|
||||||
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
|
(Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None),
|
||||||
_ => (safe_activity, intermediate_text),
|
_ => (safe_activity, intermediate_text),
|
||||||
@@ -2926,18 +2957,7 @@ impl CodexAppServerConnection {
|
|||||||
turn_start_guard.armed = false;
|
turn_start_guard.armed = false;
|
||||||
let direct_thread_id = history_root.to_string_lossy().into_owned();
|
let direct_thread_id = history_root.to_string_lossy().into_owned();
|
||||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
append_direct_thread_event(
|
append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started());
|
||||||
&direct_thread_id,
|
|
||||||
DirectThreadRawEventDraft {
|
|
||||||
event_type: "turn.started".to_string(),
|
|
||||||
turn_id: turn_id.clone(),
|
|
||||||
item_id: None,
|
|
||||||
payload: serde_json::json!({
|
|
||||||
"threadId": thread_id,
|
|
||||||
"turnId": turn_id,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let mut receiver = self.register_turn(&turn_id).await;
|
let mut receiver = self.register_turn(&turn_id).await;
|
||||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||||
@@ -2992,12 +3012,13 @@ impl CodexAppServerConnection {
|
|||||||
direct_project_history.observe_delta(&item_id, &delta);
|
direct_project_history.observe_delta(&item_id, &delta);
|
||||||
append_direct_thread_event(
|
append_direct_thread_event(
|
||||||
&direct_thread_id,
|
&direct_thread_id,
|
||||||
DirectThreadRawEventDraft {
|
// 事件自足:增量自带 item 身份与正文类别(正文 / 思考),
|
||||||
event_type: "item.delta".to_string(),
|
// 前端 reducer 不允许靠猜 itemId 的来源决定 kind。
|
||||||
turn_id: turn_id.clone(),
|
DirectThreadEvent::item_delta(
|
||||||
item_id: Some(item_id.clone()),
|
item_id.clone(),
|
||||||
payload: serde_json::json!({ "delta": delta.clone() }),
|
DirectThreadDeltaKind::Message,
|
||||||
},
|
delta.clone(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
streamed_text.push_str(&delta);
|
streamed_text.push_str(&delta);
|
||||||
@@ -3028,6 +3049,18 @@ impl CodexAppServerConnection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => {
|
||||||
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
|
append_direct_thread_event(
|
||||||
|
&direct_thread_id,
|
||||||
|
DirectThreadEvent::item_delta(
|
||||||
|
item_id,
|
||||||
|
DirectThreadDeltaKind::Reasoning,
|
||||||
|
delta,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(CodexTurnEvent::IntermediateText(text)) => {
|
Some(CodexTurnEvent::IntermediateText(text)) => {
|
||||||
if let Some(observer) = direct_observer.as_deref_mut() {
|
if let Some(observer) = direct_observer.as_deref_mut() {
|
||||||
observer(DirectCodexTurnObservation::IntermediateText(text));
|
observer(DirectCodexTurnObservation::IntermediateText(text));
|
||||||
@@ -3040,6 +3073,7 @@ impl CodexAppServerConnection {
|
|||||||
"rawResponseItem/completed 缺少 item".to_string(),
|
"rawResponseItem/completed 缺少 item".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let entry_item = direct_thread_event_item(history_root, &item);
|
||||||
let history_root = history_root.to_path_buf();
|
let history_root = history_root.to_path_buf();
|
||||||
let history_item = item.clone();
|
let history_item = item.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
@@ -3053,19 +3087,15 @@ impl CodexAppServerConnection {
|
|||||||
})?
|
})?
|
||||||
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
.map_err(platform_llm::LlmError::InvalidRequest)?;
|
||||||
direct_project_history.complete_item(&item);
|
direct_project_history.complete_item(&item);
|
||||||
let item_id = direct_thread_item_id(&item);
|
if let Some(entry_item) = entry_item {
|
||||||
append_direct_thread_event(
|
append_direct_thread_event(
|
||||||
&direct_thread_id,
|
&direct_thread_id,
|
||||||
DirectThreadRawEventDraft {
|
DirectThreadEvent::item_completed(entry_item),
|
||||||
event_type: "item.completed".to_string(),
|
);
|
||||||
turn_id: turn_id.clone(),
|
}
|
||||||
item_id,
|
|
||||||
payload: serde_json::json!({}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(CodexTurnEvent::Request { event_type, params }) => {
|
Some(CodexTurnEvent::Request { kind, params }) => {
|
||||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||||
let request_id = params
|
let request_id = params
|
||||||
.get("requestId")
|
.get("requestId")
|
||||||
@@ -3075,14 +3105,7 @@ impl CodexAppServerConnection {
|
|||||||
.map(str::to_string);
|
.map(str::to_string);
|
||||||
append_direct_thread_event(
|
append_direct_thread_event(
|
||||||
&direct_thread_id,
|
&direct_thread_id,
|
||||||
DirectThreadRawEventDraft {
|
DirectThreadEvent::request(kind, request_id),
|
||||||
event_type: event_type.to_string(),
|
|
||||||
turn_id: turn_id.clone(),
|
|
||||||
item_id: None,
|
|
||||||
payload: request_id
|
|
||||||
.map(|id| serde_json::json!({ "requestId": id }))
|
|
||||||
.unwrap_or_else(|| serde_json::json!({})),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3193,16 +3216,14 @@ impl CodexAppServerConnection {
|
|||||||
&& self.inner.workspace_mode
|
&& self.inner.workspace_mode
|
||||||
== CodexAppServerWorkspaceMode::DirectProject
|
== CodexAppServerWorkspaceMode::DirectProject
|
||||||
{
|
{
|
||||||
let item_id = direct_thread_item_id(item);
|
if let Some(entry_item) =
|
||||||
append_direct_thread_event(
|
direct_thread_event_item(history_root, item)
|
||||||
&direct_thread_id,
|
{
|
||||||
DirectThreadRawEventDraft {
|
append_direct_thread_event(
|
||||||
event_type: "item.started".to_string(),
|
&direct_thread_id,
|
||||||
turn_id: turn_id.clone(),
|
DirectThreadEvent::item_started(entry_item),
|
||||||
item_id,
|
);
|
||||||
payload: direct_thread_item_started_payload(item),
|
}
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3244,12 +3265,7 @@ impl CodexAppServerConnection {
|
|||||||
{
|
{
|
||||||
append_direct_thread_event(
|
append_direct_thread_event(
|
||||||
&direct_thread_id,
|
&direct_thread_id,
|
||||||
DirectThreadRawEventDraft {
|
DirectThreadEvent::turn_completed(status.to_string()),
|
||||||
event_type: "turn.completed".to_string(),
|
|
||||||
turn_id: turn_id.clone(),
|
|
||||||
item_id: None,
|
|
||||||
payload: serde_json::json!({ "status": status }),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
match status {
|
match status {
|
||||||
@@ -3944,8 +3960,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let event = if let Some(event_type) = direct_codex_resolution_event_type(method) {
|
let event = if let Some(kind) = direct_codex_resolution_event_type(method) {
|
||||||
CodexTurnEvent::Request { event_type, params }
|
CodexTurnEvent::Request { kind, params }
|
||||||
} else if let Some(activity) = safe_activity {
|
} else if let Some(activity) = safe_activity {
|
||||||
// Preparing notifications may carry private plan/reasoning text;
|
// Preparing notifications may carry private plan/reasoning text;
|
||||||
// expose only the safe activity category. Other categories may
|
// expose only the safe activity category. Other categories may
|
||||||
@@ -3997,8 +4013,8 @@ async fn read_game_creator_codex_app_server_stdout(
|
|||||||
),
|
),
|
||||||
method if direct_codex_request_event_type(method).is_some() => {
|
method if direct_codex_request_event_type(method).is_some() => {
|
||||||
CodexTurnEvent::Request {
|
CodexTurnEvent::Request {
|
||||||
event_type: direct_codex_request_event_type(method)
|
kind: direct_codex_request_event_type(method)
|
||||||
.expect("request event type checked above"),
|
.expect("request kind checked above"),
|
||||||
params,
|
params,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4509,11 +4525,29 @@ mod tests {
|
|||||||
"arguments": { "path": "game/index.html", "token": "secret" },
|
"arguments": { "path": "game/index.html", "token": "secret" },
|
||||||
"result": { "content": "large output" }
|
"result": { "content": "large output" }
|
||||||
});
|
});
|
||||||
assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1"));
|
// 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。
|
||||||
|
let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item");
|
||||||
|
assert_eq!(projected.item_id(), "item-1");
|
||||||
|
let payload = serde_json::to_value(&projected).expect("payload");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
direct_thread_item_started_payload(&item),
|
payload.get("itemType").and_then(serde_json::Value::as_str),
|
||||||
serde_json::json!({ "itemType": "mcpToolCall" })
|
Some("mcpToolCall")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("itemId").and_then(serde_json::Value::as_str),
|
||||||
|
Some("item-1")
|
||||||
|
);
|
||||||
|
// 卡片标题 / 折叠摘要 / 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]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,11 +8,9 @@
|
|||||||
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
|
||||||
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
|
||||||
|
|
||||||
use crate::agent::redact_secret_tokens;
|
use super::direct_thread_wire::sanitize_detail_text;
|
||||||
use crate::agent::sanitize_error_context;
|
|
||||||
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
|
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::project::{enforce_project_permission_policy, project_append_lock_for};
|
||||||
use crate::redact_absolute_path_tokens;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
@@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf {
|
|||||||
root.join(".agent/conversations/tool-calls.jsonl")
|
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),并在真正截断时补省略号。
|
/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。
|
||||||
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
fn bounded_chars(value: &str, max_chars: usize) -> String {
|
||||||
if value.chars().count() <= max_chars {
|
if value.chars().count() <= max_chars {
|
||||||
|
|||||||
@@ -5383,10 +5383,21 @@ pub(crate) async fn read_direct_project_history_slice(
|
|||||||
before_item_id.as_deref(),
|
before_item_id.as_deref(),
|
||||||
limit.unwrap_or(20),
|
limit.unwrap_or(20),
|
||||||
)?;
|
)?;
|
||||||
|
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_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 {
|
Ok(DirectThreadHistorySlice {
|
||||||
items,
|
items,
|
||||||
has_more,
|
has_more,
|
||||||
item_timestamps,
|
first_item_id,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -4101,6 +4101,7 @@ export function App({
|
|||||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||||
let runtimeError = '';
|
let runtimeError = '';
|
||||||
let loadedDirectHistoryHasMore = false;
|
let loadedDirectHistoryHasMore = false;
|
||||||
|
let loadedDirectHistoryFirstItemId: string | null = null;
|
||||||
const projectConversation = directCodexProductRuntime
|
const projectConversation = directCodexProductRuntime
|
||||||
? (() => {
|
? (() => {
|
||||||
return invoke<DirectThreadHistorySlice>(
|
return invoke<DirectThreadHistorySlice>(
|
||||||
@@ -4111,13 +4112,11 @@ export function App({
|
|||||||
},
|
},
|
||||||
).then((slice) => {
|
).then((slice) => {
|
||||||
loadedDirectHistoryHasMore = slice.hasMore;
|
loadedDirectHistoryHasMore = slice.hasMore;
|
||||||
|
loadedDirectHistoryFirstItemId = slice.firstItemId;
|
||||||
return {
|
return {
|
||||||
path: nextProjectPath,
|
path: nextProjectPath,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
messages: directThreadHistoryItemsToMessages(
|
messages: directThreadHistoryItemsToMessages(slice.items),
|
||||||
slice.items,
|
|
||||||
slice.itemTimestamps,
|
|
||||||
),
|
|
||||||
} satisfies LocalConversationResult;
|
} satisfies LocalConversationResult;
|
||||||
});
|
});
|
||||||
})()
|
})()
|
||||||
@@ -4214,9 +4213,7 @@ export function App({
|
|||||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||||
if (directCodexProductRuntime) {
|
if (directCodexProductRuntime) {
|
||||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||||
directHistoryOldestItemIdRef.current =
|
directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId;
|
||||||
conversationMessages.find((message) => message.messageId)
|
|
||||||
?.messageId ?? null;
|
|
||||||
}
|
}
|
||||||
setMessages((current) => {
|
setMessages((current) => {
|
||||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||||
@@ -12493,25 +12490,23 @@ export function App({
|
|||||||
if (localProjectPathRef.current !== projectPath) {
|
if (localProjectPathRef.current !== projectPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const older = directThreadHistoryItemsToMessages(
|
const older = directThreadHistoryItemsToMessages(slice.items).map(
|
||||||
slice.items,
|
(message) => ({
|
||||||
slice.itemTimestamps,
|
role:
|
||||||
).map((message) => ({
|
message.role === 'user'
|
||||||
role:
|
? ('user' as const)
|
||||||
message.role === 'user'
|
: ('assistant' as const),
|
||||||
? ('user' as const)
|
text: message.content,
|
||||||
: ('assistant' as const),
|
runtimeOwned: true,
|
||||||
text: message.content,
|
messageId: message.messageId,
|
||||||
runtimeOwned: true,
|
updatedAt: message.updatedAt,
|
||||||
messageId: message.messageId,
|
}),
|
||||||
updatedAt: message.updatedAt,
|
);
|
||||||
}));
|
|
||||||
setMessages((current) => [...older, ...current]);
|
setMessages((current) => [...older, ...current]);
|
||||||
setConversationVisibleCount((current) => current + older.length);
|
setConversationVisibleCount((current) => current + older.length);
|
||||||
setDirectHistoryHasMore(slice.hasMore);
|
setDirectHistoryHasMore(slice.hasMore);
|
||||||
directHistoryOldestItemIdRef.current =
|
directHistoryOldestItemIdRef.current =
|
||||||
older.find((message) => message.messageId)?.messageId ??
|
slice.firstItemId ?? directHistoryOldestItemIdRef.current;
|
||||||
directHistoryOldestItemIdRef.current;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setWorkspaceStatus(
|
setWorkspaceStatus(
|
||||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { useEffect, useId, useState } from 'react';
|
import { useEffect, useId, useState } from 'react';
|
||||||
|
|
||||||
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
|
||||||
import type { GameCreatorDirectToolCall } from '../../app/types';
|
import type { DirectChatToolCard } from './directThreadChat';
|
||||||
import {
|
import {
|
||||||
formatToolCallDuration,
|
formatToolCallDuration,
|
||||||
formatTurnDuration,
|
formatTurnDuration,
|
||||||
@@ -38,7 +38,7 @@ export function ToolCallGroup({
|
|||||||
active = false,
|
active = false,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
calls: GameCreatorDirectToolCall[];
|
calls: DirectChatToolCard[];
|
||||||
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
|
||||||
userSentAt?: number | null;
|
userSentAt?: number | null;
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +169,7 @@ function ToolCallRow({
|
|||||||
call,
|
call,
|
||||||
active,
|
active,
|
||||||
}: {
|
}: {
|
||||||
call: GameCreatorDirectToolCall;
|
call: DirectChatToolCard;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。
|
||||||
|
*
|
||||||
|
* 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 +
|
||||||
|
* 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject
|
||||||
|
* 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GameCreatorDirectToolCall } from '../../app/types';
|
||||||
|
import type {
|
||||||
|
DirectThreadConsumeResult,
|
||||||
|
DirectThreadEvent,
|
||||||
|
DirectThreadHistorySlice,
|
||||||
|
DirectThreadItem,
|
||||||
|
DirectThreadSubscriptionBootstrap,
|
||||||
|
} from './directThreadEvents';
|
||||||
|
import { projectDirectThreadItem } from './directThreadItemProjection';
|
||||||
|
|
||||||
|
export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool';
|
||||||
|
|
||||||
|
/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */
|
||||||
|
export type DirectChatToolCard = Omit<GameCreatorDirectToolCall, 'turnId'>;
|
||||||
|
|
||||||
|
/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */
|
||||||
|
export type DirectChatEntry = {
|
||||||
|
itemId: string;
|
||||||
|
kind: DirectChatEntryKind;
|
||||||
|
role?: 'user' | 'assistant' | null;
|
||||||
|
text?: string | null;
|
||||||
|
toolCall?: DirectChatToolCard | null;
|
||||||
|
at?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DirectThreadChatState = {
|
||||||
|
subscriptionId: string | null;
|
||||||
|
/** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */
|
||||||
|
lastCompletedItemId: string | null;
|
||||||
|
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
|
||||||
|
turnRunning: boolean;
|
||||||
|
/** 历史切片条目,保持文件顺序。 */
|
||||||
|
history: DirectChatEntry[];
|
||||||
|
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
|
||||||
|
live: DirectChatEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function emptyDirectThreadChatState(): DirectThreadChatState {
|
||||||
|
return {
|
||||||
|
subscriptionId: null,
|
||||||
|
lastCompletedItemId: null,
|
||||||
|
turnRunning: false,
|
||||||
|
history: [],
|
||||||
|
live: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function longerText(
|
||||||
|
left: string | null | undefined,
|
||||||
|
right: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const a = typeof left === 'string' ? left : '';
|
||||||
|
const b = typeof right === 'string' ? right : '';
|
||||||
|
// 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。
|
||||||
|
return b.length > a.length ? b : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeToolStatus(
|
||||||
|
left: DirectChatToolCard['status'] | null | undefined,
|
||||||
|
right: DirectChatToolCard['status'] | null | undefined,
|
||||||
|
): DirectChatToolCard['status'] {
|
||||||
|
// 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。
|
||||||
|
if (left === 'running' || !left) return right ?? left ?? 'running';
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeToolCard(
|
||||||
|
left: DirectChatToolCard | null,
|
||||||
|
right: DirectChatToolCard | null,
|
||||||
|
): DirectChatToolCard | null {
|
||||||
|
if (!left) return right;
|
||||||
|
if (!right) return left;
|
||||||
|
return {
|
||||||
|
...left,
|
||||||
|
kind: left.kind && left.kind !== 'other' ? left.kind : right.kind,
|
||||||
|
title: left.title?.trim() ? left.title : right.title,
|
||||||
|
summary: left.summary?.trim() ? left.summary : right.summary,
|
||||||
|
status: mergeToolStatus(left.status, right.status),
|
||||||
|
detail: {
|
||||||
|
command: left.detail.command ?? right.detail.command,
|
||||||
|
output: left.detail.output ?? right.detail.output,
|
||||||
|
changes: left.detail.changes?.length
|
||||||
|
? left.detail.changes
|
||||||
|
: right.detail.changes,
|
||||||
|
},
|
||||||
|
startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt,
|
||||||
|
updatedAt: Math.max(left.updatedAt, right.updatedAt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 先到的快照赢,后到的只补空字段。
|
||||||
|
*
|
||||||
|
* 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running`
|
||||||
|
* 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。
|
||||||
|
*/
|
||||||
|
export function mergeDirectChatEntry(
|
||||||
|
existing: DirectChatEntry,
|
||||||
|
incoming: DirectChatEntry,
|
||||||
|
): DirectChatEntry {
|
||||||
|
return {
|
||||||
|
itemId: existing.itemId || incoming.itemId,
|
||||||
|
kind:
|
||||||
|
existing.kind === 'tool' || incoming.kind === 'tool'
|
||||||
|
? 'tool'
|
||||||
|
: existing.kind,
|
||||||
|
role: existing.role ?? incoming.role ?? null,
|
||||||
|
text: longerText(existing.text, incoming.text),
|
||||||
|
toolCall: mergeToolCard(
|
||||||
|
existing.toolCall ?? null,
|
||||||
|
incoming.toolCall ?? null,
|
||||||
|
),
|
||||||
|
at: existing.at || incoming.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertLiveEntry(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
entry: DirectChatEntry,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
const index = state.live.findIndex(
|
||||||
|
(existing) => existing.itemId === entry.itemId,
|
||||||
|
);
|
||||||
|
if (index < 0) {
|
||||||
|
return { ...state, live: [...state.live, entry] };
|
||||||
|
}
|
||||||
|
const existing = state.live[index];
|
||||||
|
if (!existing) {
|
||||||
|
return { ...state, live: [...state.live, entry] };
|
||||||
|
}
|
||||||
|
const live = [...state.live];
|
||||||
|
live[index] = mergeDirectChatEntry(existing, entry);
|
||||||
|
return { ...state, live };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLiveText(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
event: Extract<DirectThreadEvent, { type: 'item.delta' }>,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
const itemId = event.itemId.trim();
|
||||||
|
if (!itemId || !event.delta) return state;
|
||||||
|
const reasoning = event.kind === 'reasoning';
|
||||||
|
const existing = state.live.find((entry) => entry.itemId === itemId);
|
||||||
|
return upsertLiveEntry(state, {
|
||||||
|
itemId,
|
||||||
|
kind: reasoning ? 'reasoning' : 'message',
|
||||||
|
role: reasoning ? null : 'assistant',
|
||||||
|
text: `${existing?.text ?? ''}${event.delta}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceDirectThreadEvent(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
event: DirectThreadEvent,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'turn.started':
|
||||||
|
return { ...state, turnRunning: true };
|
||||||
|
case 'turn.completed':
|
||||||
|
// 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
turnRunning: false,
|
||||||
|
history: mergeHistoryEntries(state.history, state.live),
|
||||||
|
live: [],
|
||||||
|
};
|
||||||
|
case 'item.delta':
|
||||||
|
return appendLiveText(state, event);
|
||||||
|
case 'item.started':
|
||||||
|
case 'item.completed': {
|
||||||
|
const entry = projectDirectThreadItem(event.item);
|
||||||
|
return entry ? upsertLiveEntry(state, entry) : state;
|
||||||
|
}
|
||||||
|
case 'request':
|
||||||
|
// 审批 / 提问只影响面板交互,不并入聊天条目。
|
||||||
|
return state;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceDirectThreadEvents(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
events: readonly DirectThreadEvent[],
|
||||||
|
): DirectThreadChatState {
|
||||||
|
return events.reduce(reduceDirectThreadEvent, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
|
||||||
|
*
|
||||||
|
* 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。
|
||||||
|
*/
|
||||||
|
export function resolveDirectThreadBootstrap(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
bootstrap: DirectThreadSubscriptionBootstrap,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
return reduceDirectThreadEvents(
|
||||||
|
{
|
||||||
|
...state,
|
||||||
|
subscriptionId: bootstrap.subscriptionId,
|
||||||
|
lastCompletedItemId: bootstrap.lastCompletedItemId ?? null,
|
||||||
|
},
|
||||||
|
bootstrap.events,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */
|
||||||
|
export function applyDirectThreadConsumeResult(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
result: DirectThreadConsumeResult,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
return reduceDirectThreadEvents(state, result.events);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */
|
||||||
|
export function mergeHistoryEntries(
|
||||||
|
leading: readonly DirectChatEntry[],
|
||||||
|
trailing: readonly DirectChatEntry[],
|
||||||
|
): DirectChatEntry[] {
|
||||||
|
const byId = new Map<string, number>();
|
||||||
|
const entries: DirectChatEntry[] = [];
|
||||||
|
for (const entry of [...leading, ...trailing]) {
|
||||||
|
const index = byId.get(entry.itemId);
|
||||||
|
if (index === undefined) {
|
||||||
|
byId.set(entry.itemId, entries.length);
|
||||||
|
entries.push(entry);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const existing = entries[index];
|
||||||
|
if (existing) entries[index] = mergeDirectChatEntry(existing, entry);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
|
||||||
|
*
|
||||||
|
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
|
||||||
|
*/
|
||||||
|
export function mergeDirectHistoryItems(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
items: readonly DirectThreadItem[],
|
||||||
|
): DirectThreadChatState {
|
||||||
|
const entries = items
|
||||||
|
.map((item) => projectDirectThreadItem(item))
|
||||||
|
.filter((entry): entry is DirectChatEntry => Boolean(entry));
|
||||||
|
return { ...state, history: mergeHistoryEntries(entries, state.history) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDirectThreadHistorySlice(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
slice: DirectThreadHistorySlice,
|
||||||
|
): DirectThreadChatState {
|
||||||
|
return mergeDirectHistoryItems(state, slice.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */
|
||||||
|
export function selectDirectChatEntries(
|
||||||
|
state: DirectThreadChatState,
|
||||||
|
): DirectChatEntry[] {
|
||||||
|
return mergeHistoryEntries(state.history, state.live);
|
||||||
|
}
|
||||||
@@ -1,59 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* DirectProject 运行态事件的线上类型。
|
||||||
|
*
|
||||||
|
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
|
||||||
|
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
|
||||||
|
*/
|
||||||
|
|
||||||
import type { LocalConversationMessageRecord } from '../../app/types';
|
import type { LocalConversationMessageRecord } from '../../app/types';
|
||||||
|
import type { DirectThreadItem } from './generated';
|
||||||
|
|
||||||
export type DirectThreadRawEvent = {
|
export type {
|
||||||
seq: number;
|
DirectThreadConsumeResult,
|
||||||
type: string;
|
DirectThreadDeltaKind,
|
||||||
turnId: string;
|
DirectThreadEvent,
|
||||||
itemId?: string;
|
DirectThreadFileChange,
|
||||||
payload: Record<string, unknown>;
|
DirectThreadHistorySlice,
|
||||||
};
|
DirectThreadItem,
|
||||||
|
DirectThreadRequestKind,
|
||||||
export type DirectThreadSubscriptionBootstrap = {
|
DirectThreadSubscriptionBootstrap,
|
||||||
subscriptionId: string;
|
} from './generated';
|
||||||
lastCompletedItemId: string | null;
|
|
||||||
events: DirectThreadRawEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DirectThreadConsumeResult = {
|
|
||||||
events: DirectThreadRawEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DirectThreadHistorySlice = {
|
|
||||||
items: unknown[];
|
|
||||||
hasMore: boolean;
|
|
||||||
itemTimestamps?: Record<string, number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史条目转聊天消息。
|
||||||
|
*
|
||||||
|
* 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。
|
||||||
|
* 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。
|
||||||
|
*/
|
||||||
export function directThreadHistoryItemsToMessages(
|
export function directThreadHistoryItemsToMessages(
|
||||||
items: unknown[],
|
items: readonly DirectThreadItem[],
|
||||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
|
||||||
): LocalConversationMessageRecord[] {
|
): LocalConversationMessageRecord[] {
|
||||||
return items.flatMap((raw) => {
|
return items.flatMap((item) => {
|
||||||
if (!raw || typeof raw !== 'object') return [];
|
if (item.itemType !== 'message') return [];
|
||||||
const item = raw as Record<string, unknown>;
|
if (item.role !== 'user' && item.role !== 'assistant') return [];
|
||||||
const role = item.role;
|
if (!item.text) return [];
|
||||||
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('')
|
|
||||||
: '';
|
|
||||||
if (!content) return [];
|
|
||||||
const messageId = typeof item.id === 'string' ? item.id : undefined;
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
schemaVersion: 'agc-direct-project-context.v1',
|
schemaVersion: 'agc-direct-project-context.v1',
|
||||||
role: messageRole,
|
role: item.role,
|
||||||
content,
|
content: item.text,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
messageId,
|
messageId: item.itemId,
|
||||||
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
|
updatedAt: item.at ?? 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* DirectProject「原始条目 → 聊天条目」投影。
|
||||||
|
*
|
||||||
|
* 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的
|
||||||
|
* `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个
|
||||||
|
* 函数,因此实时与回读不可能出现两套口径。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GameCreatorDirectToolCallChange,
|
||||||
|
GameCreatorDirectToolCallDetail,
|
||||||
|
GameCreatorDirectToolCallKind,
|
||||||
|
GameCreatorDirectToolCallStatus,
|
||||||
|
} from '../../app/types';
|
||||||
|
import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat';
|
||||||
|
import type { DirectThreadItem } from './directThreadEvents';
|
||||||
|
|
||||||
|
/** 折叠态摘要上限,与卡片契约一致。 */
|
||||||
|
const TOOL_SUMMARY_MAX_CHARS = 120;
|
||||||
|
|
||||||
|
const FAILED_ITEM_STATUS = new Set([
|
||||||
|
'failed',
|
||||||
|
'declined',
|
||||||
|
'cancelled',
|
||||||
|
'canceled',
|
||||||
|
'aborted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 toolStatus(
|
||||||
|
status: string | null,
|
||||||
|
exitCode: number | null,
|
||||||
|
): GameCreatorDirectToolCallStatus {
|
||||||
|
if (status === 'completed') return 'completed';
|
||||||
|
if (status && FAILED_ITEM_STATUS.has(status)) return 'failed';
|
||||||
|
// Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。
|
||||||
|
if (typeof exitCode === 'number') {
|
||||||
|
return exitCode === 0 ? 'completed' : 'failed';
|
||||||
|
}
|
||||||
|
return status ? 'running' : 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fileChanges(
|
||||||
|
item: Extract<DirectThreadItem, { itemType: 'fileChange' }>,
|
||||||
|
): GameCreatorDirectToolCallChange[] {
|
||||||
|
return item.changes
|
||||||
|
.filter((change) => change.path.trim().length > 0)
|
||||||
|
.map((change) => ({
|
||||||
|
path: change.path,
|
||||||
|
kind: change.kind || 'update',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolCardInput = {
|
||||||
|
itemId: string;
|
||||||
|
at: number;
|
||||||
|
kind: GameCreatorDirectToolCallKind;
|
||||||
|
/** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */
|
||||||
|
outputOnly?: boolean;
|
||||||
|
tool?: string;
|
||||||
|
command?: string;
|
||||||
|
output?: string;
|
||||||
|
status: GameCreatorDirectToolCallStatus;
|
||||||
|
changes?: GameCreatorDirectToolCallChange[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildToolCard(input: ToolCardInput): DirectChatToolCard | null {
|
||||||
|
const changes = input.changes ?? [];
|
||||||
|
const detail: GameCreatorDirectToolCallDetail = {};
|
||||||
|
if (input.command && !input.outputOnly) detail.command = input.command;
|
||||||
|
if (input.output) detail.output = input.output;
|
||||||
|
if (changes.length > 0) detail.changes = changes;
|
||||||
|
// 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。
|
||||||
|
if (!detail.command && !detail.output && !detail.changes?.length) return null;
|
||||||
|
|
||||||
|
const summarySource =
|
||||||
|
(input.kind === 'mcp_tool' ? (input.tool ?? '') : '') ||
|
||||||
|
detail.command ||
|
||||||
|
changes[0]?.path ||
|
||||||
|
input.tool ||
|
||||||
|
'';
|
||||||
|
return {
|
||||||
|
schemaVersion: 'agc-tool-call.v1',
|
||||||
|
id: input.itemId,
|
||||||
|
kind: input.kind,
|
||||||
|
title: input.outputOnly ? '' : toolTitle(input.kind, changes),
|
||||||
|
summary: input.outputOnly ? '' : firstLine(summarySource),
|
||||||
|
status: input.status,
|
||||||
|
detail,
|
||||||
|
startedAt: input.at,
|
||||||
|
updatedAt: input.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null {
|
||||||
|
switch (item.itemType) {
|
||||||
|
case 'function_call':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: toolKindFromFunctionName(item.name),
|
||||||
|
tool: item.name,
|
||||||
|
command: item.arguments,
|
||||||
|
status: 'running',
|
||||||
|
});
|
||||||
|
case 'function_call_output':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'other',
|
||||||
|
outputOnly: true,
|
||||||
|
output: item.output,
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
case 'commandExecution':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'command',
|
||||||
|
command: item.command,
|
||||||
|
output: item.output ?? '',
|
||||||
|
status: toolStatus(item.status, item.exitCode),
|
||||||
|
});
|
||||||
|
case 'fileChange':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'file_change',
|
||||||
|
changes: fileChanges(item),
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
case 'mcpToolCall':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'mcp_tool',
|
||||||
|
tool: item.tool,
|
||||||
|
command: item.arguments,
|
||||||
|
output: item.output ?? '',
|
||||||
|
status: toolStatus(item.status, null),
|
||||||
|
});
|
||||||
|
case 'webSearch':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'web_search',
|
||||||
|
command: item.query ?? '',
|
||||||
|
output: item.output ?? '',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
case 'contextCompaction':
|
||||||
|
return buildToolCard({
|
||||||
|
itemId: item.itemId,
|
||||||
|
at: item.at,
|
||||||
|
kind: 'context_compaction',
|
||||||
|
command: '整理上下文',
|
||||||
|
status: 'completed',
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。
|
||||||
|
*
|
||||||
|
* 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进
|
||||||
|
* 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。
|
||||||
|
*/
|
||||||
|
export function projectDirectThreadItem(
|
||||||
|
item: DirectThreadItem | null | undefined,
|
||||||
|
): DirectChatEntry | null {
|
||||||
|
if (!item) return null;
|
||||||
|
const itemId = item.itemId.trim();
|
||||||
|
if (!itemId) return null;
|
||||||
|
|
||||||
|
switch (item.itemType) {
|
||||||
|
case 'message': {
|
||||||
|
const role =
|
||||||
|
item.role === 'user'
|
||||||
|
? 'user'
|
||||||
|
: item.role === 'assistant'
|
||||||
|
? 'assistant'
|
||||||
|
: null;
|
||||||
|
if (!role || !item.text.trim()) return null;
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
kind: 'message',
|
||||||
|
role,
|
||||||
|
text: item.text,
|
||||||
|
toolCall: null,
|
||||||
|
at: item.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'reasoning': {
|
||||||
|
if (!item.text.trim()) return null;
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
kind: 'reasoning',
|
||||||
|
role: null,
|
||||||
|
text: item.text,
|
||||||
|
toolCall: null,
|
||||||
|
at: item.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'other':
|
||||||
|
// TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。
|
||||||
|
return null;
|
||||||
|
default: {
|
||||||
|
const toolCall = toolCardFromItem(item);
|
||||||
|
if (!toolCall) return null;
|
||||||
|
return {
|
||||||
|
itemId,
|
||||||
|
kind: 'tool',
|
||||||
|
role: null,
|
||||||
|
text: null,
|
||||||
|
toolCall,
|
||||||
|
at: item.at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectThreadEvent } from './DirectThreadEvent';
|
||||||
|
|
||||||
|
export type DirectThreadConsumeResult = { events: Array<DirectThreadEvent> };
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量正文属于哪类条目。
|
||||||
|
*/
|
||||||
|
export type DirectThreadDeltaKind = 'message' | 'reasoning';
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||||
|
import type { DirectThreadItem } from './DirectThreadItem';
|
||||||
|
import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread Manager 下发的运行态事件。
|
||||||
|
*
|
||||||
|
* 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`:
|
||||||
|
* 游标是 Thread Manager 的内部事实,不下发。
|
||||||
|
*
|
||||||
|
* 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由
|
||||||
|
* 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。
|
||||||
|
*/
|
||||||
|
export type DirectThreadEvent =
|
||||||
|
| { type: 'turn.started' }
|
||||||
|
| { type: 'turn.completed'; status: string }
|
||||||
|
| { type: 'item.started'; item: DirectThreadItem }
|
||||||
|
| { type: 'item.completed'; item: DirectThreadItem }
|
||||||
|
| {
|
||||||
|
type: 'item.delta';
|
||||||
|
itemId: string;
|
||||||
|
kind: DirectThreadDeltaKind;
|
||||||
|
delta: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'request';
|
||||||
|
kind: DirectThreadRequestKind;
|
||||||
|
requestId: string | null;
|
||||||
|
};
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条文件变更。
|
||||||
|
*/
|
||||||
|
export type DirectThreadFileChange = {
|
||||||
|
path: string;
|
||||||
|
/**
|
||||||
|
* `add` | `update` | `delete`
|
||||||
|
*/
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectThreadItem } from './DirectThreadItem';
|
||||||
|
|
||||||
|
export type DirectThreadHistorySlice = {
|
||||||
|
/**
|
||||||
|
* 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
||||||
|
*/
|
||||||
|
items: Array<DirectThreadItem>;
|
||||||
|
hasMore: boolean;
|
||||||
|
/**
|
||||||
|
* 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。
|
||||||
|
*/
|
||||||
|
firstItemId: string | null;
|
||||||
|
};
|
||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectThreadFileChange } from './DirectThreadFileChange';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。
|
||||||
|
*
|
||||||
|
* `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。
|
||||||
|
* 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。
|
||||||
|
*
|
||||||
|
* 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`,
|
||||||
|
* 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
||||||
|
*/
|
||||||
|
export type DirectThreadItem =
|
||||||
|
| {
|
||||||
|
itemType: 'message';
|
||||||
|
/**
|
||||||
|
* 归一身份:全链路只有这一个 id。
|
||||||
|
*/
|
||||||
|
itemId: string;
|
||||||
|
/**
|
||||||
|
* 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
text: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| { itemType: 'reasoning'; itemId: string; text: string; at: number }
|
||||||
|
| {
|
||||||
|
itemType: 'function_call';
|
||||||
|
itemId: string;
|
||||||
|
name: string;
|
||||||
|
arguments: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemType: 'function_call_output';
|
||||||
|
itemId: string;
|
||||||
|
output: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemType: 'commandExecution';
|
||||||
|
itemId: string;
|
||||||
|
command: string;
|
||||||
|
output: string | null;
|
||||||
|
/**
|
||||||
|
* app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / …
|
||||||
|
*/
|
||||||
|
status: string | null;
|
||||||
|
exitCode: number | null;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemType: 'fileChange';
|
||||||
|
itemId: string;
|
||||||
|
changes: Array<DirectThreadFileChange>;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemType: 'mcpToolCall';
|
||||||
|
itemId: string;
|
||||||
|
tool: string;
|
||||||
|
arguments: string;
|
||||||
|
output: string | null;
|
||||||
|
status: string | null;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
itemType: 'webSearch';
|
||||||
|
itemId: string;
|
||||||
|
query: string | null;
|
||||||
|
output: string | null;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
| { itemType: 'contextCompaction'; itemId: string; at: number }
|
||||||
|
| { itemType: 'other'; itemId: string; rawType: string; at: number };
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
||||||
|
*/
|
||||||
|
export type DirectThreadRequestKind =
|
||||||
|
| 'approval.requested'
|
||||||
|
| 'ask.requested'
|
||||||
|
| 'request.resolved';
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||||
|
import type { DirectThreadEvent } from './DirectThreadEvent';
|
||||||
|
|
||||||
|
export type DirectThreadSubscriptionBootstrap = {
|
||||||
|
subscriptionId: string;
|
||||||
|
/**
|
||||||
|
* 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
||||||
|
*/
|
||||||
|
lastCompletedItemId: string | null;
|
||||||
|
/**
|
||||||
|
* 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。
|
||||||
|
*/
|
||||||
|
events: Array<DirectThreadEvent>;
|
||||||
|
};
|
||||||
@@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem';
|
|||||||
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem';
|
||||||
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
export type { DirectCodexUserRole } from './DirectCodexUserRole';
|
||||||
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart';
|
||||||
|
export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult';
|
||||||
|
export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||||
|
export type { DirectThreadEvent } from './DirectThreadEvent';
|
||||||
|
export type { DirectThreadFileChange } from './DirectThreadFileChange';
|
||||||
|
export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice';
|
||||||
|
export type { DirectThreadItem } from './DirectThreadItem';
|
||||||
|
export type { DirectThreadRequestKind } from './DirectThreadRequestKind';
|
||||||
|
export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap';
|
||||||
|
|||||||
+10
-12
@@ -1,7 +1,5 @@
|
|||||||
import type {
|
import type { GameCreatorDirectToolCallKind } from '../../app/types';
|
||||||
GameCreatorDirectToolCall,
|
import type { DirectChatToolCard } from './directThreadChat';
|
||||||
GameCreatorDirectToolCallKind,
|
|
||||||
} from '../../app/types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
|
* 工具调用折叠块的纯文案计算:汇总 / 行文案。
|
||||||
@@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial<
|
|||||||
|
|
||||||
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
|
/** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */
|
||||||
export function toolCallGroupSummary(
|
export function toolCallGroupSummary(
|
||||||
calls: GameCreatorDirectToolCall[],
|
calls: DirectChatToolCard[],
|
||||||
running = false,
|
running = false,
|
||||||
) {
|
) {
|
||||||
const counts = new Map<string, number>();
|
const counts = new Map<string, number>();
|
||||||
@@ -77,7 +75,7 @@ export function toolCallGroupSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
|
/** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */
|
||||||
export function toolCallRowText(call: GameCreatorDirectToolCall) {
|
export function toolCallRowText(call: DirectChatToolCard) {
|
||||||
// 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达
|
// 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达
|
||||||
// (执行中 / 已执行 / 失败),前缀会和它重复。
|
// (执行中 / 已执行 / 失败),前缀会和它重复。
|
||||||
if (call.kind === 'context_compaction') {
|
if (call.kind === 'context_compaction') {
|
||||||
@@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) {
|
|||||||
return toolCallRowSummary(call);
|
return toolCallRowSummary(call);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallRowSummary(call: GameCreatorDirectToolCall) {
|
function toolCallRowSummary(call: DirectChatToolCard) {
|
||||||
if (call.kind === 'command') {
|
if (call.kind === 'command') {
|
||||||
// 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。
|
// 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。
|
||||||
const script = windowsPowerShellCommandBody(
|
const script = windowsPowerShellCommandBody(
|
||||||
@@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */
|
/** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */
|
||||||
export function toolCallInputText(call: GameCreatorDirectToolCall) {
|
export function toolCallInputText(call: DirectChatToolCard) {
|
||||||
const input = call.detail.command?.trim() ?? '';
|
const input = call.detail.command?.trim() ?? '';
|
||||||
return call.kind === 'command'
|
return call.kind === 'command'
|
||||||
? (windowsPowerShellCommandBody(input) ?? input)
|
? (windowsPowerShellCommandBody(input) ?? input)
|
||||||
@@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) {
|
|||||||
* 这两种情况不显示耗时,不显示 `0s` / 负数。
|
* 这两种情况不显示耗时,不显示 `0s` / 负数。
|
||||||
*/
|
*/
|
||||||
export function toolCallDurationMs(
|
export function toolCallDurationMs(
|
||||||
call: Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>,
|
call: Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>,
|
||||||
): number | null {
|
): number | null {
|
||||||
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0;
|
||||||
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0;
|
||||||
@@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) {
|
|||||||
|
|
||||||
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
|
/** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */
|
||||||
export function turnToolCallDurationMs(
|
export function turnToolCallDurationMs(
|
||||||
calls: Array<Pick<GameCreatorDirectToolCall, 'startedAt' | 'updatedAt'>>,
|
calls: Array<Pick<DirectChatToolCard, 'startedAt' | 'updatedAt'>>,
|
||||||
): number | null {
|
): number | null {
|
||||||
let minStartedAt = Number.POSITIVE_INFINITY;
|
let minStartedAt = Number.POSITIVE_INFINITY;
|
||||||
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
|
let maxUpdatedAt = Number.NEGATIVE_INFINITY;
|
||||||
@@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) {
|
|||||||
|
|
||||||
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
|
/** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */
|
||||||
export function turnToolCallEndedAt(
|
export function turnToolCallEndedAt(
|
||||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
|
||||||
) {
|
) {
|
||||||
let maxUpdatedAt = 0;
|
let maxUpdatedAt = 0;
|
||||||
for (const call of calls) {
|
for (const call of calls) {
|
||||||
@@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) {
|
|||||||
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
|
* 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。
|
||||||
*/
|
*/
|
||||||
export function turnToolCallTimeLabel(
|
export function turnToolCallTimeLabel(
|
||||||
calls: Array<Pick<GameCreatorDirectToolCall, 'updatedAt'>>,
|
calls: Array<Pick<DirectChatToolCard, 'updatedAt'>>,
|
||||||
userSentAt: number | null | undefined,
|
userSentAt: number | null | undefined,
|
||||||
) {
|
) {
|
||||||
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
|
const endLabel = formatClockTime(turnToolCallEndedAt(calls));
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
emptyDirectThreadChatState,
|
||||||
|
mergeDirectHistoryItems,
|
||||||
|
reduceDirectThreadEvents,
|
||||||
|
resolveDirectThreadBootstrap,
|
||||||
|
selectDirectChatEntries,
|
||||||
|
} from '../src/features/project-workspace/directThreadChat';
|
||||||
|
import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents';
|
||||||
|
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||||
|
|
||||||
|
function event(
|
||||||
|
partial: Pick<DirectThreadEvent, 'type'> & Partial<DirectThreadEvent>,
|
||||||
|
): DirectThreadEvent {
|
||||||
|
return partial as DirectThreadEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */
|
||||||
|
function toolStarted(
|
||||||
|
overrides: Partial<
|
||||||
|
Extract<DirectThreadItem, { itemType: 'function_call' }>
|
||||||
|
> = {},
|
||||||
|
): DirectThreadItem {
|
||||||
|
return {
|
||||||
|
itemType: 'function_call',
|
||||||
|
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||||
|
name: 'exec_command',
|
||||||
|
arguments: '{"cmd": "ls"}',
|
||||||
|
at: 1000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */
|
||||||
|
function toolOutput(): DirectThreadItem {
|
||||||
|
return {
|
||||||
|
itemType: 'function_call_output',
|
||||||
|
itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473',
|
||||||
|
output: 'assets\ngame',
|
||||||
|
at: 2000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageItem(
|
||||||
|
overrides: Partial<Extract<DirectThreadItem, { itemType: 'message' }>> = {},
|
||||||
|
): DirectThreadItem {
|
||||||
|
return {
|
||||||
|
itemType: 'message',
|
||||||
|
itemId: 'msg-1',
|
||||||
|
role: 'assistant',
|
||||||
|
text: '你好',
|
||||||
|
at: 3000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DirectProject 聊天 reducer', () => {
|
||||||
|
it('生命周期事件只切换"是否还在跑",不带回合身份', () => {
|
||||||
|
const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||||
|
event({ type: 'turn.started' }),
|
||||||
|
]);
|
||||||
|
expect(started.turnRunning).toBe(true);
|
||||||
|
|
||||||
|
const completed = reduceDirectThreadEvents(started, [
|
||||||
|
event({ type: 'turn.completed', status: 'completed' }),
|
||||||
|
]);
|
||||||
|
expect(completed.turnRunning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => {
|
||||||
|
const bootstrapped = resolveDirectThreadBootstrap(
|
||||||
|
emptyDirectThreadChatState(),
|
||||||
|
{
|
||||||
|
subscriptionId: 'sub-1',
|
||||||
|
lastCompletedItemId: 'msg-9',
|
||||||
|
events: [
|
||||||
|
event({ type: 'turn.started' }),
|
||||||
|
event({ type: 'item.started', item: toolStarted() }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(bootstrapped.subscriptionId).toBe('sub-1');
|
||||||
|
expect(bootstrapped.lastCompletedItemId).toBe('msg-9');
|
||||||
|
expect(bootstrapped.turnRunning).toBe(true);
|
||||||
|
expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('增量正文按条目累计,完成快照更长时覆盖同一段', () => {
|
||||||
|
const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||||
|
event({
|
||||||
|
type: 'item.delta',
|
||||||
|
itemId: 'msg-1',
|
||||||
|
kind: 'message',
|
||||||
|
delta: '你',
|
||||||
|
}),
|
||||||
|
event({
|
||||||
|
type: 'item.delta',
|
||||||
|
itemId: 'msg-1',
|
||||||
|
kind: 'message',
|
||||||
|
delta: '好',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(selectDirectChatEntries(streamed)).toHaveLength(1);
|
||||||
|
expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好');
|
||||||
|
|
||||||
|
const done = reduceDirectThreadEvents(streamed, [
|
||||||
|
event({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: messageItem({ text: '你好,我是陶泥儿。' }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const entries = selectDirectChatEntries(done);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.text).toBe('你好,我是陶泥儿。');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('思考增量按 reasoning 条目累计,不混进助手文本', () => {
|
||||||
|
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||||
|
event({
|
||||||
|
type: 'item.delta',
|
||||||
|
itemId: 'reason-1',
|
||||||
|
kind: 'reasoning',
|
||||||
|
delta: '先看目录',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const entries = selectDirectChatEntries(state);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.kind).toBe('reasoning');
|
||||||
|
expect(entries[0]?.role).toBeNull();
|
||||||
|
expect(entries[0]?.text).toBe('先看目录');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => {
|
||||||
|
const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||||
|
event({ type: 'item.started', item: toolStarted() }),
|
||||||
|
event({ type: 'item.completed', item: toolOutput() }),
|
||||||
|
]);
|
||||||
|
const entries = selectDirectChatEntries(state);
|
||||||
|
expect(entries).toHaveLength(1);
|
||||||
|
expect(entries[0]?.itemId).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 running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [
|
||||||
|
event({ type: 'turn.started' }),
|
||||||
|
event({ type: 'item.completed', item: messageItem() }),
|
||||||
|
event({ type: 'item.started', item: toolStarted() }),
|
||||||
|
]);
|
||||||
|
expect(running.live).toHaveLength(2);
|
||||||
|
|
||||||
|
const done = reduceDirectThreadEvents(running, [
|
||||||
|
event({ type: 'turn.completed', status: 'completed' }),
|
||||||
|
]);
|
||||||
|
expect(done.live).toHaveLength(0);
|
||||||
|
expect(done.history).toHaveLength(2);
|
||||||
|
expect(selectDirectChatEntries(done)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('历史切片搬运层不合并,合并发生在前端投影', () => {
|
||||||
|
const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||||
|
toolStarted(),
|
||||||
|
toolOutput(),
|
||||||
|
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
||||||
|
]);
|
||||||
|
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',
|
||||||
|
item: messageItem({
|
||||||
|
itemId: 'sys-1',
|
||||||
|
role: 'system',
|
||||||
|
text: '内部指令',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
event({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: {
|
||||||
|
itemType: 'other',
|
||||||
|
itemId: 'plan-1',
|
||||||
|
rawType: 'plan',
|
||||||
|
at: 0,
|
||||||
|
} satisfies DirectThreadItem,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(selectDirectChatEntries(state)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => {
|
||||||
|
const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [
|
||||||
|
toolStarted(),
|
||||||
|
messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }),
|
||||||
|
]);
|
||||||
|
const live = reduceDirectThreadEvents(historical, [
|
||||||
|
event({ type: 'item.completed', item: toolOutput() }),
|
||||||
|
]);
|
||||||
|
const entries = selectDirectChatEntries(live);
|
||||||
|
expect(entries).toHaveLength(2);
|
||||||
|
expect(entries[0]?.toolCall?.status).toBe('completed');
|
||||||
|
expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
directThreadHistoryItemsToMessages,
|
directThreadHistoryItemsToMessages,
|
||||||
isDirectTurnInProgress,
|
isDirectTurnInProgress,
|
||||||
} from '../src/features/project-workspace/directThreadEvents';
|
} from '../src/features/project-workspace/directThreadEvents';
|
||||||
|
import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection';
|
||||||
|
|
||||||
describe('Direct 回合状态与历史时间', () => {
|
describe('Direct 回合状态与历史时间', () => {
|
||||||
it('终态和空状态不恢复为活动回合', () => {
|
it('终态和空状态不恢复为活动回合', () => {
|
||||||
@@ -20,20 +21,28 @@ describe('Direct 回合状态与历史时间', () => {
|
|||||||
expect(isDirectTurnInProgress(status)).toBe(true);
|
expect(isDirectTurnInProgress(status)).toBe(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => {
|
it('条目时间来自搬运后的 at,前端不自己补造时间', () => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
type: 'message',
|
itemId: 'direct-codex:turn:user',
|
||||||
|
itemType: 'message',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
id: 'direct-codex:turn:user',
|
text: '帮我修改游戏',
|
||||||
content: [{ type: 'input_text', text: '帮我修改游戏' }],
|
at: 1_800_000_000_001,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 };
|
itemId: 'direct-codex:turn:assistant',
|
||||||
expect(
|
itemType: 'message',
|
||||||
directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt,
|
role: 'assistant',
|
||||||
).toBe(1_800_000_000_001);
|
text: '好的',
|
||||||
expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0);
|
at: 0,
|
||||||
|
},
|
||||||
|
] satisfies DirectThreadItem[];
|
||||||
|
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');
|
expect(items[0]).not.toHaveProperty('recordedAt');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
|
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
|
||||||
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。
|
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。
|
||||||
- [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。
|
- [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。
|
||||||
|
- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。
|
||||||
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
|
- [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。
|
||||||
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
||||||
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
|
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user