补齐智能体持久化用户输入流程

新增 user.input_request 状态机与私有 sidecar,支持重启恢复、取消和同一 Run 续跑
接入终端聊天、开发 Agent 聊天、项目 Agent 对话和 Project Supervisor 问题卡
扩展任务队列、能力清单与前后端共享契约
补齐单元测试、界面回归、真实 Runtime E2E 和技术文档
This commit is contained in:
AIGameCreator App
2026-07-16 03:44:31 +08:00
parent 0425afd5cf
commit e399eaf5de
19 changed files with 4474 additions and 93 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -785,7 +785,8 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
if current.state.run_id == canonical_run_id
&& (current.state.status == "idle"
|| current.state.status == "failed"
|| current.state.status == "waiting-for-confirmation")
|| current.state.status == "waiting-for-confirmation"
|| current.state.status == "waiting-for-user-input")
{
break Ok::<AgentRuntimeState, String>(current.state);
}
@@ -820,6 +821,8 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
Ok(())
} else if terminal.status == "waiting-for-confirmation" {
Err("单 Agent 任务正在等待开发者确认,请在开发窗口继续".to_string())
} else if terminal.status == "waiting-for-user-input" {
Err("单 Agent 任务正在等待用户回答,请使用 agc:chat 继续".to_string())
} else {
Err(format!(
"单 Agent 任务未完成:{} / {}",
@@ -691,6 +691,32 @@ pub(crate) fn reject_game_creator_agent_runtime_task(
)
}
#[tauri::command]
pub(crate) fn answer_game_creator_agent_runtime_user_input(
project_path: String,
agent_id: String,
run_id: String,
action_id: String,
request_id: String,
response_id: String,
answers: BTreeMap<String, String>,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
enforce_project_permission_policy(root, "agent.resume")?;
answer_game_creator_agent_runtime_user_input_at(
root,
agent_id.trim(),
run_id.trim(),
action_id.trim(),
request_id.trim(),
response_id.trim(),
answers,
)
}
#[tauri::command]
pub(crate) fn read_game_creator_agent_runtime(
project_path: String,
@@ -583,28 +583,10 @@ pub(crate) fn edit_game_creator_agent_goal_at(
&& runtime_before.state.run_id == goal.run_id
&& matches!(
runtime_before.state.status.as_str(),
"running" | "waiting-for-confirmation"
"running" | "waiting-for-confirmation" | "waiting-for-user-input"
)
{
let steer_id = format!("goal-edit-{}-{}", goal.goal_id, goal.revision);
let steer = steer_game_creator_agent_runtime_task_at(
root,
&agent_id,
session_id,
&goal.run_id,
&steer_id,
&instruction,
"goal-edit",
)?;
provider_interrupted = steer.provider_interrupted;
if external_agent_runner_enabled()
&& !external_agent_runner_is_server_process()
&& !provider_interrupted
{
provider_interrupted =
steer_external_agent_runner(root, &agent_id, &goal.run_id, &steer_id)?;
}
if runtime_before.state.status == "waiting-for-confirmation" {
if runtime_before.state.status == "waiting-for-user-input" {
if external_agent_runner_enabled() && !external_agent_runner_is_server_process() {
wake_external_agent_runner_pending_for_run(
root,
@@ -615,6 +597,37 @@ pub(crate) fn edit_game_creator_agent_goal_at(
} else {
let _ = resume_game_creator_agent_background_tasks_at(root)?;
}
} else {
let steer_id = format!("goal-edit-{}-{}", goal.goal_id, goal.revision);
let steer = steer_game_creator_agent_runtime_task_at(
root,
&agent_id,
session_id,
&goal.run_id,
&steer_id,
&instruction,
"goal-edit",
)?;
provider_interrupted = steer.provider_interrupted;
if external_agent_runner_enabled()
&& !external_agent_runner_is_server_process()
&& !provider_interrupted
{
provider_interrupted =
steer_external_agent_runner(root, &agent_id, &goal.run_id, &steer_id)?;
}
if runtime_before.state.status == "waiting-for-confirmation" {
if external_agent_runner_enabled() && !external_agent_runner_is_server_process() {
wake_external_agent_runner_pending_for_run(
root,
&agent_id,
&goal.run_id,
runtime_before.state.loop_iteration,
)?;
} else {
let _ = resume_game_creator_agent_background_tasks_at(root)?;
}
}
}
} else if runtime_before.state.run_id == goal.run_id {
refresh_game_creator_agent_goal_runtime_projection_at(root, &goal)?;
@@ -67,6 +67,7 @@ mod project;
mod repository_context;
mod runner;
mod swarm_cli;
mod user_input;
mod windows;
use agent::*;
@@ -92,6 +93,7 @@ use project::*;
use repository_context::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
use windows::*;
#[derive(Debug, Eq, PartialEq, Serialize)]
@@ -389,6 +391,8 @@ struct AgentRuntimeTaskQueueSummary {
#[serde(default)]
waiting_for_confirmation: u32,
#[serde(default)]
waiting_for_user_input: u32,
#[serde(default)]
paused: u32,
#[serde(default)]
cancelled: u32,
@@ -409,6 +413,7 @@ impl Default for AgentRuntimeTaskQueueSummary {
pending: 0,
running: 0,
waiting_for_confirmation: 0,
waiting_for_user_input: 0,
paused: 0,
cancelled: 0,
completed: 0,
@@ -564,6 +569,7 @@ struct AgentRuntimeResult {
recent_events: Vec<AgentRuntimeEvent>,
recent_tasks: Vec<AgentRuntimeTaskRecord>,
response_stream: Option<AgentRuntimeResponseStream>,
user_input_request: Option<AgentRuntimeUserInputRequestView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -1689,6 +1695,7 @@ fn main() {
retry_game_creator_agent_runtime_task,
confirm_game_creator_agent_runtime_task,
reject_game_creator_agent_runtime_task,
answer_game_creator_agent_runtime_user_input,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
@@ -2678,6 +2678,8 @@ struct ExternalAgentRunnerTaskQueueProbe {
#[serde(default, alias = "waiting")]
waiting_for_confirmation: u64,
#[serde(default)]
waiting_for_user_input: u64,
#[serde(default)]
running: u64,
}
@@ -2796,6 +2798,7 @@ fn external_agent_runner_root_is_idle(root: &Path) -> Result<bool, String> {
.map_err(|_| format!("解析 Agent Runtime 状态失败:{}", entry.path().display()))?;
if runtime.task_queue.pending > 0
|| runtime.task_queue.waiting_for_confirmation > 0
|| runtime.task_queue.waiting_for_user_input > 0
|| runtime.task_queue.running > 0
{
return Ok(false);
@@ -44,6 +44,8 @@ struct SwarmRuntimeObserver {
state_signatures: BTreeMap<String, String>,
seen_events: BTreeSet<String>,
handled_confirmations: BTreeSet<String>,
handled_user_input_requests: BTreeSet<String>,
user_input_response_ids: BTreeMap<String, String>,
response_streams: BTreeMap<String, SwarmResponseStreamCursor>,
open_response_line: Option<SwarmResponseStreamLine>,
}
@@ -822,6 +824,7 @@ fn print_swarm_status<W: Write>(root: &Path, output: &mut W) -> Result<(), Strin
|| runtime.task_queue.pending > 0
|| runtime.task_queue.running > 0
|| runtime.task_queue.waiting_for_confirmation > 0
|| runtime.task_queue.waiting_for_user_input > 0
}) {
print_runtime_state(&runtime.state, &runtime.task_queue, output)?;
print_runtime_response_stream_status(runtime.response_stream.as_ref(), output)?;
@@ -918,6 +921,21 @@ fn wait_for_swarm_turn<W: Write>(
SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit),
SwarmConfirmationResolution::None => {}
}
match observer.resolve_user_input_requests(
root,
parent_agent_id,
&runtimes,
input,
output,
)? {
SwarmConfirmationResolution::Handled => {
stable_since = None;
recovery_scan_required = true;
continue;
}
SwarmConfirmationResolution::Quit => return Ok(SwarmTurnOutcome::Quit),
SwarmConfirmationResolution::None => {}
}
if last_runner_check.elapsed() >= Duration::from_secs(2) {
let runner = read_external_agent_runner_status();
last_runner_check = Instant::now();
@@ -1029,11 +1047,16 @@ fn runtimes_are_busy(runtimes: &[AgentRuntimeResult]) -> bool {
runtimes.iter().any(|runtime| {
matches!(
runtime.state.status.as_str(),
"pending" | "running" | "waiting-for-confirmation" | "cancelling"
"pending"
| "running"
| "waiting-for-confirmation"
| "waiting-for-user-input"
| "cancelling"
) || runtime.state.phase == "needs-reconciliation"
|| runtime.task_queue.pending > 0
|| runtime.task_queue.running > 0
|| runtime.task_queue.waiting_for_confirmation > 0
|| runtime.task_queue.waiting_for_user_input > 0
})
}
@@ -1044,6 +1067,8 @@ fn swarm_reconciliation_agents(runtimes: &[AgentRuntimeResult]) -> Vec<String> {
runtime.state.phase == "needs-reconciliation"
|| (runtime.state.status == "waiting-for-confirmation"
&& runtime.state.pending_tool_action.is_none())
|| (runtime.state.status == "waiting-for-user-input"
&& runtime.user_input_request.is_none())
})
.map(|runtime| runtime.state.agent_id.clone())
.collect()
@@ -1497,6 +1522,9 @@ impl SwarmRuntimeObserver {
output: &mut W,
) -> Result<SwarmConfirmationResolution, String> {
for runtime in runtimes {
if runtime.state.status != "waiting-for-confirmation" {
continue;
}
let Some(pending) = runtime.state.pending_tool_action.as_ref() else {
continue;
};
@@ -1556,6 +1584,115 @@ impl SwarmRuntimeObserver {
}
Ok(SwarmConfirmationResolution::None)
}
fn resolve_user_input_requests<W: Write>(
&mut self,
root: &Path,
parent_agent_id: &str,
runtimes: &[AgentRuntimeResult],
input: &Receiver<SwarmInputEvent>,
output: &mut W,
) -> Result<SwarmConfirmationResolution, String> {
for runtime in runtimes {
let Some(request) = runtime.user_input_request.as_ref() else {
continue;
};
if runtime.state.agent_id != parent_agent_id
|| runtime.state.status != "waiting-for-user-input"
{
continue;
}
let key = format!(
"{}:{}:{}",
runtime.state.agent_id, runtime.state.run_id, request.request_id
);
if self.handled_user_input_requests.contains(&key) {
continue;
}
self.close_response_line(output)?;
writeln!(
output,
"\n[Needs input] agent={} run={} request={}",
runtime.state.agent_id, runtime.state.run_id, request.request_id
)
.map_err(|error| format!("写入终端失败:{error}"))?;
let mut answers = BTreeMap::new();
for question in &request.questions {
writeln!(output, "\n{}{}", question.header, question.question)
.map_err(|error| format!("写入终端失败:{error}"))?;
for (index, option) in question.options.iter().enumerate() {
writeln!(
output,
" {}. {} - {}",
index + 1,
option.label,
option.description
)
.map_err(|error| format!("写入终端失败:{error}"))?;
}
loop {
write!(
output,
"请选择 1-{},或直接输入其他答案:",
question.options.len()
)
.map_err(|error| format!("写入终端失败:{error}"))?;
output
.flush()
.map_err(|error| format!("刷新终端失败:{error}"))?;
let Some(line) = receive_swarm_chat_line(input)? else {
return Err("用户输入已结束;Needs input 请求保持未回答".to_string());
};
if matches!(line.as_str(), "/quit" | "/exit") {
return Ok(SwarmConfirmationResolution::Quit);
}
if line == "/status" {
print_swarm_status(root, output)?;
continue;
}
if line == "/history" {
print_conversation_history(root, parent_agent_id, output)?;
continue;
}
let answer = line
.parse::<usize>()
.ok()
.and_then(|index| index.checked_sub(1))
.and_then(|index| question.options.get(index))
.map(|option| option.label.clone())
.unwrap_or_else(|| line.trim().to_string());
if answer.is_empty() {
writeln!(output, "回答不能为空。")
.map_err(|error| format!("写入终端失败:{error}"))?;
continue;
}
answers.insert(question.id.clone(), answer);
break;
}
}
let response_id = self
.user_input_response_ids
.entry(key.clone())
.or_insert_with(|| {
format!("swarm-user-input-{}-{}", request.request_id, unix_millis())
})
.clone();
answer_game_creator_agent_runtime_user_input_at(
root,
&runtime.state.agent_id,
&runtime.state.run_id,
&request.action_id,
&request.request_id,
&response_id,
answers,
)?;
writeln!(output, "[已回答] {}", request.request_id)
.map_err(|error| format!("写入终端失败:{error}"))?;
self.handled_user_input_requests.insert(key);
return Ok(SwarmConfirmationResolution::Handled);
}
Ok(SwarmConfirmationResolution::None)
}
}
fn print_runtime_state<W: Write>(
@@ -1575,7 +1712,7 @@ fn print_runtime_state<W: Write>(
.unwrap_or_default();
writeln!(
output,
"[状态] {} {}/{} run={} queue={}/{}/{}{} | {}",
"[状态] {} {}/{} run={} queue={}/{}/{}/{}{} | {}",
state.agent_id,
state.status,
state.phase,
@@ -1583,6 +1720,7 @@ fn print_runtime_state<W: Write>(
queue.pending,
queue.running,
queue.waiting_for_confirmation,
queue.waiting_for_user_input,
relation,
state.current_action
)
@@ -1720,7 +1858,7 @@ fn runtime_state_signature(
.map(|step| format!("{}:{}:{}", step.index, step.status, step.title))
.unwrap_or_default();
let mut signature = format!(
"{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
"{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}",
state.run_id,
state.status,
state.phase,
@@ -1729,6 +1867,7 @@ fn runtime_state_signature(
queue.pending,
queue.running,
queue.waiting_for_confirmation,
queue.waiting_for_user_input,
queue.updated_at,
state.plan_revision,
state
@@ -1796,6 +1935,7 @@ mod tests {
recent_events: Vec::new(),
recent_tasks: Vec::new(),
response_stream: None,
user_input_request: None,
}
}
@@ -1,7 +1,7 @@
use super::*;
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard};
@@ -916,6 +916,332 @@ fn agent_goal_finalization_v3_treats_new_revision_as_stale_before_assistant_writ
fs::remove_dir_all(root).ok();
}
#[test]
fn user_input_sidecar_answer_is_idempotent_and_public_metadata_is_private() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-user-input-sidecar", "用户输入状态机项目")
.expect("project init");
let state = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"根据用户平台选择继续实现",
"user-input-sidecar-run",
"agent-background-task",
"等待用户平台选择",
vec!["取得用户回答后继续".to_string()],
)
.expect("start user input runtime state");
let private_question = "PRIVATE_QUESTION_7FA2:首版优先支持哪个平台?";
let action = AgentRuntimeToolAction {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
reason: Some("平台选择会改变实现路径".to_string()),
input: serde_json::json!({
"questions": [{
"id": "target_platform",
"header": "目标平台",
"question": private_question,
"options": [
{"label": "Web", "description": "先交付浏览器版本。"},
{"label": "桌面端", "description": "先交付桌面客户端。"}
]
}]
}),
};
let pending = pending_tool_action_for_test(
&root,
&state,
action,
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT,
None,
);
let first_request = match prepare_game_creator_agent_user_input_request_at(&root, &pending)
.expect("prepare first user input request")
{
AgentRuntimeUserInputRecovery::Waiting(request) => request,
other => panic!("unexpected first user input recovery: {other:?}"),
};
let repeated_request = match prepare_game_creator_agent_user_input_request_at(&root, &pending)
.expect("repeat user input request")
{
AgentRuntimeUserInputRecovery::Waiting(request) => request,
other => panic!("unexpected repeated user input recovery: {other:?}"),
};
assert_eq!(first_request, repeated_request);
assert_eq!(first_request.questions.len(), 1);
assert_eq!(first_request.questions[0].question, private_question);
let private_answer = "PRIVATE_ANSWER_41D9";
let answers = BTreeMap::from([("target_platform".to_string(), private_answer.to_string())]);
let (answered_request, first_observation) =
answer_game_creator_agent_user_input_request_for_pending_at(
&root,
&pending,
&first_request.request_id,
"response-sidecar-stable",
answers.clone(),
)
.expect("answer user input request");
let (repeated_answer, repeated_observation) =
answer_game_creator_agent_user_input_request_for_pending_at(
&root,
&pending,
&first_request.request_id,
"response-sidecar-stable",
answers.clone(),
)
.expect("repeat identical answer");
assert_eq!(answered_request, repeated_answer);
assert_eq!(first_observation, repeated_observation);
assert_eq!(
answered_request.status,
AGENT_RUNTIME_USER_INPUT_STATUS_ANSWERED
);
let conflict = answer_game_creator_agent_user_input_request_for_pending_at(
&root,
&pending,
&first_request.request_id,
"response-sidecar-conflict",
answers,
)
.expect_err("different response id must fail closed");
assert!(conflict.contains("不同 responseId"));
let stale_request = answer_game_creator_agent_user_input_request_for_pending_at(
&root,
&pending,
"user-input-stale-request",
"response-sidecar-stable",
BTreeMap::from([("target_platform".to_string(), private_answer.to_string())]),
)
.expect_err("stale request id must fail closed");
assert!(stale_request.contains("requestId 已变化"));
let conversation = read_local_conversation_for_session_at(
&root,
Some("code-prototype"),
Some(&state.session_id),
)
.expect("read user input conversation");
assert_eq!(
conversation
.messages
.iter()
.filter(
|message| message.role == "assistant" && message.content.contains(private_question)
)
.count(),
1
);
assert_eq!(
conversation
.messages
.iter()
.filter(|message| message.role == "user" && message.content.contains(private_answer))
.count(),
1
);
append_agent_runtime_action_receipt(
&root,
&state,
&pending.action_id,
&pending.action_fingerprint,
GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
AGENT_RUNTIME_ACTION_EXECUTION_MODE_AUTO,
pending.input_summary.as_deref(),
&first_observation,
)
.expect("append sanitized user input receipt");
let public_agent_db =
fs::read_to_string(root.join(".agent/agent.db")).expect("read public Agent DB");
assert!(!public_agent_db.contains(private_question));
assert!(!public_agent_db.contains(private_answer));
assert!(public_agent_db.contains("answersSha256="));
let child_state = start_game_creator_agent_runtime_task_at(
&root,
"art-director",
"被委派的专业 Agent 不得直接打断用户",
"user-input-child-denied-run",
"agent-delegate",
"回传澄清需要",
Vec::new(),
)
.expect("start delegated child state");
let child_pending = pending_tool_action_for_test(
&root,
&child_state,
AgentRuntimeToolAction {
tool: GAME_CREATOR_USER_INPUT_REQUEST_TOOL.to_string(),
reason: Some("不应直接提问".to_string()),
input: serde_json::json!({
"questions": [{
"id": "art_style",
"header": "美术风格",
"question": "首版采用哪种风格?",
"options": [
{"label": "像素", "description": "采用像素风格。"},
{"label": "手绘", "description": "采用手绘风格。"}
]
}]
}),
},
AGENT_RUNTIME_PENDING_ACTION_STATUS_WAITING_FOR_USER_INPUT,
None,
);
let child_error = prepare_game_creator_agent_user_input_request_at(&root, &child_pending)
.expect_err("delegated child must not ask the terminal user");
assert!(child_error.contains("不能直接向终端用户请求输入"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn user_input_runtime_waits_then_continues_the_same_run_once() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-user-input-runtime", "用户输入完整回合项目")
.expect("project init");
let private_question = "PRIVATE_QUESTION_93BC:首版优先支持哪个平台?";
let private_answer = "PRIVATE_ANSWER_28E4";
let final_reply = "USER_INPUT_RESUMED_FINAL";
let (request_sender, request_receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec![
user_input_tool_plan_response(private_question),
final_tool_plan_response(final_reply),
],
Some(request_sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"agentLlm": {{
"code-prototype": {{
"apiKey": "user-input-runtime-key",
"baseUrl": {base_url:?},
"model": "user-input-runtime-model",
"apiKind": "openai_chat",
"stream": false,
"maxRetries": 0
}}
}}
}}"#
));
let run_id = "user-input-runtime-same-run";
start_game_creator_agent_background_task_at(
&root,
"code-prototype",
"实现一个会根据目标平台改变结构的首版功能",
run_id,
)
.expect("start user input Runtime turn");
request_receiver
.recv_timeout(Duration::from_secs(3))
.expect("first planning request");
let waiting = wait_for_agent_runtime_user_input(&root, "code-prototype");
assert_eq!(waiting.state.run_id, run_id);
assert_eq!(waiting.state.status, "waiting-for-user-input");
assert_eq!(waiting.state.phase, "waiting-for-user-input");
assert_eq!(waiting.task_queue.waiting_for_user_input, 1);
let request = waiting
.user_input_request
.expect("structured user input request");
assert_eq!(request.run_id, run_id);
assert_eq!(request.questions[0].question, private_question);
assert!(request_receiver
.recv_timeout(Duration::from_millis(200))
.is_err());
let answered = answer_game_creator_agent_runtime_user_input_at(
&root,
"code-prototype",
run_id,
&request.action_id,
&request.request_id,
"response-runtime-stable",
BTreeMap::from([("target_platform".to_string(), private_answer.to_string())]),
)
.expect("answer Runtime user input");
assert_eq!(answered.state.run_id, run_id);
let continuation_request = request_receiver
.recv_timeout(Duration::from_secs(3))
.expect("same-run continuation request");
assert!(continuation_request.contains(private_question));
assert!(continuation_request.contains(private_answer));
assert!(request_receiver
.recv_timeout(Duration::from_millis(200))
.is_err());
let completed = wait_for_agent_runtime_idle(&root, "code-prototype");
assert_eq!(completed.run_id, run_id);
assert_eq!(completed.phase, "completed");
assert_eq!(completed.last_response.as_deref(), Some(final_reply));
let result = read_game_creator_agent_runtime_at(&root, "code-prototype")
.expect("read completed user input Runtime");
assert!(result.user_input_request.is_none());
assert_eq!(result.task_queue.waiting_for_user_input, 0);
let conversation = read_local_conversation_for_session_at(
&root,
Some("code-prototype"),
Some(&completed.session_id),
)
.expect("read completed user input conversation");
assert_eq!(
conversation
.messages
.iter()
.filter(
|message| message.role == "assistant" && message.content.contains(private_question)
)
.count(),
1
);
assert_eq!(
conversation
.messages
.iter()
.filter(|message| message.role == "user" && message.content.contains(private_answer))
.count(),
1
);
assert_eq!(
conversation
.messages
.iter()
.filter(|message| message.role == "assistant" && message.content == final_reply)
.count(),
1
);
for path in [
game_creator_agent_runtime_task_path(&root, "code-prototype"),
game_creator_agent_runtime_event_path(&root, "code-prototype"),
root.join(".agent/agent.db"),
root.join(".agent/activity.jsonl"),
root.join(".agent/output.jsonl"),
] {
if !path.exists() {
continue;
}
let public_content = fs::read_to_string(&path).expect("read user input public surface");
assert!(
!public_content.contains(private_question),
"{}",
path.display()
);
assert!(
!public_content.contains(private_answer),
"{}",
path.display()
);
}
fs::remove_dir_all(root).ok();
}
struct TestRuntimeConfigDirGuard {
_lock: StdMutexGuard<'static, ()>,
previous: Option<PathBuf>,
@@ -1150,6 +1476,21 @@ fn wait_for_agent_runtime_confirmation(root: &Path, agent_id: &str) -> AgentRunt
runtime
}
fn wait_for_agent_runtime_user_input(root: &Path, agent_id: &str) -> AgentRuntimeResult {
let mut runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for user input");
for _ in 0..250 {
if runtime.state.status == "waiting-for-user-input" && runtime.user_input_request.is_some()
{
return runtime;
}
std::thread::sleep(Duration::from_millis(20));
runtime = read_game_creator_agent_runtime_at(root, agent_id)
.expect("read runtime while waiting for user input");
}
runtime
}
fn wait_for_agent_db_record_type(root: &Path, record_type: &str) -> Vec<Value> {
let mut records = read_agent_db_records_for_test(root);
for _ in 0..250 {
@@ -3130,6 +3471,37 @@ fn final_tool_plan_response(response: impl Into<String>) -> String {
.to_string()
}
fn user_input_tool_plan_response(question: &str) -> String {
serde_json::json!({
"thinkingSummary": "实现路径取决于用户选择,需要先暂停并澄清",
"planUpdate": null,
"plan": [],
"actions": [{
"tool": GAME_CREATOR_USER_INPUT_REQUEST_TOOL,
"reason": "确认首版目标平台",
"input": {
"questions": [{
"id": "target_platform",
"header": "目标平台",
"question": question,
"options": [
{
"label": "Web",
"description": "先交付浏览器可运行版本。"
},
{
"label": "桌面端",
"description": "先交付桌面客户端版本。"
}
]
}]
}
}],
"response": ""
})
.to_string()
}
fn mock_http_request_total_bytes(request: &[u8]) -> Option<usize> {
let header_end = request
.windows(4)
@@ -9945,7 +10317,7 @@ async fn background_agent_runtime_loads_same_agent_continuity_through_tool_obser
assert!(second_design_replan_request.contains("每轮工具预算: 3"));
assert!(second_design_replan_request.contains("当前计划步骤: #1 [active] 读取本 Agent Runtime"));
assert!(second_design_replan_request.contains(
"任务队列: total=2 pending=0 running=1 waiting=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second"
"任务队列: total=2 pending=0 running=1 waiting=0 needsInput=0 cancelled=0 completed=1 failed=0 latest=design-continuity-second"
));
assert!(second_design_replan_request.contains("首轮完成:已经读取连续上下文笔记。"));
assert!(second_design_replan_request.contains("最近工具: file.read / ok"));
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+114
View File
@@ -1629,6 +1629,111 @@ textarea {
text-underline-offset: 2px;
}
.agent-runtime-status .agent-runtime-user-input {
display: grid;
gap: 10px;
padding-top: 10px;
border-top: 1px solid #d8dde5;
}
.agent-runtime-user-input header small {
color: #8a3b12;
}
.agent-runtime-user-input fieldset {
display: grid;
min-width: 0;
gap: 8px;
margin: 0;
padding: 0;
border: 0;
}
.agent-runtime-user-input legend {
padding: 0;
color: #111827;
font-size: 12px;
font-weight: 700;
}
.agent-runtime-user-input-options {
grid-template-columns: repeat(auto-fit, minmax(168px, 1fr));
gap: 8px;
}
.agent-runtime-user-input-options button {
display: grid;
min-height: 56px;
gap: 3px;
padding: 8px 10px;
border: 1px solid #cfd6e1;
border-radius: 7px;
background: #fff;
color: #111827;
text-align: left;
cursor: pointer;
}
.agent-runtime-user-input-options button:hover,
.agent-runtime-user-input-options button:focus-visible {
border-color: #9da8b8;
background: #f5f7fa;
}
.agent-runtime-user-input-options button[aria-pressed='true'] {
border-color: #e46f2c;
background: #fff7ed;
box-shadow: inset 0 0 0 1px #e46f2c;
}
.agent-runtime-user-input-options button:disabled {
cursor: default;
opacity: 0.68;
}
.agent-runtime-user-input textarea {
width: 100%;
min-height: 58px;
padding: 8px 10px;
border: 1px solid #cfd6e1;
border-radius: 7px;
background: #fff;
color: #111827;
font: inherit;
line-height: 1.45;
resize: vertical;
}
.agent-runtime-user-input textarea:focus-visible {
border-color: #e46f2c;
outline: 2px solid rgb(228 111 44 / 16%);
outline-offset: 1px;
}
.agent-runtime-user-input .agent-runtime-user-input-submit {
justify-self: end;
min-height: 32px;
padding: 0 12px;
border: 1px solid #cf5f22;
border-radius: 7px;
background: #e46f2c;
color: #fff;
font-size: 12px;
font-weight: 700;
}
.agent-runtime-user-input .agent-runtime-user-input-submit:disabled {
border-color: #cfd6e1;
background: #e8ecf2;
color: #8b95a5;
}
.agent-runtime-status .agent-runtime-user-input-missing {
padding-top: 8px;
border-top: 1px solid #d8dde5;
color: #9a3412;
}
.launcher-project-development {
padding-top: 92px;
}
@@ -1959,6 +2064,15 @@ textarea {
justify-content: flex-start;
}
.agent-runtime-user-input-options {
grid-template-columns: 1fr;
}
.agent-runtime-user-input .agent-runtime-user-input-submit {
width: 100%;
justify-self: stretch;
}
.launcher-agent-session-bar {
grid-template-columns: auto minmax(0, 1fr);
}
File diff suppressed because it is too large Load Diff
@@ -4639,6 +4639,15 @@
- 验收:STDIO 与 Streamable HTTP fixture 都必须由真实 Provider 发现并调用;有副作用 fixture 还要覆盖确认和 Runner 强杀未知窗口,证明唯一调用、零自动重放、结果回灌、同一 Agent/Session/run 以及全部公共零正文/零凭据泄漏。
- 真实验收:2026-07-15 正式 `openai_chat / gpt-5.5` 路由的隔离 `mcp-runtime` suite PASS。正常 run 的 STDIO lookup、HTTP lookup、确认后 STDIO mutate 各执行 1 次,action/私有 sidecar/terminal receipt 各 3、assistant 1HTTP mutate 副作用后 pidfd 强杀 Runner,恢复只产生 reconciliation 1marker 1sidecar/receipt/assistant/重复调用均为 0。公共 arguments、结果正文、instructions、Bearer/static header、API Key、项目和配置绝对路径泄漏均为 0;确定性 MCP 15/15、Tauri 全量 800 passed / 4 ignored,隔离 Runner、fixture、AppData 和项目已清理。
## 2026-07-16 AI 游戏创作 Agent Runtime V1.23 持久用户输入请求
- 决策:新增 `user.input_request`,让 Agent 在未完成 plan/Goal 时进入 `waiting-for-user-input`,回答后把精确结果作为工具 observation 回灌同一 run;不再用最终回复结束任务,也不把回答混入普通 steer。
- 协议:一次 1-3 个结构化问题,每题稳定 id、短 header、单句问题和 2-3 个选项,自由输入始终允许;该 action 必须单独出现且不能携带最终 response。委派专业 Agent 和 isolated child 不直达终端用户。
- 持久化:私有 sidecar 绑定 project/Agent/task/Session/run/action/Goal/steer 与稳定 question/answer messageId,状态单向推进 `pending -> answer-prepared -> answered | cancelled`。Runner 只可幂等修复本地 sidecar/会话,不重放 Provider 或外部副作用。
- 隐私与恢复:问题/答案正文只进 owning Session、私有 sidecar 和 observation;公共面只留数量、字符数与哈希。刷新、App/Runner 重启、Goal pause/resume 和重复回答保持同一 request/run,身份或正文冲突失败关闭。
- 客户端:Project Supervisor 主聊天、启动器开发 Agent 聊天和项目内 Agent 弹窗复用同一问题卡;等待时普通输入/steer 禁用,卡片不随 Runtime 详情折叠,失败重试保持同一 responseId。
- 真实验收:2026-07-16 正式 `openai_chat / gpt-5.5``user-input-runtime` suite PASS。Project Supervisor 自主提出 1 题/2 选项,Runner pidfd 强杀换 boot 后 Provider started 保持 `1 -> 1`,回答后同 Agent/Session/run 完成唯一最终 assistant;会话问题/答案各 1,重复 message、公共正文、API Key、项目/配置路径和报告泄漏均为 0,隔离现场已清理。
## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环
- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。
@@ -936,6 +936,32 @@ V1.22 对标 Codex CLI 的 MCP tool 能力,在现有单 Agent Runtime 内增
真实报告同时证明正常与强杀 run 的 Agent/Session/run 身份稳定,参数由 MCP schema `const` 提供而非用户任务正文;公共 task/event/Agent DB/receipt/conversation/report 中 arguments、结果正文、server instructions、Bearer/static header、API Key、项目绝对路径和配置绝对路径泄漏均为 0。Runner、HTTP fixture、隔离 AppData 和一次性项目全部按 sentinel 清理。确定性 MCP 组为 15/15Tauri 全量为 800 passed / 4 ignored;开发配置窗在 1440px 与 900px 真实浏览器视口均无横向溢出或侧栏遮挡,console 0 error / 0 warning。
## V1.23 单 Agent 持久用户输入请求
V1.23 对齐 Codex Plan/Goal 在任务未完成时主动澄清并进入 `Needs input` 的行为。现有普通最终回复会结束 run,same-run steer 只表达用户主动修正,工具确认只决定是否执行副作用;三者都不能证明“Agent 提出了哪个问题、用户回答了什么、答案是否只回灌一次”。本切片在同一 Runtime 增加 `user.input_request`,不新建聊天系统或第二套任务队列。
### 问题与模型协议
- `user.input_request` input 固定为 `questions`,数量 1-3。每题包含唯一 snake_case `id`、最多 12 个字符的 `header`、单句 `question` 和 2-3 个 `options`;每个 option 包含短 `label` 与一条 `description`。自由输入始终允许,模型不能关闭“其他”答案。
- 该 action 必须是本轮唯一工具动作,`response` 必须为空;Runtime 在 schema 解析后再次确定性校验。问题只用于实现路径、产品取舍或缺失事实会实质改变结果的场景;项目内可读取的事实、权限确认和工具失败不能伪装成用户问题。
- 动态 isolated child 和带父委派身份的专业 Agent 禁止直接调用;它们应通过既有 result/receipt/`agent.message` 把澄清需要交回父 Agent。直接开发试聊的静态 Agent 与 `project-supervisor` 可以调用。
### 身份、持久化与隐私
- requestId 由 project/Agent/task/Session/run/action/fingerprint 派生,模型和客户端都不能指定。私有 `game-creator-runtime-user-input.v1` sidecar 绑定 action、Goal revision/snapshot、planned steer cursor、问题规范正文、question messageId、responseId、answer messageId、答案哈希和单调状态 `pending -> answer-prepared -> answered | cancelled`
- create-once sidecar 先于 assistant question conversation;问题消息使用稳定 messageId 幂等追加到 owning Agent Session。Runner 在 pending action 已进入执行但进程退出时只允许重放该本地 create-once 协议,不重放 Provider 或外部副作用。
- 回答必须带 requestId、全量 answers map 和客户端稳定 responseId。项目锁内先写 `answer-prepared`,再幂等追加 user conversation,最后写 `answered` 和唯一工具 observation;同 responseId/同正文重试继续补齐,身份相同但正文不同失败关闭。
- 完整问题和答案只进入 sidecar、owning Session、私有 observation/context bundle。公共 Runtime event/task/Agent DB/receipt/action history/activity/output/report 只保留 request/action identity、题目/选项/答案数量、字符数和 SHA-256,不复制正文、选项描述、自由输入、路径或凭据。
### 状态、恢复与客户端
- durable pending action 增加 `waiting-for-user-input`。Runtime state/phase 同名,保持原 Agent/task/Session/run/Goal、结构化计划和 steer cursor;不完成 active plan step、不生成 finalization、不消费下一任务。普通 steer 在该状态被拒绝,回答只能走精确 request API。
- Runner 重启时:无 sidecar可安全补建;`pending`/`answer-prepared` 修复缺失的幂等 conversation 并继续等待;`answered` 从 sidecar 全量重算 observation 后继续原 run;任一身份、问题、答案或会话消息冲突进入 `needs-reconciliation`。取消将未回答请求标记 cancelled;Goal pause 保留请求,恢复后仍回到 Needs input。
- `AgentRuntimeResult` 只在 owning Session 当前 run 暴露一个 pending request。Project Supervisor 主聊天、开发 Agent 窗口和 `agc:chat` 展示同一结构化问题;桌面端按题提供 2-3 个选项和自由输入,全部必答后才能提交。提交中禁用重复操作,刷新/切 Agent/重启后从 sidecar 恢复。
- 确定性验收覆盖 schema/预算、sole-action、child deny、幂等 create/answer、responseId 冲突、会话中间失败、Runner 强杀三窗口、Goal pause/resume、cancel、普通 steer 拒绝、跨 Agent/Session/run/request 回答拒绝和公共零正文。真实 Provider 必须在复杂任务中自主提问,用户回答后同 run 完成唯一最终回复,并验证问题/答案各一条、Provider 未在等待期调用、Runner 强杀后零重复。
2026-07-16 使用正式 AppData 的 `openai_chat / gpt-5.5` 路由执行隔离 `user-input-runtime` suiteV1.23 真实验收 **PASS**。Project Supervisor 自主发起 1 个含 2 个选项的结构化问题,等待期使用 Linux pidfd 强杀 Runner 并换 boot 恢复;Provider started 记录在重启前后保持 `1 -> 1`,未暗中请求。回答后保持同一 Agent/Session/run,会话恰好为 1 条初始任务、1 条 assistant 问题、1 条 user 回答和 1 条最终 assistant;全程 2 个 Provider request identity 均唯一闭合,重复 message、遗留 finalization、公共问题/答案正文、API Key、项目/配置路径和报告泄漏均为 0,隔离 Runner、AppData 和一次性项目已清理。
## 验收命令
- `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml structured_plan_ -- --nocapture`
@@ -957,6 +983,7 @@ V1.22 对标 Codex CLI 的 MCP tool 能力,在现有单 Agent Runtime 内增
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite web-search`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite context-compaction`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite mcp-runtime`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite user-input-runtime`
- `npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> --suite full`
- `npm run check:encoding`
- `git diff --check`
@@ -566,4 +566,6 @@ game-project/
- 2026-07-15 V1.21 已落地并完成真实验收:`context-compaction` suite 在正式 `openai_chat / gpt-5.5` 路由上完成 30/30 轮、两次压缩 revision、一次 Runner pidfd 强杀恢复和早期显式约束召回;最大估算输入 29134/6400032 组 Provider lifecycle 唯一闭合,重复 assistant/audit、工具重放以及公共正文、summary、API Key、诱饵、项目路径和正式配置路径泄漏均为 0。首轮第 22 轮 Provider transport 失败按单次请求终态停止且零重放,新 disposable 项目完整重跑后 PASS。
- 2026-07-15 起,同一 Runtime 文档的“V1.22 Runner-owned MCP 动态工具”作为外部工具扩展事实源。AppData 配置管理 STDIO / Streamable HTTP server、Bearer/static header、工具 allow/deny 与 `auto / confirm / writes / deny` 审批;独立 Runner 持有连接并把过滤后的真实 tool schema 和 server instructions 送入 planning。模型通过现有 `submit_agent_tool_plan` 请求 `mcp.call`,调用继续复用 durable pending action、确认、steer、Goal、reconciliation 和 V1.21 token 预算;完整结果只落私有 sidecar,正式用户首页不新增 MCP 调试配置。本切片不宣称 OAuth、resources/prompts、sampling、elicitation 或 MCP task-mode 已实现。
- 2026-07-15 V1.22 已落地并完成真实验收:开发配置窗可管理 server、敏感凭据、工具过滤和审批并通过 Runner 查看有界目录,`agc:chat` / `agc:swarm` 可用 `/mcp` 查询状态。正式 `openai_chat / gpt-5.5` 路由真实调用 STDIO/Streamable HTTP lookup 和确认后的 mutate,正常 run 的 action/sidecar/receipt 各 3 且最终 assistant 唯一;第二 run 在 HTTP mutate 副作用后强杀 Runner,只进入 1 次 reconciliation,调用、sidecar、receipt 和 assistant 均未重放。公共 arguments、结果正文、instructions、凭据和项目/配置路径泄漏为 0,一次性现场已清理。
- 2026-07-16 起,同一 Runtime 文档的“V1.23 单 Agent 持久用户输入请求”作为 Needs input 事实源。Agent 可在计划未完成时通过 `user.input_request` 提出 1-3 个结构化问题,Runtime 保持同一 run 并暂停;Project Supervisor、开发 Agent 窗口和 `agc:chat` 从私有 sidecar 展示并提交答案。普通 steer、工具确认和最终回复不再承担问题回答语义,问题/答案正文不进入公共审计。
- 2026-07-16 V1.23 已完成真实验收:正式 `openai_chat / gpt-5.5` 路由在 Project Supervisor 上产生 1 个含 2 选项的 Needs input,等待期 Runner pidfd 强杀恢复未增加 Provider 请求,回答后同 Session/run 完成唯一最终回复。问题/回答各一条,重复消息、公共正文、密钥、路径和报告泄漏均为 0,隔离现场已清理。
- 开发模式可通过本地项目文件面板执行 `file.list/read/write/delete`,普通用户界面不暴露文件面板。
@@ -250,11 +250,12 @@ describe('AI 游戏创作 App 共享契约', () => {
(capability) => capability.id,
);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(40);
expect(GAME_CREATION_AGENT_CAPABILITIES).toHaveLength(41);
expect(capabilityIds).toEqual(
expect.arrayContaining([
'chat',
'project-supervisor',
'persistent-user-input',
'file-upload',
'llm-draft-generation',
'provider-web-search',
@@ -285,6 +286,15 @@ describe('AI 游戏创作 App 共享契约', () => {
'command-output-read',
]),
);
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'persistent-user-input',
),
).toEqual({
id: 'persistent-user-input',
area: 'agent-runtime',
title: '持久用户澄清请求',
});
expect(
GAME_CREATION_AGENT_CAPABILITIES.find(
(capability) => capability.id === 'provider-web-search',
@@ -88,6 +88,11 @@ export interface GameCreationAgentCapabilityDescriptor {
export const GAME_CREATION_AGENT_CAPABILITIES = [
{ id: 'chat', area: 'user', title: '聊天入口' },
{ id: 'project-supervisor', area: 'agent-runtime', title: '项目总控 Agent' },
{
id: 'persistent-user-input',
area: 'agent-runtime',
title: '持久用户澄清请求',
},
{ id: 'file-upload', area: 'user', title: '上传文件' },
{ id: 'built-in-commands', area: 'agent-runtime', title: '内置命令调用' },
{ id: 'llm-draft-generation', area: 'agent-runtime', title: 'LLM 草案生成' },
@@ -104,9 +104,10 @@ pub struct GameCreationAgentCapabilityDescriptor {
pub platforms: Option<&'static [&'static str]>,
}
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 40] = [
pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 41] = [
capability("chat", "user", "聊天入口"),
capability("project-supervisor", "agent-runtime", "项目总控 Agent"),
capability("persistent-user-input", "agent-runtime", "持久用户澄清请求"),
capability("file-upload", "user", "上传文件"),
capability("built-in-commands", "agent-runtime", "内置命令调用"),
capability("llm-draft-generation", "agent-runtime", "LLM 草案生成"),
@@ -1038,7 +1039,7 @@ mod tests {
#[test]
fn capabilities_cover_standard_agent_runtime_needs() {
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 40);
assert_eq!(GAME_CREATION_AGENT_CAPABILITIES.len(), 41);
let ids = GAME_CREATION_AGENT_CAPABILITIES
.iter()
@@ -1048,6 +1049,7 @@ mod tests {
for expected in [
"chat",
"project-supervisor",
"persistent-user-input",
"file-upload",
"provider-web-search",
"mcp-tools",
@@ -1075,6 +1077,12 @@ mod tests {
] {
assert!(ids.contains(&expected), "missing {expected}");
}
let persistent_user_input = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "persistent-user-input")
.expect("persistent-user-input capability should exist");
assert_eq!(persistent_user_input.area, "agent-runtime");
assert_eq!(persistent_user_input.title, "持久用户澄清请求");
let command_exec = GAME_CREATION_AGENT_CAPABILITIES
.iter()
.find(|capability| capability.id == "command-exec")