Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ad3313ac0 | |||
| 8ad8124a04 | |||
| 8b25e144b9 | |||
| a7c40da6cf | |||
| de10b99ddb | |||
| 4eefb0f41f | |||
| 158148e0c9 | |||
| 45d7dc2a63 | |||
| 0a34f4e514 | |||
| 09a9fa7b10 | |||
| 896cfad81e | |||
| 9f40f044a5 | |||
| f7c8b9f217 | |||
| 13e137191a | |||
| fd88e516b8 | |||
| 1b7bc95914 | |||
| b4a9be4581 | |||
| e174b6dcf4 | |||
| e8f9929630 | |||
| deb327ce1f | |||
| 0635ddfdb1 | |||
| 6ab7047eff | |||
| 33336d6242 |
@@ -2829,6 +2829,8 @@ impl CodexAppServerConnection {
|
||||
callback(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text: streamed_text.clone(),
|
||||
delta_text: delta,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
});
|
||||
}
|
||||
@@ -3133,6 +3135,7 @@ fn parse_game_creator_codex_app_server_text(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some(thread_id.to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -589,6 +589,7 @@ fn parse_game_creator_codex_cli_response(
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id,
|
||||
usage,
|
||||
|
||||
@@ -49,6 +49,18 @@ pub(crate) struct DesignView {
|
||||
messages: Vec<DesignMessage>,
|
||||
running: bool,
|
||||
can_retry: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
reasoning_text: Option<String>,
|
||||
reasoning_entries: Vec<DesignReasoningEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DesignReasoningEntry {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -65,6 +77,7 @@ pub(crate) struct DesignEvent {
|
||||
}
|
||||
|
||||
fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||||
let reasoning_entries = persisted_design_reasoning_entries(session);
|
||||
DesignView {
|
||||
session: DesignSessionSummary {
|
||||
session_id: session.session_id.clone(),
|
||||
@@ -82,9 +95,124 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView {
|
||||
&& session.turn.as_ref().is_some_and(|turn| turn.pending)
|
||||
&& session.pending_approval.is_none()
|
||||
&& session.pending_clarification.is_none(),
|
||||
reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()),
|
||||
reasoning_entries,
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_text_from_history_item(item: &Value) -> Option<String> {
|
||||
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
|
||||
return None;
|
||||
}
|
||||
let mut text = String::new();
|
||||
if let Some(summary) = item.get("summary").and_then(Value::as_array) {
|
||||
for part in summary {
|
||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||||
text.push_str(value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(content) = item.get("content").and_then(Value::as_array) {
|
||||
for part in content {
|
||||
let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default();
|
||||
if matches!(
|
||||
part_type,
|
||||
"reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking"
|
||||
) {
|
||||
if let Some(value) = part.get("text").and_then(Value::as_str) {
|
||||
text.push_str(value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(!text.trim().is_empty()).then_some(text)
|
||||
}
|
||||
|
||||
fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec<DesignReasoningEntry> {
|
||||
// Responses history contains tool-only provider responses. Their reasoning is
|
||||
// followed by function calls and only the next provider response may contain
|
||||
// visible assistant text, so pairing on the next `message` item makes the
|
||||
// earlier reasoning look like an orphan and moves it to the bottom of the UI.
|
||||
// Both persisted streams retain user-turn boundaries; pair reasoning and
|
||||
// visible assistant messages by their response order within each turn.
|
||||
let mut assistant_groups: Vec<Vec<String>> = vec![Vec::new()];
|
||||
for message in &session.messages {
|
||||
if message.role == "user" {
|
||||
assistant_groups.push(Vec::new());
|
||||
} else if message.role == "assistant" {
|
||||
assistant_groups
|
||||
.last_mut()
|
||||
.expect("assistant group always exists")
|
||||
.push(message.id.clone());
|
||||
}
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
let mut group_index = 0;
|
||||
let mut assistant_index = 0;
|
||||
let mut sequence = 0_u64;
|
||||
let mut current_reasoning = Vec::new();
|
||||
let mut pending_reasoning = Vec::new();
|
||||
let mut saw_response_output = false;
|
||||
|
||||
for item in &session.history {
|
||||
if item.get("role").and_then(Value::as_str) == Some("user") {
|
||||
if !pending_reasoning.is_empty() || !current_reasoning.is_empty() {
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
}
|
||||
group_index += 1;
|
||||
assistant_index = 0;
|
||||
saw_response_output = false;
|
||||
continue;
|
||||
}
|
||||
if item.get("type").and_then(Value::as_str) == Some("reasoning") {
|
||||
if saw_response_output {
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
saw_response_output = false;
|
||||
}
|
||||
if let Some(text) = reasoning_text_from_history_item(item) {
|
||||
sequence += 1;
|
||||
current_reasoning.push(DesignReasoningEntry {
|
||||
id: item
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("reasoning-{sequence}")),
|
||||
text,
|
||||
message_id: None,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if item.get("role").and_then(Value::as_str) == Some("assistant")
|
||||
|| item.get("type").and_then(Value::as_str) == Some("message")
|
||||
{
|
||||
pending_reasoning.extend(current_reasoning.drain(..));
|
||||
let assistant_id = assistant_groups
|
||||
.get(group_index)
|
||||
.and_then(|ids| ids.get(assistant_index))
|
||||
.cloned();
|
||||
assistant_index += 1;
|
||||
for mut entry in pending_reasoning.drain(..) {
|
||||
entry.message_id = assistant_id.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
saw_response_output = false;
|
||||
} else if item.get("type").is_some() {
|
||||
saw_response_output = true;
|
||||
}
|
||||
}
|
||||
pending_reasoning.append(&mut current_reasoning);
|
||||
let fallback_id = assistant_groups
|
||||
.get(group_index)
|
||||
.and_then(|ids| ids.last())
|
||||
.cloned();
|
||||
for mut entry in pending_reasoning {
|
||||
entry.message_id = fallback_id.clone();
|
||||
entries.push(entry);
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn design_event(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
@@ -104,6 +232,17 @@ fn design_event(
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reasoning_event(
|
||||
root: &Path,
|
||||
turn_id: &str,
|
||||
id: Option<&str>,
|
||||
reasoning: String,
|
||||
) -> DesignEvent {
|
||||
let mut event = design_event(root, turn_id, "reasoning", id, None, None);
|
||||
event.reasoning_text = Some(reasoning);
|
||||
event
|
||||
}
|
||||
|
||||
fn design_project_id(root: &Path) -> Result<String, String> {
|
||||
validate_project_root(root)?;
|
||||
Ok(read_existing_manifest_for_project(root)?.project_id)
|
||||
@@ -462,6 +601,7 @@ fn build_design_request(
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Auto)
|
||||
.with_web_search(false);
|
||||
apply_game_creator_llm_reasoning_effort(request, llm)
|
||||
.map(|request| request.with_reasoning_capture(true))
|
||||
}
|
||||
|
||||
// 调试队列只接收副本,写盘慢或失败时丢弃,不参与会话恢复。
|
||||
@@ -548,6 +688,12 @@ async fn request_design_provider(
|
||||
Some(String::new()),
|
||||
None,
|
||||
));
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
let result = if llm.stream {
|
||||
let mut stream_sequence = 0_u64;
|
||||
client
|
||||
@@ -565,18 +711,30 @@ async fn request_design_provider(
|
||||
"model": llm.model,
|
||||
"deltaChars": delta.delta_text.chars().count(),
|
||||
"accumulatedChars": delta.accumulated_text.chars().count(),
|
||||
"reasoningDeltaChars": delta.reasoning_delta.chars().count(),
|
||||
"reasoningAccumulatedChars": delta.accumulated_reasoning.chars().count(),
|
||||
"deltaText": delta.delta_text,
|
||||
"finishReason": delta.finish_reason,
|
||||
}),
|
||||
);
|
||||
emit(design_event(
|
||||
root,
|
||||
&turn_id,
|
||||
"text",
|
||||
Some(&message_id),
|
||||
Some(delta.accumulated_text.clone()),
|
||||
None,
|
||||
));
|
||||
if !delta.delta_text.is_empty() || delta.finish_reason.is_some() {
|
||||
emit(design_event(
|
||||
root,
|
||||
&turn_id,
|
||||
"text",
|
||||
Some(&message_id),
|
||||
Some(delta.accumulated_text.clone()),
|
||||
None,
|
||||
));
|
||||
}
|
||||
if !delta.reasoning_delta.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
delta.accumulated_reasoning.clone(),
|
||||
));
|
||||
}
|
||||
})
|
||||
.await
|
||||
} else {
|
||||
@@ -584,6 +742,14 @@ async fn request_design_provider(
|
||||
};
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if !response.reasoning.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
response.reasoning.clone(),
|
||||
));
|
||||
}
|
||||
design_debug(
|
||||
root,
|
||||
"response",
|
||||
@@ -606,6 +772,12 @@ async fn request_design_provider(
|
||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||
.is_none()
|
||||
{
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err(detail);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(
|
||||
@@ -654,8 +826,24 @@ async fn request_scripted_design_provider(
|
||||
Some(String::new()),
|
||||
None,
|
||||
));
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
match fake_provider::take() {
|
||||
Some(Ok(response)) => return Ok(response),
|
||||
Some(Ok(response)) => {
|
||||
if !response.reasoning.is_empty() {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
response.reasoning.clone(),
|
||||
));
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
let detail = redact_agent_runtime_error(
|
||||
root,
|
||||
@@ -666,10 +854,24 @@ async fn request_scripted_design_provider(
|
||||
|| game_creator_agent_runtime_transient_provider_error_kind(&error, false)
|
||||
.is_none()
|
||||
{
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err(detail);
|
||||
}
|
||||
}
|
||||
None => return Err("假 Provider 脚本耗尽".into()),
|
||||
None => {
|
||||
emit(design_reasoning_event(
|
||||
root,
|
||||
&turn_id,
|
||||
Some(&message_id),
|
||||
String::new(),
|
||||
));
|
||||
return Err("假 Provider 脚本耗尽".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
@@ -940,7 +1142,9 @@ pub(crate) fn set_design_agent_runtime_mode(
|
||||
"design.runtime-mode",
|
||||
)?;
|
||||
if active_runtime.trim() == "game" {
|
||||
crate::assets::register_design_artifacts_at(root)?;
|
||||
if crate::assets::register_design_artifacts_at(root)? {
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
}
|
||||
}
|
||||
write_design_runtime_mode(root, active_runtime.trim())
|
||||
}
|
||||
@@ -1176,6 +1380,38 @@ mod tests {
|
||||
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
|
||||
assert!(error.contains("游戏运行态"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
|
||||
let temporary = tempfile::tempdir().expect("create runtime mode root");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
let before = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read initial revision")
|
||||
.revision;
|
||||
assert_eq!(
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
|
||||
.expect("switch to game")
|
||||
.active_runtime,
|
||||
"game"
|
||||
);
|
||||
let after = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read committed revision")
|
||||
.revision;
|
||||
assert_eq!(after, before + 1);
|
||||
|
||||
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
|
||||
.expect("repeat switch to game");
|
||||
let repeated = read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read repeated revision")
|
||||
.revision;
|
||||
assert_eq!(repeated, after);
|
||||
}
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
|
||||
@@ -1285,6 +1521,7 @@ mod tests {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "fake-design".into(),
|
||||
text: text.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some(if calls.is_empty() {
|
||||
"stop".into()
|
||||
} else {
|
||||
@@ -1345,6 +1582,110 @@ mod tests {
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_request_enables_reasoning_capture_only_for_design_runtime() {
|
||||
let session = new_design_session("project", "quality");
|
||||
let request = build_design_request(&session, &pack(), &GameCreatorLlmConfig::default())
|
||||
.expect("design request");
|
||||
assert!(request.capture_reasoning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_reasoning_follows_response_order_across_tool_only_responses() {
|
||||
let mut session = new_design_session("project", "quality");
|
||||
session.messages = vec![
|
||||
DesignMessage {
|
||||
id: "turn:user".into(),
|
||||
role: "user".into(),
|
||||
text: "需求".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "call-1:tool".into(),
|
||||
role: "tool".into(),
|
||||
text: "读取资源".into(),
|
||||
},
|
||||
DesignMessage {
|
||||
id: "turn:response:0".into(),
|
||||
role: "assistant".into(),
|
||||
text: "给出方案".into(),
|
||||
},
|
||||
];
|
||||
session.history = vec![
|
||||
json!({"role":"user", "content":"需求"}),
|
||||
json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}),
|
||||
json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}),
|
||||
json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}),
|
||||
json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}),
|
||||
];
|
||||
|
||||
let entries = persisted_design_reasoning_entries(&session);
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| (entry.id.as_str(), entry.message_id.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("r1", Some("turn:response:0")),
|
||||
("r2", Some("turn:response:0")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_design_provider_emits_reasoning_without_persisting_it() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
begin_design_turn(&mut session, "turn-reasoning");
|
||||
let mut response = fake_response("reasoning", "正文", Vec::new());
|
||||
response.reasoning = "先分析需求,再组织方案。".into();
|
||||
let _fake = fake_provider::install(vec![Ok(response)], 0);
|
||||
let mut events = Vec::new();
|
||||
let response =
|
||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||||
.await
|
||||
.expect("scripted provider");
|
||||
|
||||
let reasoning_events = events
|
||||
.iter()
|
||||
.filter_map(|event| event.reasoning_text.as_deref())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reasoning_events, vec!["", "先分析需求,再组织方案。"]);
|
||||
assert_eq!(response.text, "正文");
|
||||
assert!(session.history.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn scripted_design_provider_retry_clears_previous_reasoning_attempt() {
|
||||
let (_temp, root, _resources) = init_design_project();
|
||||
let mut session = new_design_session("design-fake", "quality");
|
||||
begin_design_turn(&mut session, "turn-reasoning-retry");
|
||||
let mut response = fake_response("reasoning-retry", "重试后的正文", Vec::new());
|
||||
response.reasoning = "重试后的推理".into();
|
||||
let _fake = fake_provider::install(
|
||||
vec![
|
||||
Err(platform_llm::LlmError::Upstream {
|
||||
status_code: 503,
|
||||
message: "busy".into(),
|
||||
}),
|
||||
Ok(response),
|
||||
],
|
||||
1,
|
||||
);
|
||||
let mut events = Vec::new();
|
||||
let response =
|
||||
request_scripted_design_provider(&root, &mut session, &mut |event| events.push(event))
|
||||
.await
|
||||
.expect("scripted retry provider");
|
||||
|
||||
let reasoning_events = events
|
||||
.iter()
|
||||
.filter_map(|event| event.reasoning_text.as_deref())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(reasoning_events, vec!["", "", "重试后的推理"]);
|
||||
assert_eq!(response.text, "重试后的正文");
|
||||
assert!(session.history.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn fake_provider_walks_five_phases_and_enters_consultant() {
|
||||
let (_temp, root, resources) = init_design_project();
|
||||
|
||||
@@ -409,6 +409,8 @@ where
|
||||
(self.on_delta)(&platform_llm::LlmStreamDelta {
|
||||
accumulated_text,
|
||||
delta_text,
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason,
|
||||
});
|
||||
}
|
||||
@@ -499,6 +501,7 @@ mod tests {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "interaction-test".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("interaction-response".to_string()),
|
||||
usage: None,
|
||||
|
||||
+10
@@ -115,6 +115,7 @@ fn persist_tool_plan_handoff_repair_chain(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -146,6 +147,8 @@ fn stream_delta(delta_text: &str, accumulated_text: &str) -> platform_llm::LlmSt
|
||||
platform_llm::LlmStreamDelta {
|
||||
accumulated_text: accumulated_text.to_string(),
|
||||
delta_text: delta_text.to_string(),
|
||||
accumulated_reasoning: String::new(),
|
||||
reasoning_delta: String::new(),
|
||||
finish_reason: None,
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1006,7 @@ async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_respon
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: private_response.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1116,6 +1120,7 @@ async fn tool_plan_handoff_identity_drift_closes_entire_repair_chain_before_remo
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: old_llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1302,6 +1307,7 @@ async fn tool_plan_capacity_gate_runs_before_provider_lifecycle_and_network() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: format!("capacity response {loop_iteration}"),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1436,6 +1442,7 @@ async fn tool_plan_handoff_durable_control_closes_entire_repair_chain_before_rem
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1552,6 +1559,7 @@ fn provider_recovery_cleanup_closes_tool_plan_lifecycle_before_removing_handoff(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "cleanup handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1623,6 +1631,7 @@ fn runtime_resume_scans_and_cleans_terminal_tool_plan_handoff() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "terminal handoff".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
@@ -1718,6 +1727,7 @@ async fn provider_handoff_retry_conflict_preserves_both_sidecars_for_reconciliat
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: llm.model.clone(),
|
||||
text: "已成功但尚未消费的回复".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: None,
|
||||
usage: None,
|
||||
|
||||
@@ -798,6 +798,7 @@ mod provider_reconciliation_diagnostic_tests {
|
||||
let response = platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "test-model".to_string(),
|
||||
reasoning: String::new(),
|
||||
text: "C:\\private\\response".to_string(),
|
||||
finish_reason: Some("completed".to_string()),
|
||||
response_id: Some("response-1".to_string()),
|
||||
|
||||
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
|
||||
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
|
||||
}
|
||||
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
|
||||
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
|
||||
let design_root = root.join("design_artifacts");
|
||||
if !design_root.exists() {
|
||||
return Ok(0);
|
||||
return Ok(false);
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut directories = vec![design_root];
|
||||
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut registered = 0;
|
||||
let mut changed = false;
|
||||
for path in files {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
Some("yaml" | "yml") => "text/yaml",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
register_local_asset_at(
|
||||
let (_, asset_changed) = register_local_asset_entry_with_change(
|
||||
root,
|
||||
&relative,
|
||||
"document",
|
||||
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
|
||||
reference_resource_ids: Vec::new(),
|
||||
},
|
||||
)?;
|
||||
registered += 1;
|
||||
changed |= asset_changed;
|
||||
}
|
||||
Ok(registered)
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) fn import_canvas_asset_at(
|
||||
@@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry(
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<UploadLocalAssetResult, String> {
|
||||
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
fn register_local_asset_entry_with_change(
|
||||
root: &Path,
|
||||
local_path: &str,
|
||||
kind: &str,
|
||||
media_type: &str,
|
||||
id_prefix: &str,
|
||||
source: GameCreationAppAssetSource,
|
||||
) -> Result<(UploadLocalAssetResult, bool), String> {
|
||||
let normalized_path = normalize_relative_path(local_path)?;
|
||||
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
@@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry(
|
||||
let mut source_for_record = source.clone();
|
||||
source_for_record.prompt = None;
|
||||
|
||||
let (id, record_type) = mutate_manifest_at(root, |manifest| {
|
||||
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
|
||||
if let Some(existing) = manifest
|
||||
.assets
|
||||
.iter_mut()
|
||||
@@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry(
|
||||
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
|
||||
// 时才触发),于是这个资产永远停在错误栏目。
|
||||
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
|
||||
let changed = existing.kind != kind
|
||||
|| existing.media_type != media_type
|
||||
|| existing.source != source;
|
||||
if existing.kind != kind {
|
||||
existing.kind = kind.to_string();
|
||||
existing.category = game_creation_app_asset_category_for_kind(kind);
|
||||
}
|
||||
existing.media_type = media_type.to_string();
|
||||
existing.source = source;
|
||||
Ok((existing.id.clone(), "asset.update"))
|
||||
Ok((existing.id.clone(), "asset.update", changed))
|
||||
} else {
|
||||
let id = format!(
|
||||
"{id_prefix}-{}-{}",
|
||||
@@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry(
|
||||
tags: Vec::new(),
|
||||
source,
|
||||
});
|
||||
Ok((id, "asset.register"))
|
||||
Ok((id, "asset.register", true))
|
||||
}
|
||||
})?;
|
||||
append_agent_db_record(
|
||||
@@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry(
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
})
|
||||
Ok((
|
||||
UploadLocalAssetResult {
|
||||
id,
|
||||
local_path: normalized_path.clone(),
|
||||
absolute_path: absolute_path.to_string_lossy().into_owned(),
|
||||
manifest_path: manifest_path.to_string_lossy().into_owned(),
|
||||
},
|
||||
changed,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -2137,6 +2155,27 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn design_artifact_registration_reports_only_real_manifest_changes() {
|
||||
let temporary = tempfile::tempdir().expect("tempdir");
|
||||
let root = temporary.path();
|
||||
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
|
||||
.expect("init project");
|
||||
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
|
||||
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
|
||||
.expect("write artifact");
|
||||
|
||||
assert!(register_design_artifacts_at(root).expect("register first time"));
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root)
|
||||
.unwrap()
|
||||
.assets
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
|
||||
}
|
||||
|
||||
/// 画板导出推断出的 kind 必须已经是 canonical 值。
|
||||
///
|
||||
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
|
||||
|
||||
@@ -517,11 +517,7 @@ pub(crate) fn create_automatic_local_game_project_at(
|
||||
match fs::create_dir(&project_root) {
|
||||
Ok(()) => {
|
||||
let result = (|| {
|
||||
prepare_game_creator_private_path_for_read(
|
||||
&project_root,
|
||||
true,
|
||||
"自动项目目录",
|
||||
)?;
|
||||
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
|
||||
enforce_project_permission_policy(&project_root, "project.create")?;
|
||||
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
|
||||
init_local_game_project_at(
|
||||
|
||||
@@ -1299,15 +1299,12 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
#[cfg(all(windows, test))]
|
||||
initialize_windows_game_creator_directory_owner_for_current_user(&directory)?;
|
||||
#[cfg(windows)]
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
// This invocation created the directory, so initialize its
|
||||
// owner/DACL in-process. Marker-based managed-path detection
|
||||
// must not route a newly-created descendant into UAC.
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -1346,15 +1343,11 @@ pub(crate) fn ensure_game_creator_private_directory_tree(
|
||||
fs::create_dir(&directory).map_err(|retry_error| {
|
||||
format!("创建 {label} 失败:{}: {retry_error}", directory.display())
|
||||
})?;
|
||||
if game_creator_private_path_allows_auto_elevation(&directory) {
|
||||
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
|
||||
&directory, true, true,
|
||||
)?;
|
||||
} else {
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
// The retry also created this directory in the current
|
||||
// process; keep it on the local hardening path.
|
||||
secure_windows_game_creator_path_for_current_user_with_owner_policy(
|
||||
&directory, true, true, true,
|
||||
)?;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
|
||||
@@ -905,6 +905,7 @@ mod tests {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "context-compaction-test".to_string(),
|
||||
text: summary.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("context-compaction-response".to_string()),
|
||||
usage: Some(platform_llm::LlmTokenUsage {
|
||||
|
||||
@@ -51,6 +51,7 @@ impl AgentRuntimeProviderHandoffRecord {
|
||||
provider: self.response.provider,
|
||||
model: self.response.model.clone(),
|
||||
text: self.response.text.clone(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: self.response.finish_reason.clone(),
|
||||
response_id: self.response.response_id.clone(),
|
||||
usage: self.response.usage.clone(),
|
||||
@@ -340,6 +341,7 @@ mod tests {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "handoff-model".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("response-handoff".to_string()),
|
||||
usage: Some(LlmTokenUsage {
|
||||
|
||||
@@ -2775,6 +2775,7 @@ fn durable_provider_handoff_prevents_shutdown_even_when_corrupt() {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "provider-handoff-runner-test".to_string(),
|
||||
text: "durable final reply".to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("stop".to_string()),
|
||||
response_id: Some("provider-handoff-response".to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -4475,6 +4475,7 @@ fn real_e2e_tool_plan_checkpoint_response() -> platform_llm::LlmRunResponse {
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "real-e2e-checkpoint-model".to_string(),
|
||||
text: REAL_E2E_TOOL_PLAN_CHECKPOINT_PRIVATE_RESPONSE.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("real-e2e-checkpoint-private-response-id".to_string()),
|
||||
usage: None,
|
||||
@@ -4720,6 +4721,7 @@ fn agent_tool_plan_llm_response(
|
||||
provider: platform_llm::LlmProvider::OpenAiCompatible,
|
||||
model: "mock-game-model".to_string(),
|
||||
text: text.into(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("response-tool-plan-test".to_string()),
|
||||
usage: None,
|
||||
|
||||
@@ -127,6 +127,7 @@ impl AgentRuntimeToolPlanHandoffEntry {
|
||||
provider: self.response.provider,
|
||||
model: self.response.model.clone(),
|
||||
text,
|
||||
reasoning: String::new(),
|
||||
finish_reason: self.response.finish_reason.clone(),
|
||||
response_id: self.response.response_id.clone(),
|
||||
usage: self.response.usage.as_ref().map(LlmTokenUsage::from),
|
||||
|
||||
@@ -84,6 +84,7 @@ fn response(text: &str, tool_calls: Vec<LlmToolCall>) -> LlmRunResponse {
|
||||
provider: LlmProvider::OpenAiCompatible,
|
||||
model: "tool-plan-handoff-model".to_string(),
|
||||
text: text.to_string(),
|
||||
reasoning: String::new(),
|
||||
finish_reason: Some("tool_calls".to_string()),
|
||||
response_id: Some("tool-plan-handoff-response".to_string()),
|
||||
usage: Some(LlmTokenUsage {
|
||||
|
||||
+29
-2
@@ -265,11 +265,11 @@ pub fn inspect_separation_recovery(
|
||||
}
|
||||
|
||||
pub fn finalize_separation(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
remove_separation_state(root, asset_id)
|
||||
remove_separation_recovery_files(root, asset_id)
|
||||
}
|
||||
|
||||
pub fn discard_separation_recovery(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
remove_separation_state(root, asset_id)
|
||||
remove_separation_recovery_files(root, asset_id)
|
||||
}
|
||||
|
||||
fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
@@ -281,6 +281,33 @@ fn remove_separation_state(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_separation_recovery_files(root: &Path, asset_id: &str) -> Result<(), String> {
|
||||
let sidecar = separation_sidecar_dir(root, asset_id)?;
|
||||
remove_separation_state(root, asset_id)?;
|
||||
let entries = match fs::read_dir(&sidecar) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(format!("读取 separation 临时文件失败:{error}")),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| format!("读取 separation 临时文件失败:{error}"))?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if name.starts_with("processed-") || name.starts_with("binding-") {
|
||||
let path = entry.path();
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| format!("检查 separation 临时文件失败:{error}"))?
|
||||
.is_file()
|
||||
{
|
||||
fs::remove_file(&path)
|
||||
.map_err(|error| format!("删除 separation 临时文件失败:{error}"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -596,6 +596,12 @@ export function App({
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
} | null>(null);
|
||||
const designAgentEventSubscriptionReadyRef = useRef<Promise<void> | null>(
|
||||
null,
|
||||
);
|
||||
const designAgentEventSubscriptionResolveRef = useRef<(() => void) | null>(
|
||||
null,
|
||||
);
|
||||
// 做方案入口独立成链:立项策划需要委派、澄清 pending 与 GDD 审批,这些只存在于
|
||||
// Supervisor Runtime;direct-codex 是单回合「生成→试玩→修」循环,没有对应机制。
|
||||
// 因此策划入口不走产品默认的 direct-codex,做游戏与做素材保持 master 的新默认。
|
||||
@@ -821,7 +827,17 @@ export function App({
|
||||
useState('');
|
||||
const planningV2TransientReplyTargetRef = useRef('');
|
||||
const planningV2VisibleReplyRef = useRef('');
|
||||
const designAgentPendingViewRef = useRef<{
|
||||
clientTurnId: string;
|
||||
projectPath: string;
|
||||
view: DesignView;
|
||||
} | null>(null);
|
||||
const [planningV2Reasoning, setPlanningV2Reasoning] = useState('');
|
||||
const designAgentReasoningTurnRef = useRef<{
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const planningV2TurnRef = useRef<{
|
||||
projectPath: string;
|
||||
clientTurnId: string;
|
||||
@@ -849,6 +865,22 @@ export function App({
|
||||
}
|
||||
}
|
||||
|
||||
function designAgentEventSubscriptionReady() {
|
||||
if (!designAgentEventSubscriptionReadyRef.current) {
|
||||
designAgentEventSubscriptionReadyRef.current = new Promise<void>(
|
||||
(resolve) => {
|
||||
designAgentEventSubscriptionResolveRef.current = resolve;
|
||||
},
|
||||
);
|
||||
}
|
||||
return designAgentEventSubscriptionReadyRef.current;
|
||||
}
|
||||
|
||||
function resolveDesignAgentEventSubscriptionReady() {
|
||||
designAgentEventSubscriptionResolveRef.current?.();
|
||||
designAgentEventSubscriptionResolveRef.current = null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const target = planningV2TransientReplyTargetRef.current;
|
||||
@@ -980,6 +1012,15 @@ export function App({
|
||||
}
|
||||
|
||||
function designMessagesToChat(view: DesignView): ChatMessage[] {
|
||||
const reasoningByMessageId = new Map<string, string[]>();
|
||||
for (const entry of view.reasoningEntries ?? []) {
|
||||
if (!entry.messageId) {
|
||||
continue;
|
||||
}
|
||||
const texts = reasoningByMessageId.get(entry.messageId) ?? [];
|
||||
texts.push(entry.text);
|
||||
reasoningByMessageId.set(entry.messageId, texts);
|
||||
}
|
||||
return view.messages
|
||||
.filter((message) => message.text.trim())
|
||||
.map((message) => ({
|
||||
@@ -987,6 +1028,7 @@ export function App({
|
||||
text: message.text,
|
||||
runtimeOwned: true,
|
||||
messageId: message.id,
|
||||
reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'),
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
}
|
||||
@@ -1005,6 +1047,67 @@ export function App({
|
||||
latestMessagesRef.current = conversation;
|
||||
}
|
||||
|
||||
function commitDesignAgentView(view: DesignView, projectPath: string) {
|
||||
const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId;
|
||||
designAgentPendingViewRef.current = null;
|
||||
applyDesignView(view, projectPath);
|
||||
setPlanningV2Reasoning('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) {
|
||||
designAgentTurnRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyDesignAgentViewAfterTransient(
|
||||
view: DesignView,
|
||||
projectPath: string,
|
||||
clientTurnId: string,
|
||||
) {
|
||||
let target = planningV2TransientReplyTargetRef.current;
|
||||
const tracked = designAgentTurnRef.current;
|
||||
if (!target.trim() && !view.running) {
|
||||
const latestAssistantText = [...view.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role !== 'user' && message.text.trim())
|
||||
?.text.trim();
|
||||
if (latestAssistantText) {
|
||||
setPlanningV2TransientReplyTarget(latestAssistantText);
|
||||
target = latestAssistantText;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!view.running &&
|
||||
tracked?.clientTurnId === clientTurnId &&
|
||||
target.trim() &&
|
||||
planningV2VisibleReplyRef.current !== target
|
||||
) {
|
||||
designAgentPendingViewRef.current = {
|
||||
clientTurnId,
|
||||
projectPath,
|
||||
view,
|
||||
};
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(view, projectPath);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const pending = designAgentPendingViewRef.current;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
const target = planningV2TransientReplyTargetRef.current;
|
||||
if (target && planningV2VisibleReplyRef.current !== target) {
|
||||
return;
|
||||
}
|
||||
commitDesignAgentView(pending.view, pending.projectPath);
|
||||
}, 50);
|
||||
return () => window.clearInterval(timer);
|
||||
// 收尾定时器只需注册一次;它读取 refs,避免随每次渲染重建。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function hydrateDesignAgentSession(nextProjectPath: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke || !nextProjectPath.trim()) {
|
||||
@@ -1038,9 +1141,17 @@ export function App({
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
text: '',
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
await designAgentEventSubscriptionReady();
|
||||
setChatAgentBusy(true);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
try {
|
||||
const view = await invoke<DesignView>('continue_design_agent_session', {
|
||||
projectPath: nextProjectPath,
|
||||
@@ -1050,7 +1161,7 @@ export function App({
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
applyDesignView(view, nextProjectPath);
|
||||
applyDesignAgentViewAfterTransient(view, nextProjectPath, clientTurnId);
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
@@ -1062,8 +1173,10 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(message);
|
||||
setPlanGddError(message);
|
||||
} finally {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1668,9 @@ export function App({
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2Session(null);
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
designAgentPendingViewRef.current = null;
|
||||
designAgentReasoningTurnRef.current = null;
|
||||
setPlanningV2Reasoning('');
|
||||
setPlanningV2Active(planningStartMode);
|
||||
planningV2ActiveRef.current = planningStartMode;
|
||||
designAgentLaneRef.current = planningStartMode;
|
||||
@@ -1990,8 +2106,14 @@ export function App({
|
||||
}, [planningV2Active]);
|
||||
|
||||
useEffect(() => {
|
||||
const ready = designAgentEventSubscriptionReady();
|
||||
if (!canSubscribeTauriEvents() || !planningV2Active) {
|
||||
return;
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
return () => {
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
let cleanup: (() => void) | null = null;
|
||||
let disposed = false;
|
||||
@@ -2008,26 +2130,45 @@ export function App({
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.reasoningText != null) {
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
const reasoningTurn = designAgentReasoningTurnRef.current;
|
||||
if (
|
||||
reasoningTurn &&
|
||||
reasoningTurn.projectPath === payload.projectPath &&
|
||||
reasoningTurn.clientTurnId === payload.clientTurnId
|
||||
) {
|
||||
reasoningTurn.text = payload.reasoningText;
|
||||
setPlanningV2Reasoning(payload.reasoningText);
|
||||
}
|
||||
}
|
||||
if (payload.kind === 'tool' && payload.text) {
|
||||
setPlanningV2TransientReplyTarget(payload.text);
|
||||
}
|
||||
if (payload.view) {
|
||||
applyDesignView(payload.view, payload.projectPath);
|
||||
applyDesignAgentViewAfterTransient(
|
||||
payload.view,
|
||||
payload.projectPath,
|
||||
payload.clientTurnId,
|
||||
);
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
cleanup = unlisten;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => {
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup?.();
|
||||
resolveDesignAgentEventSubscriptionReady();
|
||||
if (designAgentEventSubscriptionReadyRef.current === ready) {
|
||||
designAgentEventSubscriptionReadyRef.current = null;
|
||||
}
|
||||
};
|
||||
// applyDesignView 读的是 refs 和当前项目路径,
|
||||
// 把它写进依赖会在每轮回复时重订事件。
|
||||
@@ -6072,6 +6213,7 @@ export function App({
|
||||
setChatAgentBusy(true);
|
||||
setProjectSupervisorRuntimeError('');
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
try {
|
||||
const result = currentSessionId
|
||||
? await invoke<PlanningSessionCommandResultV2>(
|
||||
@@ -11797,6 +11939,9 @@ export function App({
|
||||
: projectSupervisorTransientReply
|
||||
}
|
||||
designReasoning={planningV2Reasoning}
|
||||
designReasoningEntries={
|
||||
useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : []
|
||||
}
|
||||
visibleMessages={visibleMessages}
|
||||
visibleProfessionalAgentCards={visibleProfessionalAgentCards}
|
||||
showProfessionalCollaboration={
|
||||
@@ -11824,15 +11969,25 @@ export function App({
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
};
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setChatAgentBusy(true);
|
||||
setPlanGddDecisionBusy(true);
|
||||
void invoke<DesignView>('decide_design_phase', {
|
||||
designAgentReasoningTurnRef.current = {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
})
|
||||
text: '',
|
||||
};
|
||||
designAgentPendingViewRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
setPlanningV2Reasoning('');
|
||||
setChatAgentBusy(true);
|
||||
setPlanGddDecisionBusy(true);
|
||||
void designAgentEventSubscriptionReady()
|
||||
.then(() =>
|
||||
invoke<DesignView>('decide_design_phase', {
|
||||
projectPath: nextProjectPath,
|
||||
clientTurnId,
|
||||
requestId,
|
||||
approved,
|
||||
}),
|
||||
)
|
||||
.then((view) => {
|
||||
if (
|
||||
localProjectPathRef.current !== nextProjectPath ||
|
||||
@@ -11842,7 +11997,11 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
applyDesignView(view, nextProjectPath);
|
||||
applyDesignAgentViewAfterTransient(
|
||||
view,
|
||||
nextProjectPath,
|
||||
clientTurnId,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (
|
||||
@@ -11864,8 +12023,10 @@ export function App({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
if (!designAgentPendingViewRef.current) {
|
||||
designAgentTurnRef.current = null;
|
||||
setPlanningV2TransientReplyTarget('');
|
||||
}
|
||||
setChatAgentBusy(false);
|
||||
setPlanGddDecisionBusy(false);
|
||||
});
|
||||
|
||||
@@ -993,6 +993,7 @@ export interface ChatMessage {
|
||||
draftCommand?: string;
|
||||
draftCommandLabel?: string;
|
||||
messageId?: string | null;
|
||||
reasoningText?: string;
|
||||
agentId?: string | null;
|
||||
updatedAt?: number;
|
||||
runtimeOwned?: boolean;
|
||||
@@ -1038,11 +1039,19 @@ export interface DesignAgentMessage {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface DesignReasoningEntry {
|
||||
id: string;
|
||||
text: string;
|
||||
messageId?: string | null;
|
||||
}
|
||||
|
||||
export interface DesignView {
|
||||
session: DesignSessionSummary;
|
||||
messages: DesignAgentMessage[];
|
||||
running: boolean;
|
||||
canRetry: boolean;
|
||||
reasoningText?: string | null;
|
||||
reasoningEntries?: DesignReasoningEntry[];
|
||||
}
|
||||
|
||||
export interface DesignEvent {
|
||||
|
||||
+33
-3
@@ -16,7 +16,11 @@ import type {
|
||||
PlanGddDecisionAction,
|
||||
PlanGddStateViewV1,
|
||||
} from '../../app/types';
|
||||
import type { DesignClarificationRequest, DesignView } from '../../app/types';
|
||||
import type {
|
||||
DesignClarificationRequest,
|
||||
DesignReasoningEntry,
|
||||
DesignView,
|
||||
} from '../../app/types';
|
||||
import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage';
|
||||
import {
|
||||
projectProfessionalAgentLabel,
|
||||
@@ -97,6 +101,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
|
||||
showProfessionalCollaboration?: boolean;
|
||||
transientReply: string;
|
||||
designReasoning?: string;
|
||||
designReasoningEntries?: DesignReasoningEntry[];
|
||||
visibleMessages: ChatMessage[];
|
||||
visibleProfessionalAgentCards: AgentStatusCard[];
|
||||
workspaceStatus: string;
|
||||
@@ -149,6 +154,7 @@ export function ProjectSupervisorView({
|
||||
showProfessionalCollaboration = true,
|
||||
transientReply,
|
||||
designReasoning = '',
|
||||
designReasoningEntries = [],
|
||||
visibleMessages,
|
||||
visibleProfessionalAgentCards,
|
||||
workspaceStatus,
|
||||
@@ -260,11 +266,35 @@ export function ProjectSupervisorView({
|
||||
role={message.role}
|
||||
text={projectSupervisorChatMessageText(message)}
|
||||
/>
|
||||
{message.reasoningText ? (
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{message.reasoningText}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{designReasoningEntries
|
||||
.filter((entry) => !entry.messageId)
|
||||
.map((entry) => (
|
||||
<details
|
||||
key={`reasoning-${entry.id}`}
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{entry.text}</pre>
|
||||
</details>
|
||||
))}
|
||||
{designReasoning ? (
|
||||
<details className="design-agent-reasoning">
|
||||
<summary>显示思考过程</summary>
|
||||
<details
|
||||
className="design-agent-reasoning"
|
||||
aria-label="策划 Agent 思考过程"
|
||||
>
|
||||
<summary>思考过程</summary>
|
||||
<pre>{designReasoning}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
@@ -1212,7 +1212,15 @@ export function useUiEditorSession(
|
||||
'自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。',
|
||||
);
|
||||
}
|
||||
if (backfillErrors.length > 0) {
|
||||
if (
|
||||
backfillErrors.length > 0 ||
|
||||
completedResult.problematic_nodes.length > 0
|
||||
) {
|
||||
if (completedResult.problematic_nodes.length > 0) {
|
||||
backfillErrors.push(
|
||||
`${completedResult.problematic_nodes.length} 个节点达到返工上限,需要人工处理`,
|
||||
);
|
||||
}
|
||||
const recovery = await invoke<SeparationRecoveryDTO>(
|
||||
'inspect_separation_recovery',
|
||||
{ projectPath, assetId: resourceId },
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
setComposerText,
|
||||
waitFor,
|
||||
} from './harness';
|
||||
|
||||
@@ -55,6 +56,46 @@ function designClarificationView() {
|
||||
};
|
||||
}
|
||||
|
||||
function designConversationView() {
|
||||
return {
|
||||
session: {
|
||||
sessionId: 'design-session-reasoning',
|
||||
projectId: 'local-project-draft',
|
||||
currentPhase: 'concept',
|
||||
approvedPhases: [],
|
||||
pendingApproval: null,
|
||||
pendingClarification: null,
|
||||
turnIndex: 1,
|
||||
lastError: null,
|
||||
},
|
||||
messages: [],
|
||||
running: false,
|
||||
canRetry: false,
|
||||
};
|
||||
}
|
||||
|
||||
function designHistoryView() {
|
||||
return {
|
||||
...designConversationView(),
|
||||
messages: [
|
||||
{ id: 'assistant-1', role: 'assistant', text: '第一轮正文' },
|
||||
{ id: 'assistant-2', role: 'assistant', text: '第二轮正文' },
|
||||
],
|
||||
reasoningEntries: [
|
||||
{
|
||||
id: 'reasoning-1',
|
||||
messageId: 'assistant-1',
|
||||
text: '第一轮思考',
|
||||
},
|
||||
{
|
||||
id: 'reasoning-2',
|
||||
messageId: 'assistant-2',
|
||||
text: '第二轮思考',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDesignAgentSurfaceTests() {
|
||||
it('hydrates an existing design session and decides approval through design commands', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
@@ -141,4 +182,83 @@ export function registerDesignAgentSurfaceTests() {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the current turn reasoning after completion and supports collapse/expand', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
designAgentView: designConversationView(),
|
||||
designAgentContinueView: designConversationView(),
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
window.history.pushState({}, '', '/');
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: harness.projectPath,
|
||||
orchestrationMode: 'single-supervisor',
|
||||
planningStartMode: true,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const input = await screen.findByLabelText('项目需求');
|
||||
await setComposerText(input, '请给出核心玩法方案');
|
||||
fireEvent.submit(input.closest('form') as HTMLFormElement);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
harness.invoke.mock.calls.some(
|
||||
([command]) => command === 'continue_design_agent_session',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
const continueCall = [...harness.invoke.mock.calls]
|
||||
.reverse()
|
||||
.find(([command]) => command === 'continue_design_agent_session');
|
||||
const clientTurnId = String(
|
||||
(continueCall?.[1] as { clientTurnId?: string }).clientTurnId,
|
||||
);
|
||||
harness.emitDesignAgentEvent({
|
||||
projectPath: harness.projectPath,
|
||||
clientTurnId,
|
||||
kind: 'reasoning',
|
||||
reasoningText: '先分析需求,再组织方案。',
|
||||
});
|
||||
|
||||
const summary = await screen.findByText('思考过程');
|
||||
const details = summary.closest('details') as HTMLDetailsElement;
|
||||
expect(details.open).toBe(false);
|
||||
fireEvent.click(summary);
|
||||
expect(details.open).toBe(true);
|
||||
expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders historical reasoning as independent collapsed sections', async () => {
|
||||
const harness = createProjectSupervisorRuntimeHarness({
|
||||
designAgentView: designHistoryView(),
|
||||
});
|
||||
window.__TAURI__ = {
|
||||
core: { invoke: harness.invoke },
|
||||
event: { listen: harness.listen },
|
||||
};
|
||||
window.history.pushState({}, '', '/');
|
||||
render(
|
||||
React.createElement(App, {
|
||||
initialProjectPath: harness.projectPath,
|
||||
orchestrationMode: 'single-supervisor',
|
||||
planningStartMode: true,
|
||||
projectSupervisorOnly: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const summaries = await screen.findAllByText('思考过程');
|
||||
expect(summaries).toHaveLength(2);
|
||||
const details = summaries.map(
|
||||
(summary) => summary.closest('details') as HTMLDetailsElement,
|
||||
);
|
||||
expect(details.every((element) => !element.open)).toBe(true);
|
||||
fireEvent.click(summaries[0]);
|
||||
expect(details[0].open).toBe(true);
|
||||
expect(details[1].open).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -741,6 +741,9 @@ function createProjectSupervisorRuntimeHarness({
|
||||
};
|
||||
}) => void)
|
||||
| null = null;
|
||||
let designAgentUpdateHandler:
|
||||
| ((event: { payload: Record<string, unknown> }) => void)
|
||||
| null = null;
|
||||
|
||||
const conversationRecord = (
|
||||
role: 'user' | 'assistant',
|
||||
@@ -1113,6 +1116,10 @@ function createProjectSupervisorRuntimeHarness({
|
||||
if (eventName === 'game-creator-agent-progress') {
|
||||
progressHandler = handler as unknown as typeof progressHandler;
|
||||
}
|
||||
if (eventName === 'design-agent-update') {
|
||||
designAgentUpdateHandler =
|
||||
handler as unknown as typeof designAgentUpdateHandler;
|
||||
}
|
||||
return () => {
|
||||
if (runtimeUpdateHandler === handler) {
|
||||
runtimeUpdateHandler = null;
|
||||
@@ -1123,6 +1130,9 @@ function createProjectSupervisorRuntimeHarness({
|
||||
if (progressHandler === handler) {
|
||||
progressHandler = null;
|
||||
}
|
||||
if (designAgentUpdateHandler === handler) {
|
||||
designAgentUpdateHandler = null;
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -1149,6 +1159,9 @@ function createProjectSupervisorRuntimeHarness({
|
||||
setPlanningV2Result(state: Record<string, unknown> | null) {
|
||||
currentPlanningV2Result = state;
|
||||
},
|
||||
emitDesignAgentEvent(payload: Record<string, unknown>) {
|
||||
designAgentUpdateHandler?.({ payload });
|
||||
},
|
||||
setPlanningV2StartResult(state: Record<string, unknown> | null) {
|
||||
currentPlanningV2StartResult = state;
|
||||
},
|
||||
|
||||
@@ -65,11 +65,5 @@ GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
# AGC 官方 LLM Router:api / all 角色启动时硬校验官方 HTTPS 地址、固定模型和以下三个密钥,缺失直接拒绝启动。
|
||||
# 容器预览的密钥来自镜像内 /srv/genarrative/.env.secrets.local(Jenkins 预览 secrets 副本),
|
||||
# 该副本必须包含 GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET、
|
||||
# GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET 和 GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN。
|
||||
# 本文件只用于按实例显式覆盖;容器环境变量留空时仍由镜像内 .env.secrets.local 提供取值。
|
||||
# GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
|
||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
# 【里程碑】Provider 推理与正文分离及策划 Agent 展示
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| Version | 1.0 |
|
||||
| Status | in-progress |
|
||||
| Date | 2026-09-14 |
|
||||
| Parent Spec | `docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md` |
|
||||
| Related Issue | `GenarrativeAI/Genarrative#331` |
|
||||
|
||||
## 一句话交付结果
|
||||
|
||||
让策划 Agent 能在流式回合中单独收到 Provider reasoning,并在 UI 中以默认折叠的思考过程展示;用户可见正文、工具调用和 GameAgent 现有行为保持不变。
|
||||
|
||||
## 当前实现进度(2026-09-14)
|
||||
|
||||
- 已完成共享 reasoning 字段、Provider 解析、策划事件映射以及正文流式收尾的前两轮提交。
|
||||
- 当前第三轮聚焦策划 Agent 前端 reasoning 生命周期:按 `projectPath + clientTurnId` 绑定事件,回合结束后保留本轮 reasoning,下一轮或项目切换时清理。
|
||||
- 现有 `<details>` 展示保持默认收起,并提供明确的展开/收起入口;不新增持久化字段,也不改动 GameAgent、Direct/Codex、supervisor 或已退役链路。
|
||||
- 已核对实际会话产物:Responses 原生 `history` 已持久化 `reasoning` / `reasoning_text`,当前修复补齐 `reasoning_text` 提取,并在策划会话恢复时回填最近一轮 reasoning。
|
||||
|
||||
## 背景与现状
|
||||
|
||||
- `platform-llm` 当前只向上层提供正文累计值、正文增量和结束状态。
|
||||
- Chat 兼容响应中的 `reasoning`、`reasoning_content` 以及 reasoning content part 会被正文提取器过滤。
|
||||
- Responses 响应中的 reasoning 类型 output item 也不会进入独立的上层字段。
|
||||
- 策划 Agent 已经预留 `DesignEvent.reasoningText`、`planningV2Reasoning` 和默认折叠 UI,但 Provider 解析链没有产出数据,因此折叠区通常不出现。
|
||||
- GameAgent 当前只消费 `delta_text`、`accumulated_text` 和 `finish_reason`,没有消费策划 Agent 的 `reasoningText`。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 为 Provider 流式响应增加独立 reasoning 增量和累计通道。
|
||||
2. 为非流式终态响应提供独立 reasoning 字段。
|
||||
3. 支持 Responses 和 Chat 兼容协议的 reasoning 解析。
|
||||
4. 仅由策划 Agent 显式启用 reasoning 捕获和 UI 转发。
|
||||
5. 保证 reasoning 不进入用户可见正文、工具调用参数或 GameAgent 消息流。
|
||||
6. 在无 reasoning、reasoning 解析异常、重试和工具调用共存场景下保持可恢复行为。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改变 GameAgent 的正文展示、工具调用、`<think>` 过滤和运行时状态语义。
|
||||
- 不把 reasoning 自动拼接到 `delta_text`、`accumulated_text` 或正式 assistant 消息。
|
||||
- 不把 reasoning 作为新的业务消息类型写入策划会话历史。
|
||||
- 不新增通用 reasoning UI,不改造 Direct/Codex 的过程卡展示。
|
||||
- 不修改 Provider 请求模型、推理档位或 token 预算。
|
||||
- 不为 reasoning 增加新的 SpacetimeDB 表、公开 API 或持久化 schema。
|
||||
|
||||
## 受影响模块与边界
|
||||
|
||||
### Provider 共享层
|
||||
|
||||
`server-rs/crates/platform-llm` 负责协议解析和流式累计:
|
||||
|
||||
- `LlmStreamDelta` 增加 `reasoning_delta` 与 `accumulated_reasoning`。
|
||||
- `LlmRunResponse` 增加终态 reasoning 字段。
|
||||
- `LlmRunRequest` 增加默认关闭的 reasoning 捕获开关。
|
||||
- 正文提取继续排除隐藏 reasoning part;reasoning 进入旁路字段。
|
||||
- reasoning 解析失败只丢弃 reasoning,不影响正文和工具调用。
|
||||
|
||||
### 策划 Runtime
|
||||
|
||||
`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs` 仅在策划专用请求中打开 reasoning 捕获:
|
||||
|
||||
- 流式 reasoning 更新映射到已有 `DesignEvent.reasoningText`。
|
||||
- 正文继续使用已有 `text` 事件。
|
||||
- 新回合、重试、项目切换和请求失败时清理旧 reasoning。
|
||||
- debug 记录与正文记录分开,内容受现有 debug 开关和长度限制约束。
|
||||
|
||||
### 其它调用方
|
||||
|
||||
GameAgent、Agent Interaction、Direct/Codex 适配层和通用 runtime 继续只读取正文字段。新增 reasoning 字段默认为空,不改变这些调用方的业务判断。
|
||||
|
||||
### 前端
|
||||
|
||||
复用现有 `ProjectSupervisorView` 的 `designReasoning` 和默认折叠 `<details>` 展示。只补事件生命周期和状态清理,不新建平行组件或平行状态协议。
|
||||
|
||||
## 分步实施方案
|
||||
|
||||
### 第一步:冻结共享契约与兼容开关
|
||||
|
||||
明确字段语义、空值语义和捕获开关:
|
||||
|
||||
- reasoning 字段只表示 Provider 返回的内部推理内容,不代表用户正文。
|
||||
- 捕获开关默认关闭;未启用时新增字段为空。
|
||||
- 正文、工具调用、finish reason 和 Responses 原生 output 的现有语义保持不变。
|
||||
- 该步只更新规范、类型定义和构造点,不接入策划 UI。
|
||||
|
||||
验收重点:所有现有 Rust 构造点可编译,GameAgent 现有调用仍只依赖正文字段。
|
||||
|
||||
### 第二步:实现 `platform-llm` 协议解析
|
||||
|
||||
分别补齐:
|
||||
|
||||
- Responses reasoning 增量事件;
|
||||
- Responses 终态 reasoning output item / summary;
|
||||
- Chat `reasoning`、`reasoning_content` 和 reasoning content part;
|
||||
- 正文与 reasoning 的独立累计;
|
||||
- reasoning 与正文、工具调用同时出现时的顺序和去重;
|
||||
- reasoning 解析失败时的降级行为。
|
||||
|
||||
Responses 的原生 output 仍按当前方式保留,用于后续 Responses 会话回放;新增 reasoning 字段只用于上层展示和调试消费。
|
||||
|
||||
验收重点:正文永远不含 reasoning;无 reasoning 的响应与当前行为一致。
|
||||
|
||||
### 第三步:补齐共享适配层并锁定 GameAgent 不变
|
||||
|
||||
更新 `LlmStreamDelta` 构造点、适配器和测试辅助函数,使它们为新增字段提供空值。检查并锁定:
|
||||
|
||||
- GameAgent 正文流不读取 reasoning;
|
||||
- 工具调用判断不读取 reasoning;
|
||||
- Direct/Codex 过程卡不显示 reasoning;
|
||||
- 通用 response stream 过滤逻辑不因新增字段改变。
|
||||
|
||||
验收重点:现有工具调用、正文流式、Direct 和 Agent Interaction 测试无行为回归。
|
||||
|
||||
### 第四步:接通策划 Runtime 与现有 UI
|
||||
|
||||
仅在策划 Agent Provider 请求中启用捕获开关:
|
||||
|
||||
- 收到 reasoning 增量时发出独立 `reasoningText`;
|
||||
- 收到正文增量时继续发出原有 `text`;
|
||||
- 重试时替换同一回合的临时 reasoning,不残留上一 attempt;
|
||||
- 正式回合结束后保留本回合展示,下一回合开始时清理;
|
||||
- UI 默认折叠,展开后显示累计 reasoning,不影响正文滚动和输入。
|
||||
|
||||
验收重点:策划 Agent 能看到独立 reasoning,正文气泡不重复、不混入推理文本。
|
||||
|
||||
### 第五步:完成回归、文档和验收证据
|
||||
|
||||
形成逐条证据矩阵,至少覆盖:
|
||||
|
||||
- Responses reasoning 增量和终态;
|
||||
- Chat reasoning 字段和 content part;
|
||||
- 正文与 reasoning 分离;
|
||||
- reasoning 与工具调用并存;
|
||||
- reasoning 解析失败降级;
|
||||
- 无 reasoning 兼容行为;
|
||||
- 策划 Runtime 事件映射和 UI 生命周期;
|
||||
- GameAgent 正文与工具调用回归。
|
||||
|
||||
## 第五轮验收证据
|
||||
|
||||
| 验收面 | 证据 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| Chat / Responses reasoning 解析 | `cargo test --manifest-path server-rs/Cargo.toml -p platform-llm` | PASS,152 个单元测试;含字段、content part、SSE 增量、终态快照和正文隔离 |
|
||||
| reasoning 与工具调用共存 | `responses_response_captures_reasoning_alongside_tool_call`、既有 Chat/Responses 流式工具测试 | PASS |
|
||||
| 默认关闭与请求兼容 | `run_request_defaults_to_openai_responses_api_kind`、`reasoning_capture_switch_does_not_change_provider_request_body` | PASS |
|
||||
| 策划 Runtime 生命周期 | `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell design_runtime` | PASS,10 个测试;含事件映射、history 隔离、重试清理和失败清理 |
|
||||
| GameAgent / Direct/Codex 正文回归 | `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --tests` 与现有 response stream / direct tests 编译 | PASS;新增字段未进入正文消费路径 |
|
||||
| 前端与文档门禁 | `npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check` | PASS |
|
||||
| 格式门禁 | `cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check`、AGC Tauri 同命令 | PASS |
|
||||
|
||||
真实 Provider、浏览器运行时 smoke 和 `check-config.mjs` 的 Windows 私有 DACL 路径本轮未验证;前者需要凭据和运行环境,后者受当前沙箱权限限制,不能据此扩大验收结论。
|
||||
|
||||
## 契约与持久化策略
|
||||
|
||||
- 不修改 HTTP API、OpenAPI、SpacetimeDB schema 或生成绑定。
|
||||
- 不新增正式持久化字段;策划会话仍保存既有对话和 Responses 原生 output。
|
||||
- reasoning 捕获开关属于 Provider 请求的内部调用语义,默认关闭,不改变已有请求的默认指纹和展示行为。
|
||||
- reasoning 不作为下一轮普通用户可见正文回灌;Responses 原生 output 的恢复语义保持现状。
|
||||
|
||||
## 失败、重试与恢复
|
||||
|
||||
- reasoning 解析失败:保留正文和工具调用,reasoning 字段置空或保留已累计部分。
|
||||
- Provider 瞬态重试:reasoning 与正文使用同一回合、同一响应槽,新的 attempt 替换临时值。
|
||||
- 流中断:沿用现有 Provider 错误和策划会话恢复规则,不把未完成 reasoning 误判为正式消息。
|
||||
- UI 刷新或项目恢复:只从当前事件/状态恢复 reasoning,不隐式唤醒 Provider。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
| 风险 | 控制措施 | 回滚点 |
|
||||
| --- | --- | --- |
|
||||
| 共享结构体新增字段导致构造点遗漏 | 先补齐所有构造点和编译检查 | 回退共享字段提交 |
|
||||
| Provider 把 reasoning 混入正文 | 保留独立提取器和正文过滤测试 | 关闭 reasoning 捕获开关 |
|
||||
| Responses summary 事件重复累计 | 以增量事件为主,终态仅做快照/兜底 | 关闭对应事件解析 |
|
||||
| GameAgent 意外展示 reasoning | 捕获默认关闭,调用方只读正文字段 | 回退策划开关,不影响共享解析 |
|
||||
| 重试残留旧 reasoning | 按回合和响应槽清理/替换 | 回退 UI 事件消费 |
|
||||
|
||||
## 验收命令
|
||||
|
||||
代码实现阶段按里程碑执行,不在本计划阶段运行业务测试。预计命令:
|
||||
|
||||
```text
|
||||
cargo test -p platform-llm
|
||||
cargo test -p ai-game-creator-shell
|
||||
npm run typecheck
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
文档阶段已要求补充运行:
|
||||
|
||||
```text
|
||||
npm run check:doc-index
|
||||
npm run check:encoding
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## 第六轮定位:历史 reasoning 位置修复
|
||||
|
||||
持久化 Responses history 中,reasoning 不一定紧邻可见 `message`:一次 Provider 响应可能先产生 reasoning 和多个 `function_call`,下一次响应才产生正文。原实现遇到下一段 reasoning 就提前结束上一段,无法绑定到 `session.messages` 中的正确 assistant 响应,前端遂把无 `messageId` 的条目统一追加到列表底部。
|
||||
|
||||
修复方式是按每个用户回合分别收集:
|
||||
|
||||
- `session.history` 中 reasoning 的响应顺序;
|
||||
- `session.messages` 中 assistant 消息的响应顺序。
|
||||
|
||||
两者按顺序绑定,允许 reasoning 跨越 tool-only Responses;同一正文对应多段 reasoning 时前端合并显示,仍使用默认折叠的单个思考区域。只有没有任何可见 assistant 消息的异常历史才保留为底部 orphan。
|
||||
|
||||
验证:新增 tool-only Responses 顺序回归测试,策划 Runtime 定向测试 11/11 通过;前端聚合逻辑保留历史多段 reasoning 的顺序。
|
||||
|
||||
## 当前状态与下一步
|
||||
|
||||
当前已完成 Provider 解析、策划 Runtime 生命周期、历史 reasoning 恢复及响应顺序归属修复;待本轮提交后继续按定向回归结果推进后续验收。字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 不进入普通上下文的约束保持不变。
|
||||
@@ -156,6 +156,7 @@
|
||||
|
||||
- 背景:#211 要求 sidecar 满足当前用户独占、禁止继承的 DACL。新建文件会先继承父目录 ACE,生产路径把这种短暂不合格送进 UAC;`project.lock` 还在独占句柄上 harden。含空格项目路径上提权 ArgumentList 被拆开,修复以 exit 1 失败。GDD 审批改意见因此弹权限,V1 锁创建不会。
|
||||
- 决策:`harden_new_game_creator_private_path` 只在本进程收紧 owner/DACL,失败则删除刚创建的对象,不 UAC 接管。项目锁先写再释放句柄再 harden,并用内容回读防换绑;UAC 仍只用于允许范围内的已有外人本对象。提权 helper 的 ArgumentList 改为一条按 Windows 规则加引号的字符串。
|
||||
- 补充:逐级创建 `.agent`、`runtime`、`locks` 等目录时,即使祖先已有 `manifest.json`,刚由本进程创建的目录也必须直接走 owner/DACL 初始化,不能因 managed-path 判定进入 UAC;自动项目根目录同样在创建成功后立即本地加固。
|
||||
- 影响范围:`config.rs` 的新建 harden 与提权命令行、`project/write_lock.rs` 的项目锁创建;不改变锁竞争、失效回收、Drop 删除,也不放宽 symlink / reparse / 外人本 fail-closed。
|
||||
- 验证方式:Windows 定向测试覆盖 `Genarrative GameAgent\gameagent-*` 取锁与私有 DACL,以及带空格路径的 quoted ArgumentList。
|
||||
- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/pitfalls.md`。
|
||||
@@ -307,6 +308,7 @@
|
||||
|
||||
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。
|
||||
- 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。
|
||||
- 补充:策划项目切换到 GameAgent 时,`design_artifacts` 的新增或登记信息实际变化必须与一次项目 revision 推进配对;重复切换不重复推进,避免 manifest 已变化而 revision 仍停留在旧值,触发前端同 revision 清单冲突提示。
|
||||
- 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。
|
||||
- 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。
|
||||
- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user