完善游戏创作 Agent Runtime 执行闭环
收紧后台规划与回复上下文,项目事实只经权限工具进入 observation 统一前台聊天与后台任务的 Agent 锁并保留多 Agent 并行 补齐独立开发聊天和主工作区的重启恢复确认及跨项目隔离 修正工具预算耗尽、超额动作提示和完成后队列 drain 语义 补充 Runtime、恢复、并发、流式与项目切换回归测试和技术决策文档
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -248,8 +248,9 @@ pub(crate) async fn chat_with_game_creator_role_agent(
|
||||
let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?;
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
chat_with_game_creator_role_agent_runtime_for_session_at(
|
||||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||||
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
|
||||
let result = chat_with_game_creator_role_agent_runtime_for_session_at(
|
||||
root,
|
||||
&agent_id,
|
||||
session_id.as_deref(),
|
||||
@@ -257,7 +258,12 @@ pub(crate) async fn chat_with_game_creator_role_agent(
|
||||
"",
|
||||
)
|
||||
.await
|
||||
.map(|(reply, _runtime)| reply)
|
||||
.map(|(reply, _runtime)| reply);
|
||||
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
|
||||
match result {
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -277,121 +283,133 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
|
||||
resolve_agent_conversation_session_id_at(root, &agent_id, session_id.as_deref(), true)?;
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
let _lock = acquire_project_write_lock(root, "conversation.write")?;
|
||||
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?
|
||||
.ok_or_else(|| format!("Agent 正在执行其他前台或后台任务:{agent_id}"))?;
|
||||
let emit_app = app.clone();
|
||||
let event_project_path = project_path.clone();
|
||||
let event_agent_id = agent_id.clone();
|
||||
let event_run_id = run_id.clone();
|
||||
let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at(
|
||||
root,
|
||||
agent_id.as_str(),
|
||||
Some(&session_id),
|
||||
prompt.trim(),
|
||||
&run_id,
|
||||
)?;
|
||||
runtime_state = advance_game_creator_agent_runtime_turn_at(
|
||||
root,
|
||||
runtime_state,
|
||||
"llm",
|
||||
"请求 Agent LLM",
|
||||
"已读取项目上下文,正在让 Agent 独立推理。",
|
||||
)?;
|
||||
let mut streaming_runtime_state = runtime_state.clone();
|
||||
streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string();
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "started".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
session_id: Some(runtime_state.session_id.clone()),
|
||||
runtime_status: Some(runtime_state.status.clone()),
|
||||
runtime_phase: Some(runtime_state.phase.clone()),
|
||||
runtime_summary: Some(runtime_state.current_action.clone()),
|
||||
runtime_state: Some(runtime_state.clone()),
|
||||
},
|
||||
);
|
||||
let result = chat_with_game_creator_role_agent_stream_for_session_at(
|
||||
root,
|
||||
agent_id.as_str(),
|
||||
Some(&session_id),
|
||||
prompt.trim(),
|
||||
|delta| {
|
||||
let _ = emit_app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "delta".to_string(),
|
||||
delta_text: delta.delta_text.clone(),
|
||||
accumulated_text: delta.accumulated_text.clone(),
|
||||
finish_reason: delta.finish_reason.clone(),
|
||||
session_id: Some(streaming_runtime_state.session_id.clone()),
|
||||
runtime_status: Some("running".to_string()),
|
||||
runtime_phase: Some("llm".to_string()),
|
||||
runtime_summary: Some("正在接收 Agent 回复".to_string()),
|
||||
runtime_state: Some(streaming_runtime_state.clone()),
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
let command_result = async {
|
||||
let mut runtime_state = start_game_creator_agent_runtime_turn_for_session_at(
|
||||
root,
|
||||
agent_id.as_str(),
|
||||
Some(&session_id),
|
||||
prompt.trim(),
|
||||
&run_id,
|
||||
)?;
|
||||
runtime_state = advance_game_creator_agent_runtime_turn_at(
|
||||
root,
|
||||
runtime_state,
|
||||
"llm",
|
||||
"请求 Agent LLM",
|
||||
"已读取项目上下文,正在让 Agent 独立推理。",
|
||||
)?;
|
||||
let mut streaming_runtime_state = runtime_state.clone();
|
||||
streaming_runtime_state.current_action = "正在接收 Agent 回复".to_string();
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "started".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
session_id: Some(runtime_state.session_id.clone()),
|
||||
runtime_status: Some(runtime_state.status.clone()),
|
||||
runtime_phase: Some(runtime_state.phase.clone()),
|
||||
runtime_summary: Some(runtime_state.current_action.clone()),
|
||||
runtime_state: Some(runtime_state.clone()),
|
||||
},
|
||||
);
|
||||
let result = chat_with_game_creator_role_agent_stream_for_session_at(
|
||||
root,
|
||||
agent_id.as_str(),
|
||||
Some(&session_id),
|
||||
prompt.trim(),
|
||||
|delta| {
|
||||
let _ = emit_app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: event_project_path.clone(),
|
||||
agent_id: event_agent_id.clone(),
|
||||
run_id: event_run_id.clone(),
|
||||
status: "delta".to_string(),
|
||||
delta_text: delta.delta_text.clone(),
|
||||
accumulated_text: delta.accumulated_text.clone(),
|
||||
finish_reason: delta.finish_reason.clone(),
|
||||
session_id: Some(streaming_runtime_state.session_id.clone()),
|
||||
runtime_status: Some("running".to_string()),
|
||||
runtime_phase: Some("llm".to_string()),
|
||||
runtime_summary: Some("正在接收 Agent 回复".to_string()),
|
||||
runtime_state: Some(streaming_runtime_state.clone()),
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(reply) => {
|
||||
let completed_runtime = finish_game_creator_agent_runtime_turn_at(
|
||||
root,
|
||||
runtime_state,
|
||||
&reply.reply_text,
|
||||
)?;
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: project_path.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
run_id: run_id.clone(),
|
||||
status: "completed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: reply.reply_text.clone(),
|
||||
finish_reason: None,
|
||||
session_id: Some(completed_runtime.session_id.clone()),
|
||||
runtime_status: Some(completed_runtime.status.clone()),
|
||||
runtime_phase: Some(completed_runtime.phase.clone()),
|
||||
runtime_summary: Some(completed_runtime.current_action.clone()),
|
||||
runtime_state: Some(completed_runtime),
|
||||
},
|
||||
);
|
||||
Ok(reply)
|
||||
}
|
||||
Err(error) => {
|
||||
let failed_runtime =
|
||||
fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok();
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path: project_path.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
run_id: run_id.clone(),
|
||||
status: "failed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
session_id: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.session_id.clone()),
|
||||
runtime_status: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.status.clone()),
|
||||
runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()),
|
||||
runtime_summary: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.current_action.clone()),
|
||||
runtime_state: failed_runtime,
|
||||
},
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(reply) => {
|
||||
let completed_runtime =
|
||||
finish_game_creator_agent_runtime_turn_at(root, runtime_state, &reply.reply_text)?;
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path,
|
||||
agent_id,
|
||||
run_id,
|
||||
status: "completed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: reply.reply_text.clone(),
|
||||
finish_reason: None,
|
||||
session_id: Some(completed_runtime.session_id.clone()),
|
||||
runtime_status: Some(completed_runtime.status.clone()),
|
||||
runtime_phase: Some(completed_runtime.phase.clone()),
|
||||
runtime_summary: Some(completed_runtime.current_action.clone()),
|
||||
runtime_state: Some(completed_runtime),
|
||||
},
|
||||
);
|
||||
Ok(reply)
|
||||
}
|
||||
Err(error) => {
|
||||
let failed_runtime =
|
||||
fail_game_creator_agent_runtime_turn_at(root, runtime_state, &error).ok();
|
||||
let _ = app.emit(
|
||||
"game-creator-role-agent-chat-stream",
|
||||
GameCreatorRoleAgentChatStreamEvent {
|
||||
project_path,
|
||||
agent_id,
|
||||
run_id,
|
||||
status: "failed".to_string(),
|
||||
delta_text: String::new(),
|
||||
accumulated_text: String::new(),
|
||||
finish_reason: None,
|
||||
session_id: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.session_id.clone()),
|
||||
runtime_status: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.status.clone()),
|
||||
runtime_phase: failed_runtime.as_ref().map(|runtime| runtime.phase.clone()),
|
||||
runtime_summary: failed_runtime
|
||||
.as_ref()
|
||||
.map(|runtime| runtime.current_action.clone()),
|
||||
runtime_state: failed_runtime,
|
||||
},
|
||||
);
|
||||
Err(error)
|
||||
}
|
||||
spawn_next_game_creator_agent_background_task_drain_with_lock(root, &agent_id, runtime_lock);
|
||||
match command_result {
|
||||
Ok(reply) => Ok(reply),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +545,19 @@ pub(crate) fn resume_game_creator_agent_runtime_tasks(
|
||||
resume_game_creator_agent_background_tasks_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn confirm_resume_game_creator_agent_runtime_tasks(
|
||||
project_path: String,
|
||||
) -> Result<Vec<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")?;
|
||||
// 该命令只由开发者在 agent.resume 确认卡中明确批准后调用。
|
||||
enforce_project_permission_policy(root, "agent.resume")?;
|
||||
resume_game_creator_agent_background_tasks_at(root)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn schedule_game_creator_agent_ready_tasks(
|
||||
project_path: String,
|
||||
|
||||
@@ -1253,6 +1253,7 @@ fn main() {
|
||||
read_game_creator_agent_runtime,
|
||||
read_game_creator_agent_runtimes,
|
||||
resume_game_creator_agent_runtime_tasks,
|
||||
confirm_resume_game_creator_agent_runtime_tasks,
|
||||
schedule_game_creator_agent_ready_tasks,
|
||||
check_game_creator_llm_config,
|
||||
read_game_creator_app_config,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1272,6 +1272,7 @@ export type PendingCommand =
|
||||
interface PendingUiConfirmation {
|
||||
commandId: GameCreationAppCommandDescriptor['id'];
|
||||
detail: string;
|
||||
projectPath: string | null;
|
||||
}
|
||||
|
||||
type TauriInvoke = <T>(
|
||||
@@ -1297,6 +1298,20 @@ function isMissingAgentSessionCommandError(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingAgentRuntimeResumeCommandError(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes('resume_game_creator_agent_runtime_tasks') &&
|
||||
(normalized.includes('not found') ||
|
||||
normalized.includes('unknown command') ||
|
||||
normalized.includes('unexpected invoke') ||
|
||||
normalized.includes('unexpected command') ||
|
||||
normalized.includes('不存在') ||
|
||||
normalized.includes('未找到'))
|
||||
);
|
||||
}
|
||||
|
||||
function createDefaultChatMessages(): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
@@ -2888,7 +2903,10 @@ export function WorkspaceLauncher({
|
||||
const [agentChatActiveRuntime, setAgentChatActiveRuntime] =
|
||||
useState<AgentRuntimeState | null>(null);
|
||||
const [agentChatRuntimeError, setAgentChatRuntimeError] = useState('');
|
||||
const [agentChatResumeConfirmation, setAgentChatResumeConfirmation] =
|
||||
useState<{ projectPath: string; detail: string } | null>(null);
|
||||
const agentChatLoadVersionRef = useRef(0);
|
||||
const agentChatRuntimeResumeProjectPathRef = useRef<string | null>(null);
|
||||
const agentChatProjectPathRef = useRef(agentChatProjectPath);
|
||||
agentChatProjectPathRef.current = agentChatProjectPath;
|
||||
const agentChatSelectedAgentIdRef = useRef(agentChatSelectedAgentId);
|
||||
@@ -3650,6 +3668,91 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAgentChatRuntimeResume() {
|
||||
setAgentChatResumeConfirmation(null);
|
||||
setAgentChatStatus('已取消恢复 Agent Runtime 任务');
|
||||
}
|
||||
|
||||
async function confirmAgentChatRuntimeResume() {
|
||||
const pending = agentChatResumeConfirmation;
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!pending || !invoke) {
|
||||
return;
|
||||
}
|
||||
setAgentChatResumeConfirmation(null);
|
||||
setAgentChatStatus('正在恢复 Agent Runtime 任务');
|
||||
try {
|
||||
await invoke<AgentRuntimeResult[]>(
|
||||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: pending.projectPath },
|
||||
);
|
||||
if (agentChatProjectPathRef.current.trim() !== pending.projectPath) {
|
||||
return;
|
||||
}
|
||||
agentChatRuntimeResumeProjectPathRef.current = pending.projectPath;
|
||||
await loadAgentChatConversation(
|
||||
agentChatSelectedAgentIdRef.current,
|
||||
pending.projectPath,
|
||||
agentChatSelectedSessionIdRef.current,
|
||||
);
|
||||
setAgentChatStatus('已确认恢复 Agent Runtime 任务');
|
||||
} catch (error) {
|
||||
if (agentChatProjectPathRef.current.trim() !== pending.projectPath) {
|
||||
return;
|
||||
}
|
||||
agentChatRuntimeResumeProjectPathRef.current = null;
|
||||
setAgentChatStatus(
|
||||
`Agent Runtime 恢复失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeAgentChatRuntimeTasksIfNeeded(
|
||||
invoke: TauriInvoke,
|
||||
projectPathForChat: string,
|
||||
) {
|
||||
if (
|
||||
agentChatRuntimeResumeProjectPathRef.current === projectPathForChat
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
await invoke<AgentRuntimeResult[]>(
|
||||
'resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: projectPathForChat },
|
||||
);
|
||||
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
|
||||
return '';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isMissingAgentRuntimeResumeCommandError(error)) {
|
||||
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
|
||||
return '';
|
||||
}
|
||||
if (message.includes('项目权限策略要求用户确认:agent.resume')) {
|
||||
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
|
||||
setAgentChatResumeConfirmation({
|
||||
projectPath: projectPathForChat,
|
||||
detail: `恢复 ${projectPathForChat} 中未完成的 Agent Runtime 任务`,
|
||||
});
|
||||
return ';等待确认恢复 Agent Runtime 任务';
|
||||
}
|
||||
if (message.includes('项目权限策略拒绝执行:agent.resume')) {
|
||||
agentChatRuntimeResumeProjectPathRef.current = projectPathForChat;
|
||||
return ';项目策略禁止恢复 Agent Runtime 任务';
|
||||
}
|
||||
agentChatRuntimeResumeProjectPathRef.current = null;
|
||||
return `;Agent Runtime 恢复检查失败:${message}`;
|
||||
}
|
||||
}
|
||||
|
||||
useEscapeToClose(
|
||||
cancelAgentChatRuntimeResume,
|
||||
agentChatResumeConfirmation !== null,
|
||||
);
|
||||
|
||||
async function handleAgentChatPickProjectDirectory() {
|
||||
if (agentChatBusy || agentChatBackgroundBusy) {
|
||||
return;
|
||||
@@ -3670,6 +3773,8 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
setAgentChatStatus('已选择项目目录');
|
||||
resetAgentChatSessionView();
|
||||
agentChatRuntimeResumeProjectPathRef.current = null;
|
||||
setAgentChatResumeConfirmation(null);
|
||||
setAgentChatProjectPath(selectedPath);
|
||||
void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath);
|
||||
} catch (error) {
|
||||
@@ -3698,6 +3803,13 @@ export function WorkspaceLauncher({
|
||||
setAgentChatBusy(true);
|
||||
setAgentChatStatus('正在读取');
|
||||
try {
|
||||
const runtimeResumeStatus = await resumeAgentChatRuntimeTasksIfNeeded(
|
||||
invoke,
|
||||
projectPathForChat,
|
||||
);
|
||||
if (agentChatLoadVersionRef.current !== loadVersion) {
|
||||
return;
|
||||
}
|
||||
let sessionList = knownSessions;
|
||||
let sessionListError = '';
|
||||
if (sessionList === undefined) {
|
||||
@@ -3821,7 +3933,9 @@ export function WorkspaceLauncher({
|
||||
);
|
||||
}
|
||||
}
|
||||
setAgentChatStatus(`已读取 ${result.messages.length} 条:${result.path}`);
|
||||
setAgentChatStatus(
|
||||
`已读取 ${result.messages.length} 条:${result.path}${runtimeResumeStatus}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (agentChatLoadVersionRef.current !== loadVersion) {
|
||||
return;
|
||||
@@ -5244,8 +5358,11 @@ export function WorkspaceLauncher({
|
||||
value={agentChatProjectPath}
|
||||
onChange={(event) => {
|
||||
agentChatLoadVersionRef.current += 1;
|
||||
agentChatRuntimeResumeProjectPathRef.current = null;
|
||||
setAgentChatResumeConfirmation(null);
|
||||
setAgentChatProjectPath(event.currentTarget.value);
|
||||
resetAgentChatSessionView();
|
||||
setAgentChatStatus('请选择并读取会话');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -5438,6 +5555,26 @@ export function WorkspaceLauncher({
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
{agentChatResumeConfirmation ? (
|
||||
<div className="pending-command" role="status">
|
||||
<span>
|
||||
agent.resume
|
||||
<small>{agentChatResumeConfirmation.detail}</small>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelAgentChatRuntimeResume}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmAgentChatRuntimeResume()}
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="launcher-agent-chat-messages" aria-label="Agent 聊天记录">
|
||||
{agentChatMessages.length > 0 ? (
|
||||
agentChatMessages.map((message, index) => (
|
||||
@@ -13051,6 +13188,16 @@ export function App() {
|
||||
pendingNonEmptyProjectCreate !== null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
pendingUiConfirmation?.projectPath &&
|
||||
pendingUiConfirmation.projectPath !==
|
||||
resolveCurrentUiConfirmationProjectPath()
|
||||
) {
|
||||
cancelUiCommandConfirmation();
|
||||
}
|
||||
}, [localProject?.projectPath, projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialProjectPath || initialProjectOpenedRef.current) {
|
||||
return;
|
||||
@@ -13319,6 +13466,22 @@ export function App() {
|
||||
return projectPath;
|
||||
}
|
||||
|
||||
function resolveCurrentUiConfirmationProjectPath() {
|
||||
const openedProjectPath = localProjectPathRef.current?.trim();
|
||||
if (openedProjectPath) {
|
||||
return openedProjectPath;
|
||||
}
|
||||
const draftProjectPath = projectPath.trim();
|
||||
if (
|
||||
!draftProjectPath ||
|
||||
!isAbsoluteProjectPath(draftProjectPath) ||
|
||||
projectPathHasControlCharacter(draftProjectPath)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return draftProjectPath;
|
||||
}
|
||||
|
||||
function queuePendingCommand(command: PendingCommand) {
|
||||
setPendingCommand(command);
|
||||
setCommandLog((current) => [
|
||||
@@ -13347,7 +13510,7 @@ export function App() {
|
||||
}
|
||||
if (permission === 'confirm') {
|
||||
pendingUiConfirmationActionRef.current = onConfirm;
|
||||
setPendingUiConfirmation({ commandId, detail });
|
||||
setPendingUiConfirmation({ commandId, detail, projectPath: null });
|
||||
setCommandLog((current) => [
|
||||
...current,
|
||||
`permission.pending ${commandId}`,
|
||||
@@ -13369,7 +13532,7 @@ export function App() {
|
||||
onConfirm: () => void,
|
||||
) {
|
||||
pendingUiConfirmationActionRef.current = onConfirm;
|
||||
setPendingUiConfirmation({ commandId, detail });
|
||||
setPendingUiConfirmation({ commandId, detail, projectPath });
|
||||
setCommandLog((current) => [...current, `permission.pending ${commandId}`]);
|
||||
appendLocalPermissionLog(projectPath, 'permission.pending', commandId);
|
||||
}
|
||||
@@ -13474,10 +13637,20 @@ export function App() {
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pending.projectPath &&
|
||||
pending.projectPath !== resolveCurrentUiConfirmationProjectPath()
|
||||
) {
|
||||
cancelUiCommandConfirmation();
|
||||
return;
|
||||
}
|
||||
const permissionProjectPath =
|
||||
pending.projectPath ??
|
||||
resolveUiPermissionLogProjectPath(pending.commandId);
|
||||
if (
|
||||
await denyPendingCommandIfNeeded(
|
||||
pending.commandId,
|
||||
resolveUiPermissionLogProjectPath(pending.commandId),
|
||||
permissionProjectPath,
|
||||
)
|
||||
) {
|
||||
pendingUiConfirmationActionRef.current = null;
|
||||
@@ -13492,7 +13665,7 @@ export function App() {
|
||||
`permission.confirm ${pending.commandId}`,
|
||||
]);
|
||||
appendLocalPermissionLog(
|
||||
resolveUiPermissionLogProjectPath(pending.commandId),
|
||||
permissionProjectPath,
|
||||
'permission.confirm',
|
||||
pending.commandId,
|
||||
);
|
||||
@@ -13626,6 +13799,12 @@ export function App() {
|
||||
: '已取消读取 Agent run 状态',
|
||||
);
|
||||
}
|
||||
if (
|
||||
pending.commandId === 'agent.resume' &&
|
||||
pending.detail.includes('未完成的 Agent Runtime 任务')
|
||||
) {
|
||||
setAgentRunStatus('已取消恢复 Agent Runtime 任务');
|
||||
}
|
||||
if (
|
||||
pending.commandId === 'project.index' ||
|
||||
pending.commandId === 'project.diff' ||
|
||||
@@ -13669,7 +13848,8 @@ export function App() {
|
||||
`permission.cancel ${pending.commandId}`,
|
||||
]);
|
||||
appendLocalPermissionLog(
|
||||
resolveUiPermissionLogProjectPath(pending.commandId),
|
||||
pending.projectPath ??
|
||||
resolveUiPermissionLogProjectPath(pending.commandId),
|
||||
'permission.cancel',
|
||||
pending.commandId,
|
||||
);
|
||||
@@ -13962,6 +14142,13 @@ export function App() {
|
||||
const openedProject = { ...result, projectPath: openedProjectPath };
|
||||
const conversationMessages = createDefaultChatMessages();
|
||||
|
||||
if (
|
||||
pendingUiConfirmation?.projectPath &&
|
||||
pendingUiConfirmation.projectPath !== openedProject.projectPath
|
||||
) {
|
||||
cancelUiCommandConfirmation();
|
||||
}
|
||||
localProjectPathRef.current = openedProject.projectPath;
|
||||
setProjectPath(openedProject.projectPath);
|
||||
setLocalProject(openedProject);
|
||||
setManifest(openedProject.manifest);
|
||||
@@ -21168,23 +21355,80 @@ export function App() {
|
||||
}
|
||||
try {
|
||||
if (agentRuntimeResumeProjectPathRef.current !== nextProjectPath) {
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
try {
|
||||
const resumedRuntimes = await invoke<AgentRuntimeResult[]>(
|
||||
'resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
for (const runtimeResult of resumedRuntimes) {
|
||||
rememberAgentRuntimeState(agentRuntimeStateFromResult(runtimeResult));
|
||||
}
|
||||
} catch {
|
||||
// Runtime recovery is best-effort; reading current status below remains authoritative.
|
||||
} catch (error) {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isMissingAgentRuntimeResumeCommandError(error)) {
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
} else if (message.includes('项目权限策略要求用户确认:agent.resume')) {
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
requestProjectPolicyConfirmation(
|
||||
'agent.resume',
|
||||
nextProjectPath,
|
||||
`恢复 ${nextProjectPath} 中未完成的 Agent Runtime 任务`,
|
||||
() => {
|
||||
void invoke<AgentRuntimeResult[]>(
|
||||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: nextProjectPath },
|
||||
)
|
||||
.then((resumedRuntimes) => {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
for (const runtimeResult of resumedRuntimes) {
|
||||
rememberAgentRuntimeState(
|
||||
agentRuntimeStateFromResult(runtimeResult),
|
||||
);
|
||||
}
|
||||
setAgentRunStatus('已确认恢复 Agent Runtime 任务');
|
||||
})
|
||||
.catch((resumeError) => {
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
agentRuntimeResumeProjectPathRef.current = null;
|
||||
setAgentRunStatus(
|
||||
`Agent Runtime 恢复失败:${
|
||||
resumeError instanceof Error
|
||||
? resumeError.message
|
||||
: String(resumeError)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
setAgentRunStatus('等待确认恢复 Agent Runtime 任务');
|
||||
} else if (message.includes('项目权限策略拒绝执行:agent.resume')) {
|
||||
agentRuntimeResumeProjectPathRef.current = nextProjectPath;
|
||||
markProjectPolicyDenied('agent.resume', message);
|
||||
} else {
|
||||
agentRuntimeResumeProjectPathRef.current = null;
|
||||
setAgentRunStatus(`Agent Runtime 恢复失败:${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const runtimes = await invoke<AgentRuntimeResult[]>(
|
||||
'read_game_creator_agent_runtimes',
|
||||
{ projectPath: nextProjectPath },
|
||||
);
|
||||
if (localProjectPathRef.current !== nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
const nextRuntimeById: Record<string, AgentRuntimeState | undefined> =
|
||||
{};
|
||||
for (const runtimeResult of runtimes) {
|
||||
@@ -21194,7 +21438,9 @@ export function App() {
|
||||
}
|
||||
setAgentRuntimeById(nextRuntimeById);
|
||||
} catch {
|
||||
setAgentRuntimeById({});
|
||||
if (localProjectPathRef.current === nextProjectPath) {
|
||||
setAgentRuntimeById({});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1063,6 +1063,124 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('asks before recovering interrupted runtime tasks in the developer Agent chat', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||||
}
|
||||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
|
||||
agentId: args?.agentId,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/authorized-game' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
|
||||
const detail = await screen.findByText(
|
||||
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
|
||||
);
|
||||
const confirmation = detail.closest('.pending-command');
|
||||
expect(confirmation).not.toBeNull();
|
||||
expect(screen.getByText('agent.resume')).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) =>
|
||||
command === 'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
fireEvent.click(
|
||||
within(confirmation as HTMLElement).getByRole('button', {
|
||||
name: '确认',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: '/tmp/authorized-game' },
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('已确认恢复 Agent Runtime 任务')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reload an old developer Agent project after recovery returns late', async () => {
|
||||
let resolveConfirmedResume: ((value: unknown[]) => void) | null = null;
|
||||
const confirmedResume = new Promise<unknown[]>((resolve) => {
|
||||
resolveConfirmedResume = resolve;
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||||
}
|
||||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||||
return confirmedResume;
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${String(args?.projectPath ?? '')}/.agent/conversations/agents/design-director.jsonl`,
|
||||
agentId: args?.agentId,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAgentChatAt('/?agent-chat');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/project-a' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
|
||||
const detail = await screen.findByText(
|
||||
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
|
||||
);
|
||||
fireEvent.click(
|
||||
within(detail.closest('.pending-command') as HTMLElement).getByRole(
|
||||
'button',
|
||||
{ name: '确认' },
|
||||
),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
|
||||
target: { value: '/tmp/project-b' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveConfirmedResume?.([]);
|
||||
await confirmedResume;
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('请选择并读取会话').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'read_local_conversation' &&
|
||||
args?.projectPath === '/tmp/project-a',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, switches, archives, and isolates developer Agent sessions', async () => {
|
||||
type SessionRecord = {
|
||||
sessionId: string;
|
||||
@@ -14029,6 +14147,285 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(screen.queryByText('旧 Agent 历史消息')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for explicit confirmation before recovering runtime tasks under the default policy', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||||
}
|
||||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'read_local_project_file') {
|
||||
throw new Error(
|
||||
'读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)',
|
||||
);
|
||||
}
|
||||
if (command === 'list_local_project_files') {
|
||||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game');
|
||||
|
||||
const detail = await screen.findByText(
|
||||
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
|
||||
);
|
||||
expect(screen.getByText(/run:/).textContent).toContain(
|
||||
'等待确认恢复 Agent Runtime 任务',
|
||||
);
|
||||
const confirmation = detail.closest('.pending-command');
|
||||
expect(confirmation).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
fireEvent.click(
|
||||
within(confirmation as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(screen.queryByText(
|
||||
'恢复 /tmp/authorized-game 中未完成的 Agent Runtime 任务',
|
||||
)).toBeNull();
|
||||
expect(screen.getByText(/run:/).textContent).toContain(
|
||||
'已确认恢复 Agent Runtime 任务',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a stale runtime recovery confirmation when the project changes', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
);
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
const projectPath = String(args?.projectPath ?? '');
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
throw new Error('项目权限策略要求用户确认:agent.resume');
|
||||
}
|
||||
if (command === 'confirm_resume_game_creator_agent_runtime_tasks') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${String(args?.projectPath ?? '')}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_file') {
|
||||
throw new Error('run trace 不存在');
|
||||
}
|
||||
if (command === 'list_local_project_files') {
|
||||
return { projectPath: String(args?.projectPath ?? ''), files: [] };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderAppAt('/?dev');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('本地项目目录'), {
|
||||
target: { value: '/tmp/project-a' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
|
||||
const createA = screen
|
||||
.getByText('创建 /tmp/project-a')
|
||||
.closest('.pending-command');
|
||||
fireEvent.click(
|
||||
within(createA as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('本地项目目录'), {
|
||||
target: { value: '/tmp/project-b' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
|
||||
const createB = screen
|
||||
.getByText('创建 /tmp/project-b')
|
||||
.closest('.pending-command');
|
||||
fireEvent.click(
|
||||
within(createB as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'恢复 /tmp/project-b 中未完成的 Agent Runtime 任务',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'恢复 /tmp/project-a 中未完成的 Agent Runtime 任务',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) =>
|
||||
command === 'confirm_resume_game_creator_agent_runtime_tasks',
|
||||
),
|
||||
).toHaveLength(0);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'append_local_permission_log',
|
||||
expect.objectContaining({
|
||||
projectPath: '/tmp/project-a',
|
||||
event: 'permission.cancel',
|
||||
commandId: 'agent.resume',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores delayed runtime recovery results from a previously opened project', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
);
|
||||
let resolveProjectAResume: ((value: unknown[]) => void) | null = null;
|
||||
const projectAResume = new Promise<unknown[]>((resolve) => {
|
||||
resolveProjectAResume = resolve;
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
const targetProjectPath = String(args?.projectPath ?? '');
|
||||
if (command === 'append_local_permission_log') {
|
||||
return {};
|
||||
}
|
||||
if (command === 'init_local_game_project') {
|
||||
return {
|
||||
projectPath: targetProjectPath,
|
||||
manifestPath: `${targetProjectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'resume_game_creator_agent_runtime_tasks') {
|
||||
return targetProjectPath === '/tmp/project-a'
|
||||
? projectAResume
|
||||
: [];
|
||||
}
|
||||
if (command === 'read_game_creator_agent_runtimes') {
|
||||
return [];
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: `${targetProjectPath}/.agent/conversations/project.jsonl`,
|
||||
agentId: null,
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_file') {
|
||||
throw new Error('run trace 不存在');
|
||||
}
|
||||
if (command === 'list_local_project_files') {
|
||||
return { projectPath: targetProjectPath, files: [] };
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderAppAt('/?dev');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('本地项目目录'), {
|
||||
target: { value: '/tmp/project-a' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
|
||||
const createA = screen
|
||||
.getByText('创建 /tmp/project-a')
|
||||
.closest('.pending-command');
|
||||
fireEvent.click(
|
||||
within(createA as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
expect(await screen.findByText('已打开:/tmp/project-a')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'resume_game_creator_agent_runtime_tasks',
|
||||
{ projectPath: '/tmp/project-a' },
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText('本地项目目录'), {
|
||||
target: { value: '/tmp/project-b' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '初始化' }));
|
||||
const createB = screen
|
||||
.getByText('创建 /tmp/project-b')
|
||||
.closest('.pending-command');
|
||||
fireEvent.click(
|
||||
within(createB as HTMLElement).getByRole('button', { name: '确认' }),
|
||||
);
|
||||
expect(await screen.findByText('已打开:/tmp/project-b')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectAResume?.([]);
|
||||
await projectAResume;
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('read_game_creator_agent_runtimes', {
|
||||
projectPath: '/tmp/project-b',
|
||||
});
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'read_game_creator_agent_runtimes',
|
||||
{ projectPath: '/tmp/project-a' },
|
||||
);
|
||||
expect(screen.getByText('已打开:/tmp/project-b')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('shows runtime status and recent tasks in the main agent status list', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
|
||||
@@ -4129,3 +4129,11 @@
|
||||
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。
|
||||
- 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-07-10 AI 游戏创作 Agent Runtime 执行边界
|
||||
|
||||
- 决策:后台 Agent 首轮不得预加载任何需要工具权限控制的项目内容。planning 与 final reply 只拿身份、session/run 元数据、任务、工具策略和已获准 observation;记忆、黑板、对话、资产和文件内容必须通过对应工具进入。最新黑板、记忆和对话采用尾部保留截断。
|
||||
- 决策:同一 Agent 的前台聊天与后台队列共享 per-Agent OS 执行锁,前台 LLM 等待期间不持有项目写锁;同 Agent 后台投递保持 pending,前台结束后把当前锁直接移交给 drain,drain 异常不得反写已经完成的聊天结果,不同 Agent 继续并行。
|
||||
- 决策:重启恢复继续遵守 `agent.resume` 默认确认策略。自动 command 只允许 auto;默认 confirm 由主工作区或独立开发 Agent 聊天窗口的 UI 明确确认后调用独立 command,确认绑定发起项目,切换项目取消旧确认且旧项目异步结果不得污染新项目状态;独立 command 只忽略 confirm、不允许绕过 deny,临时失败必须允许重试。
|
||||
- 决策:后台 Agent loop 只有空 actions 才算收束;三轮预算耗尽仍有动作时写 `failed / budget-exhausted` 和 `loop-budget-exhausted`,不再生成总结后记成 completed。解析阶段保留 action 总数,超过单轮预算时写 `runtime.tool_budget` 并只执行前三个;Runtime 默认工具列表必须直接从可执行白名单派生。
|
||||
- 验证:Rust 覆盖首轮上下文不泄露、工具后 observation 可见、确认前后内容边界、前后台同 Agent 串行、前台结束后队列 drain、恢复确认 gate、预算耗尽失败和默认工具白名单一致性;前端分别覆盖主工作区和独立开发 Agent 聊天窗口的默认恢复确认条与显式恢复 command。
|
||||
|
||||
@@ -67,6 +67,10 @@ Agent Runtime 负责:
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。
|
||||
- 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。
|
||||
- 2026-07-10 补充:Runtime 增加 `resume_game_creator_agent_runtime_tasks` 恢复入口。客户端读取项目 Runtime 时会对每个项目路径最多自动尝试一次恢复;恢复命令必须通过 `agent.resume` 自动权限,默认需要确认或被拒绝时不会静默启动。恢复扫描 `.agent/runtime/tasks/<agentId>.jsonl` 中上一进程遗留的 `running` 或仍为 `pending` 的任务,同一 Agent 同时存在二者时先重接遗留 `running`,再由既有 drain 串行继续 `pending`;恢复动作写 `agent.runtime.background_task.recovered` 审计记录。该能力只是把本地 JSONL 队列重接到当前 App 进程,不是独立常驻 worker,也不承诺恢复已经发出的上游 LLM 请求。
|
||||
- 2026-07-10 补充:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。普通前台聊天仍可使用角色上下文。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。
|
||||
- 2026-07-10 补充:同一 Agent 的前台直接聊天、流式聊天和后台任务统一使用 `.agent/runtime/locks/<agentId>.lock` OS 文件锁。前台聊天不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的聊天结果改判为失败。不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。
|
||||
- 2026-07-10 补充:默认 `agent.resume=confirm` 时,客户端自动恢复命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口在首次读取项目 Runtime 时都必须显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。
|
||||
- 2026-07-10 补充:后台 Agent 只有返回空 `actions` 才视为本轮 loop 已收束。三轮后仍请求工具时终态为 `status=failed / phase=budget-exhausted`,error 使用 `loop-budget-exhausted` 机器可读前缀,不再调用 final reply 后写 completed 审计;解析阶段保留过滤后的 action 总数,每轮超过 3 个 action 时写入 `runtime.tool_budget` observation 并只执行前三个,要求下一轮重新排序。Runtime 默认 `allowedTools` 直接由实际可执行工具白名单派生,避免 UI 观测与执行边界漂移。
|
||||
- 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。
|
||||
- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents/<group>/<role>.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。
|
||||
- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/<agentId>.jsonl`,不把原始对话混进项目黑板或角色私有记忆。
|
||||
|
||||
Reference in New Issue
Block a user