补齐智能体持久化用户输入流程
新增 user.input_request 状态机与私有 sidecar,支持重启恢复、取消和同一 Run 续跑 接入终端聊天、开发 Agent 聊天、项目 Agent 对话和 Project Supervisor 问题卡 扩展任务队列、能力清单与前后端共享契约 补齐单元测试、界面回归、真实 Runtime E2E 和技术文档
This commit is contained in:
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
@@ -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 1;HTTP mutate 副作用后 pidfd 强杀 Runner,恢复只产生 reconciliation 1,marker 1,sidecar/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/15,Tauri 全量为 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` suite,V1.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/64000,32 组 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")
|
||||
|
||||
Reference in New Issue
Block a user