Merge branch 'codex/ai-game-creator-app' into fix/llm-parsing-and-null-output

This commit is contained in:
2026-06-26 22:00:02 +08:00
8 changed files with 622 additions and 3 deletions
@@ -88,6 +88,19 @@ struct GameCreatorLlmConfigStatus {
error: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRunControlResult {
run_id: String,
status: String,
lifecycle_status: String,
next_step: String,
message: String,
activity_path: String,
output_path: String,
context_bundle_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadLocalAssetResult {
@@ -754,6 +767,19 @@ fn get_local_game_manifest(project_path: String) -> Result<GameCreationAppManife
read_manifest_for_project(Path::new(project_path.trim()))
}
#[tauri::command]
fn control_agent_run(
project_path: String,
action: String,
detail: Option<String>,
) -> Result<AgentRunControlResult, String> {
update_agent_run_lifecycle(
Path::new(project_path.trim()),
action.trim(),
detail.as_deref().map(str::trim).filter(|value| !value.is_empty()),
)
}
#[tauri::command]
async fn generate_local_game_draft(
app: tauri::AppHandle,
@@ -3544,6 +3570,7 @@ fn write_agent_run_trace(
run_id: run_id.to_string(),
command_id: "game.generate_draft".to_string(),
status: status.to_string(),
lifecycle_status: Some(agent_run_lifecycle_status(status).to_string()),
passes,
max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES,
tool_call_count,
@@ -3584,6 +3611,216 @@ fn agent_run_stop_reason(status: &str, error: Option<&str>) -> &'static str {
}
}
fn agent_run_lifecycle_status(status: &str) -> &'static str {
match status {
"running" | "needs-revision" | "artifacts-written" | "preview-running" => "running",
"waiting" | "preview-stopped" => "waiting",
"pending" => "pending",
"killed" => "killed",
"failed" => "failed",
"passed" => "done",
_ => "scheduled",
}
}
fn append_agent_run_activity(
root: &Path,
run_id: &str,
event: &str,
message: &str,
) -> Result<(), String> {
append_agent_run_jsonl(
root,
".agent/activity.jsonl",
&serde_json::json!({
"timestamp": unix_timestamp(),
"runId": run_id,
"event": event,
"message": message,
}),
)
}
fn append_agent_run_output(
root: &Path,
run_id: &str,
event: &str,
message: &str,
) -> Result<(), String> {
append_agent_run_jsonl(
root,
".agent/output.jsonl",
&serde_json::json!({
"timestamp": unix_timestamp(),
"runId": run_id,
"event": event,
"content": message,
}),
)
}
fn append_agent_run_jsonl(
root: &Path,
relative_path: &str,
value: &serde_json::Value,
) -> Result<(), String> {
let path = root.join(relative_path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 Agent 事件目录失败:{}: {error}", parent.display()))?;
}
let mut line = serde_json::to_string(value)
.map_err(|error| format!("序列化 Agent 事件失败:{error}"))?;
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入 Agent 事件失败:{}: {error}", path.display()))
}
fn write_agent_run_context_bundle(root: &Path, trace: &GameCreationAgentRunTrace) -> Result<(), String> {
let bundle_path = root.join(".agent/context.bundle.json");
if let Some(parent) = bundle_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!(
"创建 Agent context bundle 目录失败:{}: {error}",
parent.display()
)
})?;
}
let manifest = read_manifest_for_project(root).ok();
let payload = serde_json::json!({
"schemaVersion": "game-creator-context-bundle.v1",
"runId": trace.run_id,
"commandId": trace.command_id,
"goal": trace.goal,
"lifecycleStatus": trace.lifecycle_status.as_deref().unwrap_or_else(|| agent_run_lifecycle_status(&trace.status)),
"status": trace.status,
"nextStep": trace.next_step,
"memory": {
"short": "memory/session.md",
"long": "memory/project.md"
},
"manifest": manifest,
"trace": ".agent/run.latest.json",
"activity": ".agent/activity.jsonl",
"output": ".agent/output.jsonl",
"updatedAt": unix_timestamp()
});
let content = serde_json::to_string_pretty(&payload)
.map_err(|error| format!("生成 Agent context bundle 失败:{error}"))?;
fs::write(&bundle_path, content).map_err(|error| {
format!(
"写入 Agent context bundle 失败:{}: {error}",
bundle_path.display()
)
})
}
fn update_agent_run_lifecycle(
root: &Path,
action: &str,
detail: Option<&str>,
) -> Result<AgentRunControlResult, String> {
let trace_path = root.join(".agent/run.latest.json");
let content = fs::read_to_string(&trace_path).map_err(|error| {
format!(
"读取 Agent run trace 失败:{}: {error}",
trace_path.display()
)
})?;
let mut trace =
serde_json::from_str::<GameCreationAgentRunTrace>(&content).map_err(|error| {
format!(
"解析 Agent run trace 失败:{}: {error}",
trace_path.display()
)
})?;
let (status, lifecycle_status, next_step, event, message) = match action {
"status" => {
let lifecycle = trace
.lifecycle_status
.clone()
.unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string());
(
trace.status.clone(),
lifecycle.clone(),
trace.next_step.clone(),
"agent.run_status",
format!("run {} 当前状态:{} / {}", trace.run_id, trace.status, lifecycle),
)
}
"kill" => (
"killed".to_string(),
"killed".to_string(),
"resume-or-retry".to_string(),
"agent.kill",
format!("run {} 已标记为 killed", trace.run_id),
),
"retry" => (
"pending".to_string(),
"pending".to_string(),
"runner-claim".to_string(),
"agent.retry",
format!("run {} 已重试,等待下一次 claim", trace.run_id),
),
"resume" => (
"pending".to_string(),
"pending".to_string(),
"runner-claim".to_string(),
"agent.resume",
format!(
"run {} 已恢复:{}",
trace.run_id,
detail.unwrap_or("等待下一次 claim")
),
),
_ => return Err("未知 Agent run 控制动作".to_string()),
};
if action != "status" {
trace.status = status;
trace.lifecycle_status = Some(lifecycle_status);
trace.next_step = next_step;
trace.stop_reason = match action {
"kill" => "killed",
"retry" => "retry-requested",
"resume" => "human-resume",
_ => trace.stop_reason.as_str(),
}
.to_string();
trace.error = if action == "kill" {
Some("用户请求停止当前 run".to_string())
} else {
None
};
trace.updated_at = unix_timestamp();
write_agent_run_trace_payload(root, &trace)?;
}
append_agent_run_activity(root, &trace.run_id, event, &message)?;
append_agent_run_output(root, &trace.run_id, event, &message)?;
write_agent_run_context_bundle(root, &trace)?;
Ok(AgentRunControlResult {
run_id: trace.run_id,
status: trace.status,
lifecycle_status: trace
.lifecycle_status
.unwrap_or_else(|| agent_run_lifecycle_status("scheduled").to_string()),
next_step: trace.next_step,
message,
activity_path: root.join(".agent/activity.jsonl").to_string_lossy().to_string(),
output_path: root.join(".agent/output.jsonl").to_string_lossy().to_string(),
context_bundle_path: root
.join(".agent/context.bundle.json")
.to_string_lossy()
.to_string(),
})
}
fn count_agent_tool_calls(steps: &[GameCreationAgentRunStep]) -> Result<u16, String> {
let count = steps.iter().try_fold(0u16, |current, step| {
let step_count = u16::try_from(step.tool_calls.len())
@@ -6005,6 +6242,7 @@ fn main() {
})
.invoke_handler(tauri::generate_handler![
init_local_game_project,
control_agent_run,
generate_local_game_draft,
check_game_creator_llm_config,
upload_local_asset,
@@ -8086,6 +8324,61 @@ GENARRATIVE_GAME_CREATOR_LLM_STREAM=true
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_run_control_updates_lifecycle_and_jsonl() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_agent_run_trace(
&root,
"run-control-1",
"做一个可控 run",
"running",
1,
&[agent_trace_step(
1,
"Planner",
"completed",
&[".agent/manifest.json"],
&[".agent/spec.md"],
"已拆解目标",
"llm.chat.planner",
)],
None,
)
.expect("run trace");
let killed =
update_agent_run_lifecycle(&root, "kill", None).expect("kill should update trace");
assert_eq!(killed.status, "killed");
assert_eq!(killed.lifecycle_status, "killed");
assert_eq!(killed.next_step, "resume-or-retry");
let trace: Value =
serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap())
.expect("run trace json");
assert_eq!(trace["status"], "killed");
assert_eq!(trace["lifecycleStatus"], "killed");
assert_eq!(trace["stopReason"], "killed");
let retried =
update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending");
assert_eq!(retried.status, "pending");
assert_eq!(retried.lifecycle_status, "pending");
assert_eq!(retried.next_step, "runner-claim");
let status =
update_agent_run_lifecycle(&root, "status", None).expect("status should read trace");
assert_eq!(status.status, "pending");
assert_eq!(status.lifecycle_status, "pending");
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.run_status"));
let output = fs::read_to_string(root.join(".agent/output.jsonl")).expect("output");
assert!(output.contains("agent.kill"));
assert!(root.join(".agent/context.bundle.json").exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn limited_local_command_rejects_placeholder_game_smoke() {
let root = unique_project_path();
+159 -1
View File
@@ -139,6 +139,17 @@ interface AgentProgressEvent {
message: string;
}
interface AgentRunControlResult {
runId: string;
status: string;
lifecycleStatus: string;
nextStep: string;
message: string;
activityPath: string;
outputPath: string;
contextBundlePath: string;
}
export type PendingCommand =
| {
id: 'game.generate_draft';
@@ -165,6 +176,10 @@ export type PendingCommand =
| {
id: 'preview.open';
}
| {
id: 'agent.kill' | 'agent.retry' | 'agent.resume';
detail?: string;
}
| {
id: 'memory.write';
scope: MemoryScope;
@@ -257,6 +272,10 @@ const chatCommandHelp = [
'/status:查看项目状态',
'/tasks:查看任务拆分',
'/trace 或 /loop:查看最近一次 Agent loop trace',
'/agent-status:查看最近 run 生命周期',
'/agent-kill:标记最近 run 为 killed',
'/agent-retry:把最近 run 标记为 pending 等待重试',
'/agent-resume [说明]:把最近 run 标记为 pending 等待继续',
'/files:列出本地项目文件',
'/assets:列出本地项目资产',
'/read 路径:读取本地项目内文本文件',
@@ -681,6 +700,9 @@ export function isAbsoluteProjectPath(value: string) {
export function needsInitializedChatProject(commandId: PendingCommand['id']) {
return [
'asset.upload',
'agent.kill',
'agent.retry',
'agent.resume',
'command.run_limited',
'game.generate_draft',
'game.run_local',
@@ -909,6 +931,13 @@ function pendingCommandTitle(command: PendingCommand) {
if (command.id === 'project.create') {
return command.id;
}
if (
command.id === 'agent.kill' ||
command.id === 'agent.retry' ||
command.id === 'agent.resume'
) {
return command.id;
}
if (command.id === 'memory.write' || command.id === 'memory.delete') {
return `${command.id} · ${memoryScopeLabel(command.scope)}`;
}
@@ -952,6 +981,15 @@ export function pendingCommandDetail(
if (command.id === 'preview.open') {
return '打开当前本地预览';
}
if (command.id === 'agent.kill') {
return `标记 ${projectPath}/.agent/run.latest.json 为 killed,并写入 activity/output`;
}
if (command.id === 'agent.retry') {
return `标记 ${projectPath}/.agent/run.latest.json 为 pending,等待 runner claim`;
}
if (command.id === 'agent.resume') {
return `附加用户说明并标记 ${projectPath}/.agent/run.latest.json 为 pending`;
}
if (command.id === 'memory.write') {
return `${
command.mode === 'replace' ? '覆盖保存到' : '追加到'
@@ -1241,6 +1279,50 @@ export function App() {
return;
}
if (prompt === '/agent-status') {
void executeAgentRunControl('status', undefined, true);
return;
}
if (prompt === '/agent-kill') {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({ id: 'agent.kill' });
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备标记最近 run 为 killed。' },
]);
return;
}
if (prompt === '/agent-retry') {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({ id: 'agent.retry' });
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备把最近 run 标记为 pending。' },
]);
return;
}
if (prompt === '/agent-resume' || prompt.startsWith('/agent-resume ')) {
if (!requireChatProjectForUserAction()) {
return;
}
queuePendingCommand({
id: 'agent.resume',
detail: prompt.slice('/agent-resume'.length).trim(),
});
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备恢复最近 run。' },
]);
return;
}
if (prompt === '/smoke') {
if (!requireChatProjectForUserAction()) {
return;
@@ -1707,6 +1789,16 @@ export function App() {
void executePreviewStart(true);
} else if (command.id === 'preview.open') {
void executePreviewOpen(true);
} else if (
command.id === 'agent.kill' ||
command.id === 'agent.retry' ||
command.id === 'agent.resume'
) {
void executeAgentRunControl(
command.id.slice('agent.'.length),
command.detail,
true,
);
} else if (command.id === 'memory.write') {
void executeMemoryWrite(
command.scope,
@@ -1735,7 +1827,7 @@ export function App() {
command.canvasProjectId,
true,
);
} else {
} else if (command.id === 'game.generate_draft') {
void executeGameDraft(command.prompt);
}
}
@@ -2088,6 +2180,72 @@ export function App() {
}
}
async function executeAgentRunControl(
action: string,
detail: string | undefined,
announceToChat: boolean,
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentRunStatus('需要在 Tauri App 内运行');
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
]);
}
return;
}
const nextProjectPath = requireChatProjectForUserAction();
if (!nextProjectPath) {
return;
}
try {
const result = await invoke<AgentRunControlResult>('control_agent_run', {
projectPath: nextProjectPath,
action,
detail,
});
setAgentRunStatus(
`${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`,
);
setCommandLog((current) => [
...current,
`agent.${action}`,
'file.write .agent/activity.jsonl',
'file.write .agent/output.jsonl',
'file.write .agent/context.bundle.json',
]);
await refreshAgentRunTrace(nextProjectPath);
if (announceToChat) {
setMessages((current) => [
...current,
{
role: 'assistant',
text: [
result.message,
`状态:${result.status} / ${result.lifecycleStatus}`,
`下一步:${result.nextStep}`,
`事件:${result.activityPath}`,
`输出:${result.outputPath}`,
`上下文包:${result.contextBundlePath}`,
].join('\n'),
},
]);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setAgentRunStatus(message);
if (announceToChat) {
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
}
}
}
async function executeAgentAuditChat() {
const invoke = resolveTauriInvoke();
if (!invoke) {
@@ -93,6 +93,8 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/),
).not.toBeNull();
expect(screen.getByText(/\/agent-status:查看最近 run 生命周期/)).not.toBeNull();
expect(screen.getByText(/\/agent-kill:标记最近 run 为 killed/)).not.toBeNull();
expect(screen.queryByLabelText('开发环境')).toBeNull();
});
@@ -2142,6 +2144,116 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
it('controls agent run lifecycle from chat through the authorized local project path', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
'未命名游戏原型',
);
const trace: GameCreationAgentRunTrace = {
schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
runId: 'run-control-chat',
commandId: 'game.generate_draft',
status: 'pending',
lifecycleStatus: 'pending',
passes: 1,
maxPasses: 3,
toolCallCount: 1,
maxToolCalls: 128,
stopReason: 'retry-requested',
goal: '做一个反弹弹幕厨房游戏',
coordination: 'filesystem',
steps: [],
artifacts: [],
taskGraph: {
goal: '做一个反弹弹幕厨房游戏',
readyTaskIds: [],
activeTaskIds: [],
carriedTaskIds: [],
repairFocus: [],
repairRoutes: [],
tasks: createGameCreationAppSeedTasks(),
},
passPlans: [],
nextStep: 'runner-claim',
error: null,
updatedAt: 1,
};
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 === 'control_agent_run') {
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',
activityPath: '/tmp/authorized-game/.agent/activity.jsonl',
outputPath: '/tmp/authorized-game/.agent/output.jsonl',
contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json',
};
}
if (command === 'read_local_project_file') {
return {
path: '.agent/run.latest.json',
absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`,
content: JSON.stringify(trace),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderAppAt('/');
submitChat('/project /tmp/authorized-game');
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
).not.toBeNull();
submitChat('/agent-status');
expect(
await screen.findByText(/run run-control-chat 当前状态:pending \/ pending/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'status',
detail: undefined,
});
submitChat('/agent-kill');
expect(screen.getByText('agent.kill')).not.toBeNull();
expect(
screen.getByText(
'标记 /tmp/authorized-game/.agent/run.latest.json 为 killed,并写入 activity/output',
),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
expect(
await screen.findByText(/run run-control-chat 已标记为 killed/),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('control_agent_run', {
projectPath: '/tmp/authorized-game',
action: 'kill',
detail: undefined,
});
});
it('manages long memory from chat through the authorized local project path', async () => {
const manifest = createGameCreationAppManifest(
'local-project-draft',
@@ -102,6 +102,9 @@ describe('AI 游戏创作聊天记忆命令', () => {
it('requires a project before chat commands write or run local artifacts', () => {
expect(needsInitializedChatProject('game.generate_draft')).toBe(true);
expect(needsInitializedChatProject('asset.upload')).toBe(true);
expect(needsInitializedChatProject('agent.kill')).toBe(true);
expect(needsInitializedChatProject('agent.retry')).toBe(true);
expect(needsInitializedChatProject('agent.resume')).toBe(true);
expect(needsInitializedChatProject('command.run_limited')).toBe(true);
expect(needsInitializedChatProject('preview.open')).toBe(true);
expect(needsInitializedChatProject('preview.start')).toBe(true);
@@ -113,6 +116,21 @@ describe('AI 游戏创作聊天记忆命令', () => {
expect(needsInitializedChatProject('canvas.project_open')).toBe(false);
});
it('describes agent run lifecycle controls before confirmation', () => {
expect(pendingCommandDetail({ id: 'agent.kill' }, '/tmp/game')).toBe(
'标记 /tmp/game/.agent/run.latest.json 为 killed,并写入 activity/output',
);
expect(pendingCommandDetail({ id: 'agent.retry' }, '/tmp/game')).toBe(
'标记 /tmp/game/.agent/run.latest.json 为 pending,等待 runner claim',
);
expect(
pendingCommandDetail(
{ id: 'agent.resume', detail: '继续修复输入监听' },
'/tmp/game',
),
).toBe('附加用户说明并标记 /tmp/game/.agent/run.latest.json 为 pending');
});
it('shows the authorized project path in pending write command details', () => {
expect(
resolvePendingCommandProjectPath(