保留唯一的thread manager作为direct project的状态来源 (#384)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 6m21s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m51s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m3s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 5m54s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 4m28s
Project CI / AI game creator shell Rust crates (push) Successful in 1m53s
Project CI / Repository checks (push) Successful in 5m33s
Project CI / Frontend tests (push) Successful in 7m20s
Project CI / Native shell tests (push) Successful in 8m29s
Project CI / Backend tests (push) Successful in 8m57s
Project CI / AI game creator shell web tests (push) Successful in 3m16s

说明: 在把工作交给段哥前还没有实现direct project聊天页面的迁移, 导致在 #375 里用很复杂的实现又做了一套事件流, 实测还有会话丢失的bug, 我在这里把数据获取的部分迁移到 #367 上

---------

Co-authored-by: 孔令弘 <ink29535@proton.me>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/384
Reviewed-by: 孔令弘 <ink29535@proton.me>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #384.
This commit is contained in:
2026-09-18 02:13:50 +08:00
committed by 孔令弘
parent 1c6e4c1e74
commit 30c377d0f0
58 changed files with 5997 additions and 4077 deletions
+16
View File
@@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器
Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。
_Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
## 项目开发对话(DirectProject
**项目对话历史**:
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
**运行态事件**:
Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。
_Avoid_: 进度通知、快照轮询、第二套历史
**聊天投影**:
把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。
_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer
## Relationships
- 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**
@@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
- “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。
- “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。
- “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。
- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**
- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。
@@ -21,6 +21,7 @@ mod direct_project_history;
mod direct_project_turn_history;
mod direct_runtime;
mod direct_thread_manager;
mod direct_thread_wire;
mod direct_tool_bridge;
mod direct_tool_calls;
mod direct_tools_mcp;
@@ -40,7 +41,8 @@ use codex_app_server::*;
pub(crate) use codex_app_server::{
cancel_direct_codex_turn_at,
direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView,
direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat,
direct_thread_id_for_project, DirectTurnCancelView,
};
use codex_cli::*;
pub(crate) use codex_cli::{
@@ -55,6 +57,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_wire::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tool_calls::*;
pub(crate) use direct_tools_mcp::*;
@@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity(
))
}
/// 项目根目录在 Thread Manager 里的线程身份。
///
/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到
/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有
/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。
///
/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见
/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把
/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个
/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。
pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String {
let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else {
return root.to_string_lossy().into_owned();
};
canonical_root
.to_str()
.and_then(|value| value.strip_prefix(r"\\?\"))
.map(std::path::Path::new)
.unwrap_or(canonical_root.as_path())
.to_string_lossy()
.into_owned()
}
pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec<u8> {
#[cfg(unix)]
{
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,8 @@ mod validation;
mod wire;
pub(crate) use model::{
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope,
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
};
pub(crate) use validation::validate_direct_codex_user_item;
pub(crate) use wire::{
@@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart {
pub(crate) resource_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
pub(crate) struct DirectCodexUserMessageEnvelope {
pub(crate) item: DirectCodexUserItem,
}
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
//! GameAgent 对话「工具调用卡片」的采集持久化与回读
//! GameAgent 对话「工具调用卡片」的采集持久化。
//!
//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更
@@ -8,11 +8,9 @@
//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的
//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。
use crate::agent::redact_secret_tokens;
use crate::agent::sanitize_error_context;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use super::direct_thread_wire::sanitize_detail_text;
use crate::config::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;
@@ -24,7 +22,7 @@ use std::path::{Path, PathBuf};
pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item";
/// 条目 schema 版本。
pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1";
/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。
pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200;
/// `detail.command` / `detail.output` 的字符上限。
const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000;
@@ -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 {
@@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec<DirectToolCall>) -> Vec<DirectToolCall> {
normalized
}
/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。
pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result<Vec<DirectToolCall>, String> {
let path = tool_calls_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? {
return Ok(Vec::new());
}
Ok(normalize_tool_calls(read_tool_call_lines(&path)))
}
/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。
fn status_certainty(status: &str) -> u8 {
match status {
@@ -684,10 +545,22 @@ mod tests {
use super::{
direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status,
direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at,
read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall,
DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION,
read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path,
DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT,
DIRECT_TOOL_CALL_SCHEMA_VERSION,
};
use serde_json::json;
use std::path::Path;
/// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪,
/// 断言因此落在「磁盘上到底写了什么」这一层。
fn persisted_tool_calls(root: &Path) -> Vec<DirectToolCall> {
std::fs::read_to_string(tool_calls_path(root))
.unwrap_or_default()
.lines()
.filter_map(tool_call_from_line)
.collect()
}
/// 一行合法的落盘信封(回读用例的夹具)。
fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String {
@@ -812,7 +685,7 @@ mod tests {
.expect("completed tool call");
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed");
let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls");
let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(calls[0].status, "completed");
assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖");
@@ -912,85 +785,6 @@ mod tests {
);
}
/// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。
#[test]
fn tool_call_read_skips_corrupted_lines() {
let root = init_tool_call_project("tool-call-corrupt");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let good = serde_json::to_string(&json!({
"type": "tool_call_item",
"payload": {
"schemaVersion": "agc-tool-call.v1",
"id": "item-good",
"turnId": "turn-1",
"kind": "command",
"title": "执行命令",
"summary": "npm run build",
"status": "completed",
"detail": {"command": "npm run build"},
"startedAt": 1,
"updatedAt": 2
}
}))
.expect("serialize good row");
std::fs::write(
&path,
format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"),
)
.expect("write fixture");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_direct_tool_calls_at(missing.path())
.expect("missing file is empty")
.is_empty(),
"历史文件缺失必须返回空数组"
);
let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines");
assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条");
assert_eq!(calls[0].id, "item-good");
}
/// 判据:回读按时间正序,且超出上限时保留最新。
#[test]
fn tool_call_read_is_ordered_and_capped() {
let root = init_tool_call_project("tool-call-cap");
let total = DIRECT_TOOL_CALL_LIMIT + 5;
let calls = (0..total)
.map(|index| {
direct_tool_call_from_item(
root.path(),
&json!({
"id": format!("item-{index:04}"),
"type": "commandExecution",
"command": format!("run {index}"),
"startedAtMs": 1000 + index as u64,
}),
"turn-1",
false,
1000 + index as u64,
)
.expect("tool call")
})
.collect::<Vec<_>>();
persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条");
assert_eq!(
read.first().expect("first").id,
format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT),
"最早被裁掉的是最旧的条目"
);
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
);
}
/// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。
#[test]
fn tool_call_file_change_title_counts_unique_paths() {
@@ -1189,7 +983,7 @@ mod tests {
persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first");
persist_direct_tool_call_at(root.path(), &running).expect("persist stale running");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write");
let calls = persisted_tool_calls(root.path());
assert_eq!(calls.len(), 1, "同一 id 只能有一行");
assert_eq!(
calls[0].status, "completed",
@@ -1201,7 +995,7 @@ mod tests {
// 回合末整批落盘那条路径同样不得回退。
persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running))
.expect("persist stale running batch");
let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write");
let calls = persisted_tool_calls(root.path());
assert_eq!(
calls[0].status, "completed",
"整批落盘路径同样不得把 completed 打回 running"
@@ -1209,28 +1003,6 @@ mod tests {
assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt");
}
/// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。
#[test]
fn tool_call_read_merges_duplicate_rows_monotonically() {
let root = init_tool_call_project("tool-call-read-monotonic");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
let completed = tool_call_row("item-1", 1000, 2000);
let stale_running = tool_call_row("item-1", 1000, 1000)
.replace("\"status\":\"completed\"", "\"status\":\"running\"");
assert!(stale_running.contains("\"status\":\"running\""));
std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows");
assert_eq!(calls.len(), 1, "同 id 归并成一条");
assert_eq!(
calls[0].status, "completed",
"磁盘上更旧的快照不得把状态打回 running"
);
assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt");
assert_eq!(calls[0].started_at, 1000);
}
/// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。
#[test]
fn tool_call_paths_become_project_relative() {
@@ -1283,9 +1055,10 @@ mod tests {
);
}
/// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回
/// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。
#[test]
fn tool_call_read_skips_invalid_utf8_line() {
fn tool_call_pre_read_skips_invalid_utf8_line() {
let root = init_tool_call_project("tool-call-invalid-utf8");
let path = tool_calls_path(root.path());
std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir");
@@ -1298,7 +1071,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes());
bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write invalid utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8");
let calls = read_tool_call_lines(&path);
assert_eq!(
calls.len(),
2,
@@ -1318,7 +1091,7 @@ mod tests {
bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes());
bytes.push(b'\n');
std::fs::write(&path, &bytes).expect("write truncated utf8 fixture");
let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line");
let calls = read_tool_call_lines(&path);
assert_eq!(
calls.len(),
2,
@@ -1326,10 +1099,16 @@ mod tests {
);
assert_eq!(calls[0].id, "item-a");
assert_eq!(calls[1].id, "item-c");
let missing = tempfile::tempdir().expect("missing dir");
assert!(
read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(),
"历史文件缺失时写前读取必须返回空表"
);
}
/// 判据:200 条上限「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃
/// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
/// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的
/// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。
#[test]
fn tool_call_cap_drops_oldest_turn_cards() {
let root = init_tool_call_project("tool-call-cap-oldest");
@@ -1366,7 +1145,7 @@ mod tests {
.expect("newest tool call");
persist_direct_tool_call_at(root.path(), &newest).expect("persist newest");
let read = read_direct_tool_calls_at(root.path()).expect("read capped");
let read = persisted_tool_calls(root.path());
assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条");
assert_eq!(
read.last().expect("last").id,
@@ -1381,7 +1160,7 @@ mod tests {
assert!(
read.windows(2)
.all(|pair| pair[0].timestamp() <= pair[1].timestamp()),
"回读必须按时间正序"
"落盘顺序必须按时间正序"
);
}
@@ -1,4 +1,4 @@
//! GameAgent 对话「回合流」的采集持久化与回读
//! GameAgent 对话「回合流」的采集持久化。
//!
//! 顺序真相放在一处:`<projectRoot>/.agent/conversations/turn-stream.jsonl` 按**出现顺序**
//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文
@@ -11,7 +11,7 @@
//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。
use crate::agent::sanitize_detail_text;
use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file};
use crate::config::write_game_creator_private_file;
use crate::project::{enforce_project_permission_policy, project_append_lock_for};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at(
})
}
/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。
pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result<Vec<DirectTurnStreamItem>, String> {
let path = turn_stream_path(root);
if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? {
return Ok(Vec::new());
}
Ok(normalize_stream_items(read_stream_lines(&path)))
}
/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。
///
/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾
@@ -1,7 +1,7 @@
use super::*;
use crate::agent::{
direct_codex_canonical_project_identity, read_direct_project_chat_history_at,
read_direct_project_last_item_id_at,
read_direct_project_chat_history_at, read_direct_project_last_item_id_at,
DirectProjectHistoryAnchor,
};
use crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256};
@@ -5678,31 +5678,6 @@ pub(crate) async fn read_agent_runtime_error_detail(
.await
.map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_tool_calls(
project_path: String,
) -> Result<Vec<DirectToolCall>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_tool_calls_at(root)
})
.await
.map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) async fn read_direct_turn_stream(
project_path: String,
) -> Result<Vec<DirectTurnStreamItem>, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_direct_turn_stream_at(root)
})
.await
.map_err(|error| format!("读取回合流历史后台任务失败:{error}"))?
}
#[tauri::command]
pub(crate) fn list_game_creator_direct_active_turns(
@@ -5717,13 +5692,7 @@ pub(crate) async fn subscribe_direct_project_thread(
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let (canonical_root, _) = direct_codex_canonical_project_identity(root)?;
let thread_root = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
.map(Path::new)
.unwrap_or(canonical_root.as_path());
let thread_id = thread_root.to_string_lossy().into_owned();
let thread_id = direct_thread_id_for_project(root);
let mut bootstrap = subscribe_direct_thread(&thread_id);
if bootstrap.last_completed_item_id.is_none() {
bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?;
@@ -5741,34 +5710,43 @@ pub(crate) fn consume_direct_project_thread(
consume_direct_thread(subscription_id.trim())
}
/// 读一屏项目对话历史。
///
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的
/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。
#[tauri::command]
pub(crate) async fn read_direct_project_history_slice(
project_path: String,
before_item_id: Option<String>,
through_item_id: Option<String>,
limit: Option<usize>,
messages_only: Option<bool>,
) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
let read_slice = if messages_only.unwrap_or(false) {
read_direct_project_chat_items_slice_at
} else {
read_direct_project_history_items_slice_at
let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) {
(Some(_), Some(_)) => {
return Err(
"DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId"
.to_string(),
)
}
(Some(before), None) => DirectProjectHistoryAnchor::Before(before),
(None, Some(through)) => DirectProjectHistoryAnchor::Through(through),
(None, None) => DirectProjectHistoryAnchor::Newest,
};
let (items, has_more, item_timestamps) =
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
let oldest_item_id = items
.first()
.and_then(|item| item.get("id"))
.and_then(serde_json::Value::as_str)
.filter(|id| !id.is_empty())
.map(str::to_string);
let (raw_items, has_more, recorded_at_ms, first_item_id) =
read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
let items = direct_thread_items_from_history(root, &raw_items, |item| {
direct_thread_item_identity(item)
.and_then(|identity| recorded_at_ms.get(&identity).copied())
.unwrap_or_default()
});
Ok(DirectThreadHistorySlice {
items,
has_more,
item_timestamps,
oldest_item_id,
first_item_id,
})
})
.await
@@ -2647,8 +2647,6 @@ fn main() {
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_direct_tool_calls,
read_direct_turn_stream,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
export const CONVERSATION_VISIBLE_STEP = 20;
/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */
export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5;
export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor';
/**
+3 -90
View File
@@ -999,8 +999,6 @@ export interface ChatMessage {
agentId?: string | null;
updatedAt?: number;
runtimeOwned?: boolean;
/** 来自历史回读,不作为尚未落盘的实时消息追加到新历史页末尾。 */
fromHistory?: boolean;
}
export type DesignAgentInput =
@@ -1079,27 +1077,6 @@ export interface AgentProgressEvent {
message: string;
}
export type GameCreatorDirectTurnUpdateStatus =
| 'accepted'
| 'running'
| 'streaming'
| 'finalizing'
| 'completed'
| 'failed';
export type GameCreatorDirectTurnActivity =
| 'request-accepted'
| 'preparing'
| 'file-read'
| 'file-write'
| 'game-verify'
| 'command-exec'
| 'controlled-tool'
| 'web-search'
| 'context-compaction'
| 'response-finalization'
| 'none';
export type GameCreatorDirectToolCallKind =
| 'command'
| 'file_change'
@@ -1127,10 +1104,9 @@ export interface GameCreatorDirectToolCallDetail {
/**
* 一条工具调用(Codex item 的结构化投影)。
*
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`
* 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件
* `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于
* 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。
* 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`字段形状与
* Rust 侧 `DirectToolCall`、独立历史文件 `.agent/conversations/tool-calls.jsonl` 的 payload
* 一致(DirectProject 聊天卡片用 `Omit<GameCreatorDirectToolCall, 'turnId'>` 这一变体)。
*/
export interface GameCreatorDirectToolCall {
schemaVersion: string;
@@ -1145,69 +1121,6 @@ export interface GameCreatorDirectToolCall {
updatedAt: number;
}
/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */
export type GameCreatorDirectTurnToolCall = Omit<
GameCreatorDirectToolCall,
'turnId'
>;
export interface GameCreatorDirectTurnUpdateEvent {
projectPath: string;
turnId: string;
sequence: number;
status: GameCreatorDirectTurnUpdateStatus;
activity?: GameCreatorDirectTurnActivity | null;
accumulatedText?: string | null;
/**
* 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。
* 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。
*/
toolCalls?: GameCreatorDirectTurnToolCall[] | null;
/**
* 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。
*/
reasoningText?: string | null;
/**
* 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。
*
* 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据,
* 前端不再自己猜切点。可选:老版本事件没有这个字段。
*/
streamItems?: TurnStreamItem[] | null;
updatedAt: number;
}
/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */
export interface TurnStreamTextItem extends TurnStreamItemBase {
kind: 'text';
text: string;
}
/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */
export interface TurnStreamToolItem extends TurnStreamItemBase {
kind: 'tool';
callId: string;
}
interface TurnStreamItemBase {
schemaVersion: string;
/** 幂等身份:文本段 `text:<turnId>:<itemId>`、工具 `tool:<turnId>:<callId>`。 */
id: string;
turnId: string;
/** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */
seq: number;
/** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */
at: number;
updatedAt: number;
}
/**
* 回合流条目(`read_direct_turn_stream` 的返回元素)。
*
* 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。
*/
export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem;
/** `cancel_direct_codex_turn` 的返回值。 */
export interface DirectTurnCancelView {
/**
File diff suppressed because it is too large Load Diff
@@ -80,6 +80,7 @@ import {
type ChatComposerDraft,
type ChatReference,
chatReferenceListKey,
chatReferenceToContentPart,
currentIterationVersionAssets,
dedupeChatReferences,
refreshResourceReference,
@@ -203,25 +204,7 @@ function collectDraftParts(
if ($isResourceReferenceNode(node)) {
textParts.push(`@${node.__reference.label}`);
references.push(node.__reference);
content.push(
node.__reference.type === 'resource'
? {
type: 'agc_resource_reference',
resourceId: node.__reference.resourceId,
}
: {
type: 'agc_runtime_region_reference',
label: node.__reference.label,
runId: node.__reference.runId,
versionId: node.__reference.versionId,
elementTag: node.__reference.elementTag,
elementRole: node.__reference.elementRole,
text: node.__reference.text,
width: node.__reference.width,
height: node.__reference.height,
resourceIds: node.__reference.resourceIds,
},
);
content.push(chatReferenceToContentPart(node.__reference));
return;
}
if ($isElementNode(node)) {
@@ -9,7 +9,7 @@ import {
import { useEffect, useId, useState } from 'react';
import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent';
import type { GameCreatorDirectToolCall } from '../../app/types';
import type { DirectChatToolCard } from './directThreadChat';
import {
formatToolCallDuration,
formatTurnDuration,
@@ -38,7 +38,7 @@ export function ToolCallGroup({
active = false,
className,
}: {
calls: GameCreatorDirectToolCall[];
calls: DirectChatToolCard[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
/**
@@ -169,7 +169,7 @@ function ToolCallRow({
call,
active,
}: {
call: GameCreatorDirectToolCall;
call: DirectChatToolCard;
active: boolean;
}) {
const [expanded, setExpanded] = useState(false);
@@ -0,0 +1,59 @@
/**
* 首屏历史锚点闸门:把「订阅回执里的 `lastCompletedItemId`」交给「首屏切片读取」。
*
* 这个 id 是订阅那一刻最后一条已完成条目,也是历史切片与运行态事件的唯一分界:首屏切片只
* 允许取到它为止(含该条),比它更新的条目只能来自运行态事件。回执到达之前首屏必须等它,
* 不能退化成"取文件尾"——那会把回执之后才完成的条目也拉进历史,与运行态事件重叠。
*
* 生命周期:打开项目时首屏读取先开一道闸门(订阅 effect 还没跑),订阅侧复用同一道闸门并在
* 回执到达后 `settle` 它;订阅不可用 / 失败 / 切项目时 settle 成 `null`,首屏退化成取文件尾。
* 一道闸门只服务这次订阅的第一次首屏读取,用过之后置 `consumed`。
*/
export type DirectHistoryAnchorGate = {
projectPath: string;
anchor: Promise<string | null>;
settle: (anchor: string | null) => void;
/** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */
consumed: boolean;
};
/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */
export function openDirectHistoryAnchorGate(
projectPath: string,
): DirectHistoryAnchorGate {
let settle: (anchor: string | null) => void = () => {};
const anchor = new Promise<string | null>((resolve) => {
settle = resolve;
});
return { projectPath, anchor, settle, consumed: false };
}
/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */
export function reuseOrOpenDirectHistoryAnchorGate(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate {
if (current && current.projectPath === projectPath) {
return current;
}
return openDirectHistoryAnchorGate(projectPath);
}
/**
* 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。
*
* 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过
* (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。
*/
export function directHistoryAnchorGateToWaitFor(
current: DirectHistoryAnchorGate | null,
projectPath: string,
): DirectHistoryAnchorGate | null {
const sameProjectGate =
current && current.projectPath === projectPath ? current : null;
if (!sameProjectGate) {
return openDirectHistoryAnchorGate(projectPath);
}
return sameProjectGate.consumed ? null : sameProjectGate;
}
@@ -0,0 +1,84 @@
/**
* DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。
*
* 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏,
* 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后
* 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。
*
* 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。
*/
import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants';
import {
type DirectChatEntry,
mergeHistoryEntries,
projectDirectHistoryItems,
} from './directThreadChat';
import type {
DirectThreadHistorySlice,
DirectThreadItem,
} from './directThreadEvents';
import { buildDirectChatTurns } from './directTurnPresentation';
export type DirectHistoryPages = {
/** 累积到的条目,保持文件顺序(旧 → 新)。 */
items: DirectThreadItem[];
/** 后端声明的"还有更早的历史";取不动时收口为 false。 */
hasMore: boolean;
/** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */
firstItemId: string | null;
/** 中断原因;`null` 表示正常停止。 */
error: unknown;
};
function visibleTurnCount(entries: readonly DirectChatEntry[]): number {
return buildDirectChatTurns({ entries }).length;
}
/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */
function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) {
return [...pages].reverse().flat();
}
export async function readDirectHistoryPages({
existingEntries,
beforeItemId,
readSlice,
}: {
/** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */
existingEntries: readonly DirectChatEntry[];
beforeItemId: string | null;
readSlice: (beforeItemId: string | null) => Promise<DirectThreadHistorySlice>;
}): Promise<DirectHistoryPages> {
const baseTurnCount = visibleTurnCount(existingEntries);
const pages: DirectThreadItem[][] = [];
let anchor = beforeItemId;
let hasMore = false;
let error: unknown = null;
for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) {
let slice: DirectThreadHistorySlice;
try {
slice = await readSlice(anchor);
} catch (thrown) {
// 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。
error = thrown;
break;
}
pages.push([...slice.items]);
hasMore = slice.hasMore;
const nextAnchor = slice.firstItemId;
if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) {
// 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。
hasMore = false;
break;
}
anchor = nextAnchor;
if (!hasMore) break;
const merged = mergeHistoryEntries(
projectDirectHistoryItems(inFileOrder(pages)),
existingEntries,
);
if (visibleTurnCount(merged) > baseTurnCount) break;
}
return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error };
}
@@ -0,0 +1,272 @@
/**
* 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 = {
/** 最新回合是否还在跑;只由生命周期事件的先后决定。 */
turnRunning: boolean;
/** 历史切片条目,保持文件顺序。 */
history: DirectChatEntry[];
/** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */
live: DirectChatEntry[];
};
export function emptyDirectThreadChatState(): DirectThreadChatState {
return {
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 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。
*
* 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部
* 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。
*/
export function resolveDirectThreadBootstrap(
state: DirectThreadChatState,
bootstrap: DirectThreadSubscriptionBootstrap,
): DirectThreadChatState {
return reduceDirectThreadEvents(state, 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;
}
/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */
export function projectDirectHistoryItems(
items: readonly DirectThreadItem[],
): DirectChatEntry[] {
return items
.map((item) => projectDirectThreadItem(item))
.filter((entry): entry is DirectChatEntry => Boolean(entry));
}
/**
* 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。
*
* 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。
*/
export function mergeDirectHistoryItems(
state: DirectThreadChatState,
items: readonly DirectThreadItem[],
): DirectThreadChatState {
return {
...state,
history: mergeHistoryEntries(
projectDirectHistoryItems(items),
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,121 +1,17 @@
import type {
ChatMessage,
LocalConversationMessageRecord,
} from '../../app/types';
/**
* DirectProject 运行态事件的线上类型。
*
* 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做
* 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。
*/
export type DirectThreadRawEvent = {
seq: number;
type: string;
turnId: string;
itemId?: string;
payload: Record<string, unknown>;
};
export type DirectThreadSubscriptionBootstrap = {
subscriptionId: string;
lastCompletedItemId: string | null;
events: DirectThreadRawEvent[];
};
export type DirectThreadConsumeResult = {
events: DirectThreadRawEvent[];
};
export type DirectThreadHistorySlice = {
items: unknown[];
hasMore: boolean;
itemTimestamps?: Record<string, number>;
oldestItemId?: string | null;
};
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
export function directThreadHistoryPage(
slice: DirectThreadHistorySlice,
previousCursor: string | null = null,
) {
const first = slice.items[0];
const firstId =
first && typeof first === 'object' && 'id' in first
? (first as { id?: unknown }).id
: null;
const cursor =
slice.oldestItemId ??
(typeof firstId === 'string' && firstId ? firstId : null);
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
}
return {
messages: directThreadHistoryItemsToMessages(
slice.items,
slice.itemTimestamps,
),
hasMore: slice.hasMore,
cursor,
};
}
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
export function prependDirectHistoryMessages(
current: readonly ChatMessage[],
older: readonly ChatMessage[],
): ChatMessage[] {
const ids = new Set(
current.flatMap((message) =>
message.messageId ? [message.messageId] : [],
),
);
const additions = older.filter((message) => {
if (!message.messageId) return true;
if (ids.has(message.messageId)) return false;
ids.add(message.messageId);
return true;
});
return [...additions, ...current];
}
export function directThreadHistoryItemsToMessages(
items: unknown[],
itemTimestamps: Readonly<Record<string, number>> = {},
): LocalConversationMessageRecord[] {
return items.flatMap((raw) => {
if (!raw || typeof raw !== 'object') return [];
const item = raw as Record<string, unknown>;
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('')
: '';
if (!content) return [];
const messageId = typeof item.id === 'string' ? item.id : undefined;
return [
{
schemaVersion: 'agc-direct-project-context.v1',
role: messageRole,
content,
agentId: null,
messageId,
updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0,
},
];
});
}
/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */
export function isDirectTurnInProgress(
status: string | null | undefined,
): status is 'accepted' | 'running' | 'streaming' | 'finalizing' {
return (
status === 'accepted' ||
status === 'running' ||
status === 'streaming' ||
status === 'finalizing'
);
}
export type {
DirectThreadConsumeResult,
DirectThreadDeltaKind,
DirectThreadEvent,
DirectThreadFileChange,
DirectThreadHistorySlice,
DirectThreadItem,
DirectThreadRequestKind,
DirectThreadSubscriptionBootstrap,
} from './generated';
@@ -0,0 +1,269 @@
/**
* 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 = typeof item.itemId === 'string' ? 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,
};
}
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More