Merge remote-tracking branch 'origin/master' into feat/adapt-taonier-skill
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
This commit is contained in:
@@ -73,6 +73,83 @@ fn claude_project_key(root: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
|
||||
}
|
||||
|
||||
/// Claude Code 把每个会话的轨迹持久化在 `<config>/projects/<项目目录名>/<sessionId>.jsonl`,
|
||||
/// 目录名由 cwd 派生:非字母数字字符统一替换成 `-`。
|
||||
///
|
||||
/// 实例:`C:\Users\...\projects\gameagent-0514673b` 对应
|
||||
/// `C--Users-...-projects-gameagent-0514673b`。
|
||||
fn claude_code_project_dir_name(root: &Path) -> String {
|
||||
root.to_string_lossy()
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 返回某个会话目录里 mtime 最新的一份 cc 轨迹:`(修改时间, sessionId)`。
|
||||
fn newest_claude_code_session_in(dir: &Path) -> Option<(std::time::SystemTime, String)> {
|
||||
let mut newest: Option<(std::time::SystemTime, String)> = None;
|
||||
for entry in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(std::ffi::OsStr::to_str) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Some(session_id) = path
|
||||
.file_stem()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.filter(|stem| !stem.is_empty())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Ok(modified) = entry.metadata().and_then(|metadata| metadata.modified()) else {
|
||||
continue;
|
||||
};
|
||||
if newest
|
||||
.as_ref()
|
||||
.map(|(at, _)| modified > *at)
|
||||
.unwrap_or(true)
|
||||
{
|
||||
newest = Some((modified, session_id.to_string()));
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
/// 从隔离 home 里找出本项目最近的 cc 会话 id,供 Claude Agent SDK `resume` 使用。
|
||||
///
|
||||
/// 只认本项目目录(目录名由 cwd 派生,`resume` 也只会在那里查这份轨迹):进程内会话表
|
||||
/// 在用户"离开项目再进入"或重启客户端后必然为空,此时必须回退到磁盘,否则 cc 执行器
|
||||
/// 会以全新会话开工,模型看不到任何历史对话。现场:2026-10-07 用户反馈"离开后再进入
|
||||
/// 项目,cc 那边看不到历史对话",项目 `gameagent-0514673b` 的隔离 home 里留下了两份
|
||||
/// 独立轨迹(14:52 与 15:05),第二份的首次用户输入只有一句"现在呢"。
|
||||
fn latest_claude_code_session_id_at(home: &Path, root: &Path) -> Option<String> {
|
||||
let projects = home.join("claude").join("projects");
|
||||
let preferred = projects.join(claude_code_project_dir_name(root));
|
||||
newest_claude_code_session_in(&preferred).map(|(_, session_id)| session_id)
|
||||
}
|
||||
|
||||
/// 解析本回合要恢复的 cc 会话:进程内会话表优先,没有就回退到磁盘上最新的会话轨迹。
|
||||
///
|
||||
/// 返回 `(会话 id, 来源)`,来源只进应用日志,供"cc 看不到历史对话"这类反馈一眼分辨是
|
||||
/// 内存命中、磁盘回放,还是本项目根本没有历史会话。
|
||||
fn resolve_claude_direct_resume(
|
||||
root: &Path,
|
||||
home: &Path,
|
||||
) -> Result<(Option<String>, &'static str), String> {
|
||||
let session_key = claude_project_key(root);
|
||||
let in_memory = claude_direct_sessions()
|
||||
.lock()
|
||||
.map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())?
|
||||
.get(&session_key)
|
||||
.cloned();
|
||||
if let Some(session_id) = in_memory {
|
||||
return Ok((Some(session_id), "memory"));
|
||||
}
|
||||
match latest_claude_code_session_id_at(home, root) {
|
||||
Some(session_id) => Ok((Some(session_id), "disk")),
|
||||
None => Ok((None, "none")),
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -798,6 +875,58 @@ fn configure_claude_code_environment(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 把 Claude Agent SDK 的 `system/api_error`(自动重试)事件投影成一条过程条目。
|
||||
///
|
||||
/// SDK 每次自动重试都会带上真实上游状态与正文,但此前这类事件在 `observe` 里被整段丢弃,
|
||||
/// 结果是重试期间界面只有“正在处理”,直到重试终于成功(或耗尽)才落一条结果——用户看到
|
||||
/// 的现象就是“中间过程没有任何输出”。现场证据(2026-10-07 项目 gameagent-0514673b):
|
||||
/// 14:23:34 起连续 10 次 409「泥点余额不足」重试,14:30:11 才出现唯一一条最终文本,
|
||||
/// CC 会话轨迹里除这 10 条 `system/api_error` 外没有任何 assistant / tool 事件。
|
||||
///
|
||||
/// 返回 `(item_id, text)`:同一 attempt 复用同一个 id,重复事件只更新同一条过程行。
|
||||
fn claude_api_error_retry_item(event: &serde_json::Value) -> Option<(String, String)> {
|
||||
let error = event.get("error")?;
|
||||
let attempt = event
|
||||
.get("retryAttempt")
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
let max_retries = event.get("maxRetries").and_then(serde_json::Value::as_u64);
|
||||
let status = error
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.or_else(|| event.get("status").and_then(serde_json::Value::as_u64));
|
||||
// 优先用 SDK 的 `formatted`(人读形态,例如 `409 泥点余额不足`);它缺失时才回落到
|
||||
// `message`(往往是整段上游 JSON,直接给用户看没有价值)。
|
||||
let detail = error
|
||||
.get("formatted")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.or_else(|| claude_result_error_detail(error))
|
||||
.unwrap_or_default();
|
||||
let detail = match status {
|
||||
Some(status) if detail.starts_with(&format!("{status} ")) => {
|
||||
detail[format!("{status} ").len()..].to_string()
|
||||
}
|
||||
_ => detail,
|
||||
};
|
||||
let detail = crate::agent::redact_agent_runtime_error(
|
||||
Path::new("__agc_no_project_root__"),
|
||||
detail.trim(),
|
||||
300,
|
||||
);
|
||||
let mut text = String::from("上游请求失败");
|
||||
if let Some(status) = status {
|
||||
text.push_str(&format!("(HTTP {status})"));
|
||||
}
|
||||
if !detail.trim().is_empty() {
|
||||
text.push_str(&format!(":{}", detail.trim()));
|
||||
}
|
||||
if let (Some(attempt), Some(max)) = (attempt, max_retries) {
|
||||
text.push_str(&format!(";正在自动重试(第 {attempt}/{max} 次)"));
|
||||
}
|
||||
let item_id = format!("direct-cc:api-error:{}", attempt.unwrap_or(0));
|
||||
Some((item_id, text))
|
||||
}
|
||||
|
||||
fn claude_result_error_detail(value: &serde_json::Value) -> Option<String> {
|
||||
fn value_detail(value: &serde_json::Value) -> Option<String> {
|
||||
match value {
|
||||
@@ -1762,6 +1891,25 @@ impl ClaudeCodeStreamState {
|
||||
Some("thinking_tokens") => {
|
||||
claude_stream_observe(observer, TurnObservation::Reasoning(String::new()));
|
||||
}
|
||||
Some("api_error") => {
|
||||
// 重试/上游失败必须有一条可见的过程行:否则重试期间聊天区完全静默,
|
||||
// 用户只会看到“正在处理”,拿不到 HTTP 状态与上游正文(见本函数上方注释)。
|
||||
if let Some((item_id, text)) = claude_api_error_retry_item(event) {
|
||||
let at = crate::agent::now_ms();
|
||||
crate::agent::append_thread_event(
|
||||
&crate::agent::thread_id_for_project(root),
|
||||
ThreadEvent::item_completed(
|
||||
ThreadItem::Message {
|
||||
item_id,
|
||||
role: "system".to_string(),
|
||||
text,
|
||||
at,
|
||||
},
|
||||
at,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Some("result") => {
|
||||
@@ -1838,6 +1986,46 @@ impl ClaudeCodeStreamState {
|
||||
|
||||
/// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback
|
||||
/// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。
|
||||
/// DirectProject 的 CC 回合请求载荷。
|
||||
///
|
||||
/// 模型的全部工具能力只靠 `mcpServers` 下发:宿主把 loopback MCP 端点交给 Claude Agent
|
||||
/// SDK,SDK 再以 `mcp__agc__*` 暴露给模型。载荷一旦漏掉这项,模型就退化成"没有任何工具、
|
||||
/// 只能把代码贴在聊天里"的纯聊天执行器——现场证据:2026-10-07 安装版 0.1.227 的 CC 回合,
|
||||
/// 会话轨迹里零 tool_use,模型原话"我这里没有可用的工具:❌ 写文件 / ❌ 读取你的项目目录"。
|
||||
/// 这里把载荷抽成纯函数并配单测:构建或合并再丢掉这项接线时,测试会直接失败。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn claude_direct_turn_payload(
|
||||
client_turn_id: Option<&str>,
|
||||
session_key: &str,
|
||||
resume: Option<String>,
|
||||
user_prompt: &str,
|
||||
system_prompt: &str,
|
||||
cwd: &Path,
|
||||
model: &str,
|
||||
max_turns: u32,
|
||||
mcp_url: &str,
|
||||
mcp_token: &str,
|
||||
) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "turn",
|
||||
"id": client_turn_id.unwrap_or("claude-direct-turn"),
|
||||
"sessionKey": session_key,
|
||||
"resume": resume,
|
||||
"prompt": user_prompt,
|
||||
"systemPrompt": system_prompt,
|
||||
"cwd": cwd,
|
||||
"model": model,
|
||||
"maxTurns": max_turns,
|
||||
"mcpServers": {
|
||||
"agc": {
|
||||
"type": "http",
|
||||
"url": mcp_url,
|
||||
"headers": { "Authorization": format!("Bearer {mcp_token}") }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
root: &Path,
|
||||
llm: &GameCreatorLlmConfig,
|
||||
@@ -1858,30 +2046,28 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
std::fs::create_dir_all(&home)
|
||||
.map_err(|error| format!("创建 cc Direct 隔离目录失败:{error}"))?;
|
||||
let session_key = claude_project_key(root);
|
||||
let resume = claude_direct_sessions()
|
||||
.lock()
|
||||
.map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())?
|
||||
.get(&session_key)
|
||||
.cloned();
|
||||
let (resume, resume_source) = resolve_claude_direct_resume(root, &home)?;
|
||||
app_log!(
|
||||
"agent.direct_codex.claude_resume source={} session={}",
|
||||
resume_source,
|
||||
resume
|
||||
.as_deref()
|
||||
.map(|session_id| session_id.chars().take(8).collect::<String>())
|
||||
.unwrap_or_else(|| "none".to_string())
|
||||
);
|
||||
direct_turn_trace("claude-sidecar-start");
|
||||
let payload = serde_json::json!({
|
||||
"type": "turn",
|
||||
"id": client_turn_id.unwrap_or("claude-direct-turn"),
|
||||
"sessionKey": format!("agc-direct:{}", session_key.display()),
|
||||
"resume": resume,
|
||||
"prompt": user_prompt,
|
||||
"systemPrompt": system_prompt,
|
||||
"cwd": root,
|
||||
"model": llm.model.trim(),
|
||||
"maxTurns": CLAUDE_DIRECT_MAX_TURNS,
|
||||
"mcpServers": {
|
||||
"agc": {
|
||||
"type": "http",
|
||||
"url": mcp_url,
|
||||
"headers": { "Authorization": format!("Bearer {mcp_token}") }
|
||||
}
|
||||
}
|
||||
});
|
||||
let payload = claude_direct_turn_payload(
|
||||
client_turn_id,
|
||||
&format!("agc-direct:{}", session_key.display()),
|
||||
resume,
|
||||
&user_prompt,
|
||||
&system_prompt,
|
||||
root,
|
||||
llm.model.trim(),
|
||||
CLAUDE_DIRECT_MAX_TURNS,
|
||||
&mcp_url,
|
||||
&mcp_token,
|
||||
);
|
||||
let mut stream_state = ClaudeCodeStreamState::default();
|
||||
let mut on_event = |event: &serde_json::Value| {
|
||||
stream_state.observe(root, event, &mut observer);
|
||||
@@ -1980,6 +2166,71 @@ mod tests {
|
||||
request
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_direct_turn_payload_wires_loopback_mcp_tools() {
|
||||
let payload = claude_direct_turn_payload(
|
||||
Some("turn-1"),
|
||||
"agc-direct:C:/project",
|
||||
None,
|
||||
"做个废土风扫雷",
|
||||
"系统提示",
|
||||
Path::new("C:/project"),
|
||||
"claude-opus-5-5",
|
||||
16,
|
||||
"http://127.0.0.1:1234/mcp-abc",
|
||||
"token-1",
|
||||
);
|
||||
|
||||
assert_eq!(payload["type"], "turn");
|
||||
assert_eq!(payload["id"], "turn-1");
|
||||
assert_eq!(payload["sessionKey"], "agc-direct:C:/project");
|
||||
assert_eq!(payload["maxTurns"], 16);
|
||||
// 工具能力只靠这一项下发:漏了就退化成“没有工具的纯聊天”,
|
||||
// 模型再也无法写文件 / 跑构建(见函数上方现场记录)。
|
||||
assert_eq!(payload["mcpServers"]["agc"]["type"], "http");
|
||||
assert_eq!(
|
||||
payload["mcpServers"]["agc"]["url"],
|
||||
"http://127.0.0.1:1234/mcp-abc"
|
||||
);
|
||||
assert_eq!(
|
||||
payload["mcpServers"]["agc"]["headers"]["Authorization"],
|
||||
"Bearer token-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_api_error_retry_item_surfaces_status_and_upstream_body() {
|
||||
let event = serde_json::json!({
|
||||
"type": "system",
|
||||
"subtype": "api_error",
|
||||
"error": {
|
||||
"message": "409 {\"error\":{\"code\":\"MUD_POINTS_INSUFFICIENT\",\"message\":\"泥点余额不足\"}}",
|
||||
"status": 409,
|
||||
"formatted": "409 泥点余额不足"
|
||||
},
|
||||
"retryAttempt": 3,
|
||||
"maxRetries": 10,
|
||||
"source": "request_retry"
|
||||
});
|
||||
|
||||
let (item_id, text) =
|
||||
claude_api_error_retry_item(&event).expect("api_error 事件必须投影出过程行");
|
||||
assert_eq!(item_id, "direct-cc:api-error:3");
|
||||
assert!(text.contains("HTTP 409"), "{text}");
|
||||
assert!(text.contains("泥点余额不足"), "{text}");
|
||||
assert!(text.contains("第 3/10 次"), "{text}");
|
||||
// 同一次尝试复用同一条过程行:事件重放不会多出一条。
|
||||
assert_eq!(
|
||||
claude_api_error_retry_item(&event).map(|item| item.0),
|
||||
Some(item_id)
|
||||
);
|
||||
// 没有错误载荷的事件不投影,避免造出一条没有正文的失败行。
|
||||
assert!(claude_api_error_retry_item(
|
||||
&serde_json::json!({ "type": "system", "subtype": "api_error" })
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_block_item_id_keeps_thinking_and_text_apart() {
|
||||
let mut state = ClaudeCodeStreamState::default();
|
||||
@@ -2489,4 +2740,92 @@ mod tests {
|
||||
Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_session_lookup_uses_the_project_directory_and_newest_trace() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("projects").join("gameagent-0514673b");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
let dir_name = claude_code_project_dir_name(&root);
|
||||
// 目录名必须与 Claude Code 自己的派生规则一致:非字母数字统一换成 `-`,
|
||||
// 否则 resume 会去一个不存在的目录里找会话。
|
||||
assert!(dir_name.contains("gameagent-0514673b"));
|
||||
assert!(!dir_name.contains([':', '\\', '/']));
|
||||
let sessions = home.join("claude").join("projects").join(&dir_name);
|
||||
std::fs::create_dir_all(&sessions).unwrap();
|
||||
|
||||
// 本项目还没有任何轨迹时不能凭空造出 resume。
|
||||
assert_eq!(latest_claude_code_session_id_at(&home, &root), None);
|
||||
|
||||
let older = sessions.join("24c434aa-8f92-4615-9c74-46433eaa3a85.jsonl");
|
||||
let newer = sessions.join("f79dae28-ff58-4da3-8908-7a86a2a368e4.jsonl");
|
||||
std::fs::write(&older, "{}\n").unwrap();
|
||||
std::fs::write(&newer, "{}\n").unwrap();
|
||||
std::fs::write(sessions.join("notes.md"), "x").unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&older)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::now() - Duration::from_secs(600))
|
||||
.unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&newer)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::now())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
latest_claude_code_session_id_at(&home, &root).as_deref(),
|
||||
Some("f79dae28-ff58-4da3-8908-7a86a2a368e4")
|
||||
);
|
||||
|
||||
// 其他项目的轨迹再新也不能顶替本项目:SDK 只会在本项目的目录里查这份轨迹。
|
||||
let other = home
|
||||
.join("claude")
|
||||
.join("projects")
|
||||
.join("C--other-project");
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::fs::write(
|
||||
other.join("00000000-0000-0000-0000-000000000000.jsonl"),
|
||||
"{}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
latest_claude_code_session_id_at(&home, &root).as_deref(),
|
||||
Some("f79dae28-ff58-4da3-8908-7a86a2a368e4")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
let sessions = home
|
||||
.join("claude")
|
||||
.join("projects")
|
||||
.join(claude_code_project_dir_name(&root));
|
||||
std::fs::create_dir_all(&sessions).unwrap();
|
||||
std::fs::write(sessions.join("disk-session.jsonl"), "{}\n").unwrap();
|
||||
|
||||
// 冷启动(离开项目再进入 / 重启客户端)时进程表必然为空,此时必须回放磁盘轨迹,
|
||||
// 否则 cc 执行器以全新会话开工,模型看不到任何历史对话。
|
||||
assert_eq!(
|
||||
resolve_claude_direct_resume(&root, &home).unwrap(),
|
||||
(Some("disk-session".to_string()), "disk")
|
||||
);
|
||||
|
||||
let key = claude_project_key(&root);
|
||||
claude_direct_sessions()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.clone(), "memory-session".to_string());
|
||||
assert_eq!(
|
||||
resolve_claude_direct_resume(&root, &home).unwrap(),
|
||||
(Some("memory-session".to_string()), "memory")
|
||||
);
|
||||
claude_direct_sessions().lock().unwrap().remove(&key);
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -3,6 +3,20 @@ import { describe, expect, it } from 'vitest';
|
||||
import { projectDirectThreadItem } from './directThreadItemProjection';
|
||||
|
||||
describe('DirectProject 工具条目投影', () => {
|
||||
it('系统的上游重试条目按过程行渲染,不会被读成 assistant 回复', () => {
|
||||
const entry = projectDirectThreadItem({
|
||||
itemType: 'message',
|
||||
itemId: 'direct-cc:api-error:3',
|
||||
role: 'system',
|
||||
text: '上游请求失败(HTTP 409):泥点余额不足;正在自动重试(第 3/10 次)',
|
||||
at: 5,
|
||||
});
|
||||
|
||||
expect(entry?.kind).toBe('reasoning');
|
||||
expect(entry?.role).toBeNull();
|
||||
expect(entry?.text).toContain('泥点余额不足');
|
||||
});
|
||||
|
||||
it('CC 无参数、无输出的工具在开始和完成后都保留卡片', () => {
|
||||
const started = projectDirectThreadItem(
|
||||
{
|
||||
|
||||
+14
-1
@@ -303,13 +303,26 @@ export function projectDirectThreadItem(
|
||||
|
||||
switch (item.itemType) {
|
||||
case 'message': {
|
||||
if (!item.text.trim()) return null;
|
||||
if (item.role === 'system') {
|
||||
// 宿主诊断条目(例如上游自动重试):不是模型正文,按过程行渲染,
|
||||
// 既能让重试期间有可见输出,也不会被读成 assistant 的回复。
|
||||
return {
|
||||
itemId,
|
||||
kind: 'reasoning',
|
||||
role: null,
|
||||
text: item.text,
|
||||
toolCall: null,
|
||||
at: item.at,
|
||||
};
|
||||
}
|
||||
const role =
|
||||
item.role === 'user'
|
||||
? 'user'
|
||||
: item.role === 'assistant'
|
||||
? 'assistant'
|
||||
: null;
|
||||
if (!role || !item.text.trim()) return null;
|
||||
if (!role) return null;
|
||||
return {
|
||||
itemId,
|
||||
kind: 'message',
|
||||
|
||||
@@ -6570,3 +6570,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **CPU 边界**:发行网关是公开无鉴权端点,压缩按请求实时算。release 构建实测 level 6 为 1.3 MiB→10 ms、8 MiB→59 ms、64 MiB(单文件上限)→522 ms 纯 CPU,因此在 `RELEASE_COMPRESSION_FAST_ABOVE_BYTES`(2 MiB)以上改用 level 1(zlib 端实测 level 1 约为 level 6 的 1/3 耗时、压缩比只差约 3%)。若后续要再做减法,优先把压缩结果按 `(对象键, 资源路径)` 缓存,而不是放宽级别。
|
||||
- **验证**:`cargo test -p api-server --bin api-server -- game_distribution`(新增 ETag 作用域、`If-None-Match` 列表/弱校验命中、`gzip;q=0` 拒绝、文本压缩与二进制/小文件不压、304 无正文、`*` 对包内缺失路径仍 404 等用例);`npx vitest run packages/shared/src/components/PlatformGameLoadingSurface.test.tsx`、`src/components/game-distribution/GameDistributionPages.test.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx`;`npm run check:game-distribution-ops-rollback-e2e` 47 项通过(含压缩/Vary/ETag/304/不接受 gzip 四条新断言)。真实栈同链路复跑:`phaser.min.js` 1,375,976 B → 353,336 B(gzip,4 Mbps 下 2724 ms → 774 ms),用户端游戏画面 3298 ms → 1543 ms、后台试玩 4587 ms → 2694 ms。
|
||||
- **关联**:`packages/shared/src/components/PlatformGameLoadingSurface.tsx`、`src/components/game-distribution/GamePlayPage.tsx`、`apps/admin-web/src/pages/AdminGameDistributionReviewPage.tsx`、`server-rs/crates/api-server/src/modules/game_distribution.rs`、`deploy/nginx/README.md`。
|
||||
|
||||
## 2026-10-07 cc 会话恢复只活在进程内存里,「离开项目再进入」就看不到历史对话
|
||||
|
||||
- **现象**:用户反馈「离开后再进入项目,cc 那边看不到历史对话」——聊天面板里的历史还在(`.agent/conversations/project.jsonl` 是 UI 的事实源),但模型完全不知道之前说过什么。
|
||||
- **根因**:`CLAUDE_DIRECT_SESSIONS` 是进程级 `Mutex<HashMap<PathBuf, String>>`,只负责把上一个回合的 `session_id` 交给 SDK 的 `resume`。用户离开项目再进入、重启客户端,或上一回合失败(失败路径根本不写这张表)之后,表里没有 id,cc 就以全新会话开工;而 SDK 自己的轨迹其实一直落在隔离 home 里:`<项目>/.agent/runtime/claude-code/home/claude/projects/<sanitized-cwd>/<sessionId>.jsonl`。现场:项目 `gameagent-0514673b` 的隔离 home 里留下两份独立轨迹(14:52 与 15:05),第二份的首次用户输入只有一句「现在呢」;`gameagent-754f4779` 更累积到 8 份。
|
||||
- **现行口径**:恢复顺序固定为「进程内会话表 → 本项目目录下 mtime 最新的会话轨迹」。目录名按 Claude Code 自己的派生规则算(cwd 里非字母数字一律换成 `-`,实例 `C:\Users\...\projects\gameagent-0514673b` → `C--Users-...-projects-gameagent-0514673b`),并且只认本项目目录——SDK 的 `resume` 也只在那里查这份轨迹,从别的目录捞来的 id 会被判成「No conversation found」,比不恢复更糟。日志 `agent.direct_codex.claude_resume source=memory|disk|none` 记录本回合的恢复来源,用户再报「看不到历史对话」时可以一眼分辨是内存命中、磁盘回放,还是本项目确实没有历史会话。
|
||||
- **验证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell claude_` 34 passed(新增 `claude_code_session_lookup_uses_the_project_directory_and_newest_trace`、`claude_direct_resume_falls_back_to_disk_and_prefers_the_process_map`)。真实环境用随包 `claude.exe`(2.1.285)复验:把项目隔离 home 的 `projects/` 拷进临时 `CLAUDE_CONFIG_DIR`、cwd 设为项目根,`--resume 00000000-…` 立刻回 `No conversation found with session ID`;`--resume f79dae28-…`(磁盘上最新那份)不报找不到,直接进入模型请求,落在本地抓包桩上的请求体里带着完整历史(`messages` 13 条,首条就是 14:23 的「做个废土风的扫雷…」原文)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`。
|
||||
|
||||
Reference in New Issue
Block a user