解决platform-llm和策划agent reasoning问题,并修复若干bug (#350)
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 4m44s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m9s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 4m33s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 3m53s
Project CI / AI game creator shell Rust crates (push) Successful in 2m51s
Project CI / Frontend tests (push) Successful in 4m58s
Project CI / Repository checks (push) Successful in 3m18s
Project CI / Native shell tests (push) Successful in 6m10s
Project CI / Backend tests (push) Successful in 7m7s
Project CI / AI game creator shell web tests (push) Successful in 2m16s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (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 shard 1/4 (pull_request) Has been cancelled

Reviewed-on: #350
This commit was merged in pull request #350.
This commit is contained in:
2026-09-15 00:50:36 +08:00
parent 0ced0c2564
commit 9aa6f5efea
28 changed files with 1744 additions and 116 deletions
@@ -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,8 +688,15 @@ 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;
let mut emitted_reasoning = String::new();
client
.stream_run(request.clone(), |delta| {
stream_sequence = stream_sequence.saturating_add(1);
@@ -565,18 +712,33 @@ 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()
|| delta.accumulated_reasoning != emitted_reasoning
{
emit(design_reasoning_event(
root,
&turn_id,
Some(&message_id),
delta.accumulated_reasoning.clone(),
));
emitted_reasoning = delta.accumulated_reasoning.clone();
}
})
.await
} else {
@@ -584,6 +746,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 +776,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 +830,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 +858,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 +1146,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 +1384,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 +1525,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 +1586,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,
@@ -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(
@@ -1319,15 +1319,10 @@ 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: initialize it in
// process first, with a narrowly-scoped managed-path fallback
// only if Windows rejects that local ACL update.
harden_new_game_creator_private_path(&directory, true, label)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -1366,15 +1361,7 @@ 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,
)?;
}
harden_new_game_creator_private_path(&directory, true, label)?;
}
Err(error) => {
return Err(format!(
@@ -1439,15 +1426,34 @@ pub(crate) fn harden_new_game_creator_private_path(
path.display()
));
}
// This invocation created the object, so its owner is the current
// user. Tighten the inherited descriptor in-process; UAC repair is
// reserved for existing, externally-owned objects.
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
)?;
// This invocation created the object, so local hardening is always
// the first path. Some Windows configurations can nevertheless
// reject the descriptor update (for example when an inherited ACL is
// protected by the parent). Only a managed path may use the existing
// one-shot repair in that exceptional case; ordinary new projects do
// not prompt for UAC.
if let Err(local_error) =
secure_windows_game_creator_path_for_current_user_with_owner_policy(
path,
is_directory,
true,
true,
)
{
if !game_creator_private_path_allows_auto_elevation(path)
|| !windows_acl_error_may_need_elevation(&local_error)
{
return Err(local_error);
}
secure_windows_game_creator_path_for_current_user_with_auto_elevation(
path,
is_directory,
true,
)
.map_err(|repair_error| {
format!("{local_error};新建对象的受控 ACL 修复未完成:{repair_error}")
})?;
}
}
#[cfg(unix)]
{
@@ -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 {
+187 -17
View File
@@ -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,28 @@ export function App({
}
}
function designAgentEventSubscriptionReady() {
// 业务动作只读取当前订阅代次;清理与下一次 effect 建立之间不能创建
// 一个没有订阅 effect 接管的悬挂 promise。
return designAgentEventSubscriptionReadyRef.current ?? Promise.resolve();
}
function createDesignAgentEventSubscriptionReady() {
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 +1018,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 +1034,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 +1053,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 +1147,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 +1167,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 +1179,10 @@ export function App({
setProjectSupervisorRuntimeError(message);
setPlanGddError(message);
} finally {
designAgentTurnRef.current = null;
setPlanningV2TransientReplyTarget('');
if (!designAgentPendingViewRef.current) {
designAgentTurnRef.current = null;
setPlanningV2TransientReplyTarget('');
}
setChatAgentBusy(false);
}
}
@@ -1555,6 +1674,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 +2112,15 @@ export function App({
}, [planningV2Active]);
useEffect(() => {
const ready = createDesignAgentEventSubscriptionReady();
if (!canSubscribeTauriEvents() || !planningV2Active) {
return;
resolveDesignAgentEventSubscriptionReady();
return () => {
if (designAgentEventSubscriptionReadyRef.current === ready) {
designAgentEventSubscriptionReadyRef.current = null;
createDesignAgentEventSubscriptionReady();
}
};
}
let cleanup: (() => void) | null = null;
let disposed = false;
@@ -2008,26 +2137,46 @@ 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;
createDesignAgentEventSubscriptionReady();
}
};
// applyDesignView 读的是 refs 和当前项目路径,
// 把它写进依赖会在每轮回复时重订事件。
@@ -6072,6 +6221,7 @@ export function App({
setChatAgentBusy(true);
setProjectSupervisorRuntimeError('');
setPlanningV2TransientReplyTarget('');
setPlanningV2Reasoning('');
try {
const result = currentSessionId
? await invoke<PlanningSessionCommandResultV2>(
@@ -11796,7 +11946,11 @@ export function App({
? directCodexTransientReply
: projectSupervisorTransientReply
}
showDesignReasoning={planningV2Active}
designReasoning={planningV2Reasoning}
designReasoningEntries={
useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : []
}
visibleMessages={visibleMessages}
visibleProfessionalAgentCards={visibleProfessionalAgentCards}
showProfessionalCollaboration={
@@ -11824,15 +11978,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 +12006,11 @@ export function App({
) {
return;
}
applyDesignView(view, nextProjectPath);
applyDesignAgentViewAfterTransient(
view,
nextProjectPath,
clientTurnId,
);
})
.catch((error) => {
if (
@@ -11864,8 +12032,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 {
@@ -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,
@@ -96,7 +100,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
projectPath: string;
showProfessionalCollaboration?: boolean;
transientReply: string;
showDesignReasoning?: boolean;
designReasoning?: string;
designReasoningEntries?: DesignReasoningEntry[];
visibleMessages: ChatMessage[];
visibleProfessionalAgentCards: AgentStatusCard[];
workspaceStatus: string;
@@ -148,7 +154,9 @@ export function ProjectSupervisorView({
projectPath,
showProfessionalCollaboration = true,
transientReply,
showDesignReasoning = false,
designReasoning = '',
designReasoningEntries = [],
visibleMessages,
visibleProfessionalAgentCards,
workspaceStatus,
@@ -260,11 +268,36 @@ export function ProjectSupervisorView({
role={message.role}
text={projectSupervisorChatMessageText(message)}
/>
{showDesignReasoning && message.reasoningText ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary>思考过程</summary>
<pre>{message.reasoningText}</pre>
</details>
) : null}
</div>
))}
{designReasoning ? (
<details className="design-agent-reasoning">
<summary>显示思考过程</summary>
{showDesignReasoning &&
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>
))}
{showDesignReasoning && designReasoning ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary>思考过程</summary>
<pre>{designReasoning}</pre>
</details>
) : null}
@@ -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;
},