完善创作壳运行控制审计

记录自动状态命令到本地命令日志

限制 command.auto 只能记录 auto 权限命令

补齐 agent retry 和 resume 的界面与生命周期测试
This commit is contained in:
AIGameCreator App
2026-06-30 10:19:51 +08:00
parent 9af13974db
commit 1fedd62c90
4 changed files with 138 additions and 20 deletions
@@ -243,6 +243,9 @@ for (const snippet of [
'#[cfg(debug_assertions)]\nfn open_developer_window(app: &tauri::App)',
'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())',
'open_developer_window(app)?;',
'fn append_local_permission_log_at(',
'"command.auto"',
'GameCreationAppPermission::Auto',
]) {
if (!tauriMainSource.includes(snippet)) {
throw new Error(
@@ -275,6 +278,8 @@ for (const snippet of [
"'permission.pending'",
"'permission.confirm'",
"'permission.cancel'",
"'command.auto'",
"'agent.run_status'",
'function summarizeAgentRunTrace',
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
'agentRunTrace.error ?',
@@ -27,7 +27,7 @@ use shared_contracts::game_creation_app::{
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPreviewState, GameCreationAppPreviewStatus,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState, GameCreationAppPreviewStatus,
GameCreationAppTaskState, GameCreationAppTaskStatus, GAME_CREATION_AGENT_CAPABILITIES,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
@@ -5401,15 +5401,18 @@ fn append_local_permission_log_at(
validate_project_root(root)?;
if !matches!(
event,
"permission.pending" | "permission.confirm" | "permission.cancel"
"permission.pending" | "permission.confirm" | "permission.cancel" | "command.auto"
) {
return Err("不支持的权限日志事件".to_string());
return Err("不支持的命令日志事件".to_string());
}
if !GAME_CREATION_APP_COMMANDS
let Some(command) = GAME_CREATION_APP_COMMANDS
.iter()
.any(|command| command.id == command_id)
{
.find(|command| command.id == command_id)
else {
return Err("不支持的内置命令".to_string());
};
if event == "command.auto" && command.permission != GameCreationAppPermission::Auto {
return Err("自动命令日志只能记录 auto 权限命令".to_string());
}
let log_path = root.join(".agent/logs/command.log");
@@ -8316,11 +8319,13 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
.expect("confirm log");
append_local_permission_log_at(&root, "permission.cancel", "memory.write")
.expect("cancel log");
append_local_permission_log_at(&root, "command.auto", "preview.status").expect("auto log");
let log = fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log");
assert!(log.contains("permission.pending preview.start"));
assert!(log.contains("permission.confirm preview.start"));
assert!(log.contains("permission.cancel memory.write"));
assert!(log.contains("command.auto preview.status"));
fs::remove_dir_all(root).ok();
}
@@ -8330,14 +8335,19 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let event_error = append_local_permission_log_at(&root, "permission.grant", "preview.start")
.expect_err("unknown event should fail");
let event_error =
append_local_permission_log_at(&root, "permission.grant", "preview.start")
.expect_err("unknown event should fail");
let command_error =
append_local_permission_log_at(&root, "permission.pending", "shell.exec")
.expect_err("unknown command should fail");
let auto_permission_error =
append_local_permission_log_at(&root, "command.auto", "agent.retry")
.expect_err("confirm command should not be auto-logged");
assert!(event_error.contains("不支持的权限日志事件"));
assert!(event_error.contains("不支持的命令日志事件"));
assert!(command_error.contains("不支持的内置命令"));
assert!(auto_permission_error.contains("auto 权限命令"));
fs::remove_dir_all(root).ok();
}
@@ -8431,6 +8441,16 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
assert_eq!(retried.status, "pending");
assert_eq!(retried.lifecycle_status, "pending");
assert_eq!(retried.next_step, "runner-claim");
let resumed = update_agent_run_lifecycle(&root, "resume", Some("继续修复输入监听"))
.expect("resume should mark pending");
assert_eq!(resumed.status, "pending");
assert_eq!(resumed.lifecycle_status, "pending");
assert_eq!(resumed.next_step, "runner-claim");
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json after resume");
assert_eq!(trace["stopReason"], "human-resume");
assert_eq!(trace["error"], Value::Null);
let status =
update_agent_run_lifecycle(&root, "status", None).expect("status should read trace");
assert_eq!(status.status, "pending");
@@ -8438,9 +8458,11 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity");
assert!(activity.contains("agent.kill"));
assert!(activity.contains("agent.retry"));
assert!(activity.contains("agent.resume"));
assert!(activity.contains("agent.run_status"));
let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output");
assert!(output.contains("agent.kill"));
assert!(output.contains("agent.resume"));
assert!(root.join(".agent/context.bundle.json").exists());
fs::remove_dir_all(root).ok();
+26 -3
View File
@@ -10,6 +10,7 @@ import {
GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS,
type GameCreationAppAgentGroup,
type GameCreationAppCommandDescriptor,
type GameCreationAgentRunTrace,
type GameCreationAppManifest,
type GameCreationAppPermission,
@@ -1099,8 +1100,12 @@ export function App() {
function appendLocalPermissionLog(
projectPath: string | null,
event: 'permission.pending' | 'permission.confirm' | 'permission.cancel',
commandId: PendingCommand['id'],
event:
| 'permission.pending'
| 'permission.confirm'
| 'permission.cancel'
| 'command.auto',
commandId: GameCreationAppCommandDescriptor['id'],
) {
const invoke = resolveTauriInvoke();
if (!invoke || !projectPath) {
@@ -2211,13 +2216,24 @@ export function App() {
setAgentRunStatus(
`${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`,
);
const commandId =
action === 'status'
? 'agent.run_status'
: (`agent.${action}` as GameCreationAppCommandDescriptor['id']);
setCommandLog((current) => [
...current,
`agent.${action}`,
commandId,
'file.write .agent/activity.jsonl',
'file.write .agent/output.jsonl',
'file.write .agent/context.bundle.json',
]);
if (announceToChat && action === 'status') {
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'agent.run_status',
);
}
await refreshAgentRunTrace(nextProjectPath);
if (announceToChat) {
setMessages((current) => [
@@ -2501,6 +2517,13 @@ export function App() {
setPreviewStatus('未启动');
}
setCommandLog((current) => [...current, 'preview.status']);
if (announceToChat && nextProjectPath) {
appendLocalPermissionLog(
nextProjectPath,
'command.auto',
'preview.status',
);
}
if (announceToChat) {
setMessages((current) => [
...current,
@@ -1092,6 +1092,11 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', {
projectPath: '/tmp/authorized-game',
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
projectPath: '/tmp/authorized-game',
event: 'command.auto',
commandId: 'preview.status',
});
});
it('runs static smoke and starts preview from chat through the authorized project path', async () => {
@@ -2193,16 +2198,40 @@ describe('AI 游戏创作 App 界面边界', () => {
};
}
if (command === 'control_agent_run') {
const action = String(args?.action ?? '');
const detail = String(args?.detail ?? '');
const resultByAction = {
status: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: 'run run-control-chat 当前状态:pending / pending',
},
kill: {
status: 'killed',
lifecycleStatus: 'killed',
nextStep: 'resume-or-retry',
message: 'run run-control-chat 已标记为 killed',
},
retry: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: 'run run-control-chat 已重试,等待下一次 claim',
},
resume: {
status: 'pending',
lifecycleStatus: 'pending',
nextStep: 'runner-claim',
message: `run run-control-chat 已恢复:${detail}`,
},
}[action];
if (!resultByAction) {
throw new Error(`unexpected agent run action ${action}`);
}
return {
runId: 'run-control-chat',
status: args?.action === 'kill' ? 'killed' : 'pending',
lifecycleStatus: args?.action === 'kill' ? 'killed' : 'pending',
nextStep:
args?.action === 'kill' ? 'resume-or-retry' : 'runner-claim',
message:
args?.action === 'kill'
? 'run run-control-chat 已标记为 killed'
: 'run run-control-chat 当前状态:pending / pending',
...resultByAction,
activityPath: '/tmp/authorized-game/.agent/activity.jsonl',
outputPath: '/tmp/authorized-game/.agent/output.jsonl',
contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json',
@@ -2236,6 +2265,11 @@ describe('AI 游戏创作 App 界面边界', () => {
action: 'status',
detail: undefined,
});
expect(invoke).toHaveBeenCalledWith('append_local_permission_log', {
projectPath: '/tmp/authorized-game',
event: 'command.auto',
commandId: 'agent.run_status',
});
submitChat('/agent-kill');
expect(screen.getByText('agent.kill')).not.toBeNull();
@@ -2253,6 +2287,40 @@ describe('AI 游戏创作 App 界面边界', () => {
action: 'kill',
detail: undefined,
});
submitChat('/agent-retry');
expect(screen.getByText('agent.retry')).not.toBeNull();
expect(
screen.getByText(
'标记 /tmp/authorized-game/.agent/run.latest.json 为 pending,等待 runner claim',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已重试,等待下一次 claim/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'retry',
detail: undefined,
});
submitChat('/agent-resume 继续修复输入监听');
expect(screen.getByText('agent.resume')).not.toBeNull();
expect(
screen.getByText(
'附加用户说明并标记 /tmp/authorized-game/.agent/run.latest.json 为 pending',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已恢复:继续修复输入监听/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'resume',
detail: '继续修复输入监听',
});
});
it('manages long memory from chat through the authorized local project path', async () => {