完善 Agent 多会话运行时

为开发者 Agent 聊天新增会话创建、切换、归档和历史只读界面
按会话隔离对话、运行状态、任务事件、提示上下文和后台回复
保持单 Agent FIFO 与 OS 锁并让重试恢复沿用原会话
补齐 Tauri 命令、持久化目录、旧会话兼容与回归测试
同步实施方案和项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-10 18:22:01 +08:00
parent aea75ef701
commit 1ea6671583
10 changed files with 2695 additions and 137 deletions
File diff suppressed because it is too large Load Diff
@@ -241,6 +241,7 @@ pub(crate) async fn chat_with_game_creator_agent(
pub(crate) async fn chat_with_game_creator_role_agent(
project_path: String,
agent_id: String,
session_id: Option<String>,
prompt: String,
) -> Result<GameCreatorChatAgentReply, String> {
let root = Path::new(project_path.trim());
@@ -248,9 +249,15 @@ pub(crate) async fn chat_with_game_creator_role_agent(
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_at(root, &agent_id, prompt.trim(), "")
.await
.map(|(reply, _runtime)| reply)
chat_with_game_creator_role_agent_runtime_for_session_at(
root,
&agent_id,
session_id.as_deref(),
prompt.trim(),
"",
)
.await
.map(|(reply, _runtime)| reply)
}
#[tauri::command]
@@ -258,6 +265,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
app: tauri::AppHandle,
project_path: String,
agent_id: String,
session_id: Option<String>,
prompt: String,
run_id: String,
) -> Result<GameCreatorChatAgentReply, String> {
@@ -265,6 +273,8 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
let agent_id = normalize_game_creator_runtime_agent_id(agent_id.trim())?;
let run_id = run_id.trim().to_string();
let root = Path::new(project_path.as_str());
let session_id =
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")?;
@@ -272,8 +282,13 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
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_at(root, agent_id.as_str(), prompt.trim(), &run_id)?;
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,
@@ -300,9 +315,10 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
runtime_state: Some(runtime_state.clone()),
},
);
let result = chat_with_game_creator_role_agent_stream_at(
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(
@@ -315,7 +331,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
delta_text: delta.delta_text.clone(),
accumulated_text: delta.accumulated_text.clone(),
finish_reason: delta.finish_reason.clone(),
session_id: None,
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()),
@@ -383,6 +399,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream(
pub(crate) fn start_game_creator_agent_runtime_task(
project_path: String,
agent_id: String,
session_id: Option<String>,
task: String,
run_id: String,
) -> Result<AgentRuntimeResult, String> {
@@ -390,7 +407,13 @@ pub(crate) fn start_game_creator_agent_runtime_task(
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
enforce_project_permission_policy(root, "agent.run_status")?;
start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim())
start_game_creator_agent_background_task_for_session_at(
root,
agent_id.trim(),
session_id.as_deref(),
task.trim(),
run_id.trim(),
)
}
#[tauri::command]
@@ -476,10 +499,11 @@ pub(crate) fn reject_game_creator_agent_runtime_task(
pub(crate) fn read_game_creator_agent_runtime(
project_path: String,
agent_id: String,
session_id: Option<String>,
) -> Result<AgentRuntimeResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_game_creator_agent_runtime_at(root, agent_id.trim())
read_game_creator_agent_runtime_for_session_at(root, agent_id.trim(), session_id.as_deref())
}
#[tauri::command]
@@ -827,26 +851,82 @@ pub(crate) fn delete_local_game_memory(
delete_local_game_memory_at(root, scope.trim())
}
#[tauri::command]
pub(crate) fn list_game_creator_agent_sessions(
project_path: String,
agent_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
list_game_creator_agent_sessions_at(root, agent_id.trim())
}
#[tauri::command]
pub(crate) fn create_game_creator_agent_session(
project_path: String,
agent_id: String,
title: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
}
#[tauri::command]
pub(crate) fn set_active_game_creator_agent_session(
project_path: String,
agent_id: String,
session_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
set_active_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
}
#[tauri::command]
pub(crate) fn archive_game_creator_agent_session(
project_path: String,
agent_id: String,
session_id: String,
) -> Result<AgentConversationSessionListResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
archive_game_creator_agent_session_at(root, agent_id.trim(), session_id.trim())
}
#[tauri::command]
pub(crate) fn read_local_conversation(
project_path: String,
agent_id: Option<String>,
session_id: Option<String>,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
read_local_conversation_at(root, agent_id.as_deref())
read_local_conversation_for_session_at(root, agent_id.as_deref(), session_id.as_deref())
}
#[tauri::command]
pub(crate) fn append_local_conversation_message(
project_path: String,
agent_id: Option<String>,
session_id: Option<String>,
message: LocalConversationMessage,
) -> Result<LocalConversationResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.write")?;
let _lock = acquire_project_write_lock(root, "conversation.write")?;
append_local_conversation_message_at(root, agent_id.as_deref(), message)
append_local_conversation_message_for_session_at(
root,
agent_id.as_deref(),
session_id.as_deref(),
message,
)
}
#[tauri::command]
@@ -665,9 +665,31 @@ struct LocalConversationMessageRecord {
struct LocalConversationResult {
path: String,
agent_id: Option<String>,
session_id: Option<String>,
messages: Vec<LocalConversationMessageRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionRecord {
session_id: String,
title: String,
created_at: u64,
updated_at: u64,
archived_at: Option<u64>,
message_count: u64,
legacy: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionListResult {
path: String,
agent_id: String,
active_session_id: String,
sessions: Vec<AgentConversationSessionRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPermissionPolicy {
@@ -808,6 +830,7 @@ const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json";
const PROJECT_INDEX_PATH: &str = ".agent/project.index.json";
const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock";
const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1";
const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1";
const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20;
const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12;
@@ -1254,6 +1277,10 @@ fn main() {
write_local_agent_memory,
write_local_game_memory,
delete_local_game_memory,
list_game_creator_agent_sessions,
create_game_creator_agent_session,
set_active_game_creator_agent_session,
archive_game_creator_agent_session,
read_local_conversation,
append_local_conversation_message,
build_local_project_index,
File diff suppressed because it is too large Load Diff
@@ -11067,6 +11067,484 @@ fn local_conversation_can_read_and_append_project_and_agent_messages() {
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_conversation_sessions_preserve_legacy_and_isolate_new_history() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
append_local_conversation_message_at(
&root,
Some("design-director"),
LocalConversationMessage {
role: "assistant".to_string(),
content: "legacy history".to_string(),
agent_id: None,
},
)
.expect("append legacy history");
let legacy =
list_game_creator_agent_sessions_at(&root, "design-director").expect("list legacy session");
assert_eq!(legacy.sessions.len(), 1);
assert_eq!(legacy.active_session_id, "agent-session-design-director");
assert!(legacy.sessions[0].legacy);
assert_eq!(legacy.sessions[0].message_count, 1);
let created = create_game_creator_agent_session_at(&root, "design-director", "角色规范")
.expect("create session");
let new_session_id = created.active_session_id.clone();
assert_ne!(new_session_id, "agent-session-design-director");
append_local_conversation_message_for_session_at(
&root,
Some("design-director"),
Some(&new_session_id),
LocalConversationMessage {
role: "user".to_string(),
content: "new session only".to_string(),
agent_id: None,
},
)
.expect("append new session");
let legacy_history = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some("agent-session-design-director"),
)
.expect("read legacy");
let new_history = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&new_session_id),
)
.expect("read new session");
assert_eq!(legacy_history.messages.len(), 1);
assert_eq!(legacy_history.messages[0].content, "legacy history");
assert_eq!(new_history.messages.len(), 1);
assert_eq!(new_history.messages[0].content, "new session only");
assert!(legacy_history.path.ends_with("design-director.jsonl"));
assert!(new_history
.path
.ends_with(&format!("design-director/sessions/{new_session_id}.jsonl")));
let legacy_context = render_local_conversation_prompt_context_for_session(
&root,
Some("design-director"),
Some("agent-session-design-director"),
)
.expect("render legacy context");
assert!(legacy_context.contains("legacy history"));
assert!(!legacy_context.contains("new session only"));
let new_context = render_local_conversation_prompt_context_for_session(
&root,
Some("design-director"),
Some(&new_session_id),
)
.expect("render new session context");
assert!(new_context.contains("new session only"));
assert!(!new_context.contains("legacy history"));
let listed = list_game_creator_agent_sessions_at(&root, "design-director")
.expect("reload session catalog");
assert_eq!(listed.active_session_id, new_session_id);
assert_eq!(
listed
.sessions
.iter()
.find(|session| session.session_id == listed.active_session_id)
.map(|session| session.message_count),
Some(1)
);
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_conversation_session_archive_is_read_only_and_keeps_active_session() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let created = create_game_creator_agent_session_at(&root, "design-director", "待归档")
.expect("create session");
let session_id = created.active_session_id.clone();
append_local_conversation_message_for_session_at(
&root,
Some("design-director"),
Some(&session_id),
LocalConversationMessage {
role: "user".to_string(),
content: "archive me".to_string(),
agent_id: None,
},
)
.expect("append before archive");
let archived = archive_game_creator_agent_session_at(&root, "design-director", &session_id)
.expect("archive session");
assert_eq!(archived.active_session_id, "agent-session-design-director");
assert!(archived
.sessions
.iter()
.find(|session| session.session_id == session_id)
.and_then(|session| session.archived_at)
.is_some());
let history =
read_local_conversation_for_session_at(&root, Some("design-director"), Some(&session_id))
.expect("archived history remains readable");
assert_eq!(history.messages[0].content, "archive me");
let error = append_local_conversation_message_for_session_at(
&root,
Some("design-director"),
Some(&session_id),
LocalConversationMessage {
role: "assistant".to_string(),
content: "should fail".to_string(),
agent_id: None,
},
)
.expect_err("archived session is read only");
assert!(error.contains("已归档"));
assert!(archive_game_creator_agent_session_at(
&root,
"design-director",
"agent-session-design-director",
)
.expect_err("legacy session cannot archive")
.contains("legacy"));
assert!(start_game_creator_agent_runtime_turn_for_session_at(
&root,
"design-director",
Some(&session_id),
"archived runtime must fail",
"archived-session-run",
)
.expect_err("archived session cannot start runtime")
.contains("已归档"));
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_conversation_session_rejects_unsafe_ids() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let error =
read_local_conversation_for_session_at(&root, Some("design-director"), Some("../other"))
.expect_err("unsafe session id");
assert!(error.contains("session id"));
assert!(
set_active_game_creator_agent_session_at(&root, "design-director", "/tmp/other",)
.expect_err("absolute session id")
.contains("session id")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_runtime_session_views_filter_before_recent_limits() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let first = create_game_creator_agent_session_at(&root, "design-director", "第一会话")
.expect("create first session");
let first_session_id = first.active_session_id.clone();
let first_state = start_game_creator_agent_runtime_task_for_session_at(
&root,
"design-director",
Some(&first_session_id),
"first session task",
"first-session-run",
"agent-background-task",
"start first session task",
vec!["finish first session task".to_string()],
)
.expect("start first session runtime");
finish_game_creator_agent_runtime_turn_at(&root, first_state, "first session reply")
.expect("finish first session runtime");
let second = create_game_creator_agent_session_at(&root, "design-director", "第二会话")
.expect("create second session");
let second_session_id = second.active_session_id.clone();
for index in 0..14 {
let state = start_game_creator_agent_runtime_task_for_session_at(
&root,
"design-director",
Some(&second_session_id),
&format!("second session task {index}"),
&format!("second-session-run-{index}"),
"agent-background-task",
"start second session task",
vec!["finish second session task".to_string()],
)
.expect("start second session runtime");
finish_game_creator_agent_runtime_turn_at(
&root,
state,
&format!("second session reply {index}"),
)
.expect("finish second session runtime");
}
let first_view = read_game_creator_agent_runtime_for_session_at(
&root,
"design-director",
Some(&first_session_id),
)
.expect("read first session runtime");
assert_eq!(first_view.state.session_id, first_session_id);
assert_eq!(first_view.state.status, "idle");
assert_eq!(first_view.state.phase, "idle");
assert!(first_view.state.current_task.is_empty());
assert_eq!(first_view.task_queue.total, 1);
assert_eq!(first_view.recent_tasks.len(), 1);
assert!(!first_view.recent_events.is_empty());
assert!(first_view
.recent_tasks
.iter()
.all(|task| task.session_id == first_view.state.session_id));
assert!(first_view
.recent_events
.iter()
.all(|event| event.session_id == first_view.state.session_id));
let second_view = read_game_creator_agent_runtime_for_session_at(
&root,
"design-director",
Some(&second_session_id),
)
.expect("read second session runtime");
assert_eq!(second_view.state.session_id, second_session_id);
assert_eq!(second_view.task_queue.total, 14);
assert!(second_view
.recent_tasks
.iter()
.all(|task| task.session_id == second_view.state.session_id));
assert!(second_view
.recent_events
.iter()
.all(|event| event.session_id == second_view.state.session_id));
let global_view = read_game_creator_agent_runtime_at(&root, "design-director")
.expect("read global agent runtime");
assert_eq!(global_view.task_queue.total, 15);
assert_eq!(
global_view.recent_tasks.len(),
AGENT_RUNTIME_RECENT_TASK_LIMIT
);
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn agent_runtime_conversation_tool_uses_run_session() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
append_local_conversation_message_at(
&root,
Some("design-director"),
LocalConversationMessage {
role: "user".to_string(),
content: "legacy session context".to_string(),
agent_id: None,
},
)
.expect("append legacy context");
let active = create_game_creator_agent_session_at(&root, "design-director", "当前会话")
.expect("create active session");
append_local_conversation_message_for_session_at(
&root,
Some("design-director"),
Some(&active.active_session_id),
LocalConversationMessage {
role: "user".to_string(),
content: "active session context".to_string(),
agent_id: None,
},
)
.expect("append active context");
let state = start_game_creator_agent_runtime_task_for_session_at(
&root,
"design-director",
Some("agent-session-design-director"),
"read original conversation",
"conversation-session-run",
"agent-background-task",
"read conversation",
vec!["read conversation".to_string()],
)
.expect("start runtime in legacy session");
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: "design-director".to_string(),
task_id: "design-director".to_string(),
session_id: active.active_session_id.clone(),
run_id: "other-session-task".to_string(),
source: "agent-background-task".to_string(),
task: "must stay outside current run status".to_string(),
status: "failed".to_string(),
phase: "failed".to_string(),
current_action: "failed".to_string(),
error: Some("other session failure".to_string()),
updated_at: unix_timestamp(),
},
);
let observation = execute_game_creator_agent_runtime_tool_action(
&root,
"design-director",
&state.run_id,
&state.current_task,
&AgentRuntimeToolAction {
tool: "conversation.read".to_string(),
reason: Some("verify captured session".to_string()),
input: serde_json::json!({}),
},
)
.await;
assert_eq!(observation.status, "ok");
let detail = observation.detail.expect("conversation observation detail");
assert!(detail.contains("legacy session context"));
assert!(!detail.contains("active session context"));
let status_observation = execute_game_creator_agent_runtime_tool_action(
&root,
"design-director",
&state.run_id,
&state.current_task,
&AgentRuntimeToolAction {
tool: "agent.run_status".to_string(),
reason: Some("verify session-scoped runtime status".to_string()),
input: serde_json::json!({ "scope": "self" }),
},
)
.await;
assert_eq!(status_observation.status, "ok");
let status_detail = status_observation.detail.expect("runtime status detail");
assert!(status_detail.contains("任务队列: total=1"));
assert!(!status_detail.contains("must stay outside current run status"));
finish_game_creator_agent_runtime_turn_at(&root, state, "done")
.expect("finish conversation runtime");
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_background_queue_captures_explicit_session() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let first = create_game_creator_agent_session_at(&root, "design-director", "排队会话")
.expect("create queued session");
let first_session_id = first.active_session_id.clone();
let second = create_game_creator_agent_session_at(&root, "design-director", "当前会话")
.expect("create active session");
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
.expect("acquire agent runtime lock")
.expect("runtime lock available");
let queued = start_game_creator_agent_background_task_for_session_at(
&root,
"design-director",
Some(&first_session_id),
"queued in first session",
"captured-session-run",
)
.expect("queue background task");
assert_eq!(queued.state.session_id, first_session_id);
assert_eq!(queued.task_queue.pending, 1);
assert!(queued
.recent_tasks
.iter()
.all(|task| { task.session_id == queued.state.session_id && task.status == "pending" }));
let first_conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&queued.state.session_id),
)
.expect("read queued session conversation");
assert_eq!(first_conversation.messages.len(), 1);
assert_eq!(
first_conversation.messages[0].content,
"queued in first session"
);
let second_conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&second.active_session_id),
)
.expect("read active session conversation");
assert!(second_conversation.messages.is_empty());
drop(runtime_lock);
fs::remove_dir_all(root).ok();
}
#[test]
fn agent_runtime_retry_preserves_original_session() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
let original = create_game_creator_agent_session_at(&root, "design-director", "原任务会话")
.expect("create original session");
let original_session_id = original.active_session_id.clone();
let active = create_game_creator_agent_session_at(&root, "design-director", "当前会话")
.expect("create current session");
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: "design-director".to_string(),
task_id: "design-director".to_string(),
session_id: original_session_id.clone(),
run_id: "failed-original-run".to_string(),
source: "agent-background-task".to_string(),
task: "retry in original session".to_string(),
status: "failed".to_string(),
phase: "failed".to_string(),
current_action: "failed".to_string(),
error: Some("test failure".to_string()),
updated_at: unix_timestamp(),
},
);
let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(&root, "design-director")
.expect("acquire agent runtime lock")
.expect("runtime lock available");
let retried = retry_game_creator_agent_runtime_task_at(
&root,
"design-director",
"failed-original-run",
"retry-original-session-run",
)
.expect("retry task");
assert_eq!(retried.state.session_id, original_session_id);
assert_eq!(retried.task_queue.failed, 1);
assert_eq!(retried.task_queue.pending, 1);
assert!(retried.recent_tasks.iter().any(|task| {
task.run_id == "retry-original-session-run"
&& task.session_id == retried.state.session_id
&& task.status == "pending"
}));
let original_conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&retried.state.session_id),
)
.expect("read original session conversation");
assert_eq!(original_conversation.messages.len(), 1);
assert_eq!(
original_conversation.messages[0].content,
"retry in original session"
);
let active_conversation = read_local_conversation_for_session_at(
&root,
Some("design-director"),
Some(&active.active_session_id),
)
.expect("read current session conversation");
assert!(active_conversation.messages.is_empty());
drop(runtime_lock);
fs::remove_dir_all(root).ok();
}
#[test]
fn local_conversation_prompt_context_scopes_agent_messages() {
let root = unique_project_path();
@@ -11290,6 +11768,7 @@ fn local_conversation_write_respects_project_policy() {
let error = append_local_conversation_message(
root.to_string_lossy().into_owned(),
None,
None,
LocalConversationMessage {
role: "user".to_string(),
content: "should fail".to_string(),
@@ -11326,7 +11805,7 @@ fn local_conversation_read_respects_project_policy() {
)
.expect("write policy");
let error = read_local_conversation(root.to_string_lossy().into_owned(), None)
let error = read_local_conversation(root.to_string_lossy().into_owned(), None, None)
.expect_err("conversation read denied");
assert!(error.contains("项目权限策略拒绝执行:conversation.read"));
File diff suppressed because it is too large Load Diff
+132 -1
View File
@@ -286,6 +286,7 @@ textarea {
.launcher-main {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto auto;
align-content: start;
min-width: 0;
@@ -1039,10 +1040,29 @@ textarea {
padding-top: 92px;
}
.launcher-agent-chat-page > header .launcher-project-list-actions button {
height: 34px;
padding: 0 12px;
border: 1px solid #d8dde5;
border-radius: 6px;
background: #fff;
color: #111827;
font-size: 12px;
white-space: nowrap;
}
.launcher-agent-chat-page > header .launcher-project-list-actions button:last-child {
border-color: #111827;
background: #111827;
color: #fff;
}
.launcher-agent-chat-layout {
display: grid;
grid-template-columns: minmax(220px, 0.3fr) minmax(0, 1fr);
gap: 12px;
width: 100%;
min-width: 0;
min-height: min(620px, calc(100vh - 168px));
}
@@ -1129,7 +1149,7 @@ textarea {
.launcher-agent-chat-main {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
overflow: hidden;
}
@@ -1157,6 +1177,84 @@ textarea {
font-size: 12px;
}
.launcher-agent-session-bar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
background: #f8fafc;
}
.launcher-agent-session-actions,
.launcher-agent-session-list {
display: flex;
align-items: center;
gap: 6px;
}
.launcher-agent-session-actions button {
display: grid;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid #d8dde5;
border-radius: 6px;
background: #fff;
color: #374151;
place-items: center;
}
.launcher-agent-session-list {
min-width: 0;
overflow-x: auto;
}
.launcher-agent-session-list button {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 7px;
max-width: 190px;
height: 30px;
padding: 0 9px;
border: 1px solid #d8dde5;
border-radius: 6px;
background: #fff;
color: #374151;
}
.launcher-agent-session-list button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.launcher-agent-session-list button small {
flex: 0 0 auto;
}
.launcher-agent-session-list .launcher-agent-session-active {
border-color: #7c3aed;
background: #f5f0ff;
color: #5b21b6;
}
.launcher-agent-session-list .launcher-agent-session-archived {
border-style: dashed;
color: #6b7280;
}
.launcher-agent-session-status {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.launcher-agent-chat-messages {
display: grid;
align-content: start;
@@ -1596,11 +1694,44 @@ textarea {
width: min(100%, calc(100vw - 76px));
}
.launcher-agent-chat-page > header,
.launcher-agent-chat-main > header {
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.launcher-agent-chat-main > header > small {
max-width: 100%;
overflow-wrap: anywhere;
}
.launcher-showcase-grid,
.launcher-development-grid {
grid-template-columns: 1fr;
}
.launcher-agent-chat-layout {
grid-template-columns: 1fr;
min-height: auto;
}
.launcher-agent-picker {
max-height: 240px;
}
.launcher-agent-chat-main {
min-height: 620px;
}
.launcher-agent-session-bar {
grid-template-columns: auto minmax(0, 1fr);
}
.launcher-agent-session-status {
display: none;
}
.launcher-development-assets div {
grid-template-columns: 1fr;
}
@@ -1063,6 +1063,217 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
it('creates, switches, archives, and isolates developer Agent sessions', async () => {
type SessionRecord = {
sessionId: string;
title: string;
createdAt: number;
updatedAt: number;
archivedAt: number | null;
messageCount: number;
legacy: boolean;
};
const legacySessionId = 'agent-session-design-director';
const roleSessionId = 'agent-session-design-director-role-spec';
const createdSessionId = 'agent-session-design-director-created';
let activeSessionId = roleSessionId;
const sessions: SessionRecord[] = [
{
sessionId: legacySessionId,
title: '默认会话',
createdAt: 1,
updatedAt: 1,
archivedAt: null,
messageCount: 1,
legacy: true,
},
{
sessionId: roleSessionId,
title: '角色规范',
createdAt: 2,
updatedAt: 2,
archivedAt: null,
messageCount: 1,
legacy: false,
},
];
const messages = new Map<string, Array<{ role: string; content: string }>>([
[legacySessionId, [{ role: 'assistant', content: '默认会话历史' }]],
[roleSessionId, [{ role: 'assistant', content: '角色规范历史' }]],
]);
const sessionResult = () => ({
path: '/tmp/authorized-game/.agent/runtime/sessions/design-director.json',
agentId: 'design-director',
activeSessionId,
sessions: sessions.map((session) => ({
...session,
messageCount: messages.get(session.sessionId)?.length ?? 0,
})),
});
const conversationResult = (sessionId: string) => ({
path:
sessionId === legacySessionId
? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl'
: `/tmp/authorized-game/.agent/conversations/agents/design-director/sessions/${sessionId}.jsonl`,
agentId: 'design-director',
sessionId,
messages: (messages.get(sessionId) ?? []).map((message, index) => ({
schemaVersion: 'game-creator-conversation.v1',
...message,
agentId: 'design-director',
updatedAt: index + 1,
})),
});
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'list_game_creator_agent_sessions') {
return sessionResult();
}
if (command === 'set_active_game_creator_agent_session') {
activeSessionId = String(args?.sessionId);
return sessionResult();
}
if (command === 'create_game_creator_agent_session') {
activeSessionId = createdSessionId;
sessions.push({
sessionId: createdSessionId,
title: '新会话',
createdAt: 3,
updatedAt: 3,
archivedAt: null,
messageCount: 0,
legacy: false,
});
messages.set(createdSessionId, []);
return sessionResult();
}
if (command === 'archive_game_creator_agent_session') {
const session = sessions.find(
(candidate) => candidate.sessionId === args?.sessionId,
);
if (session) {
session.archivedAt = 4;
}
activeSessionId = legacySessionId;
return sessionResult();
}
if (command === 'read_local_conversation') {
return conversationResult(
String(args?.sessionId ?? legacySessionId),
);
}
if (command === 'append_local_conversation_message') {
const sessionId = String(args?.sessionId ?? legacySessionId);
const message = args?.message as { role: string; content: string };
messages.get(sessionId)?.push(message);
return conversationResult(sessionId);
}
if (command === 'chat_with_game_creator_role_agent') {
return { replyText: '新会话回复' };
}
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: '读取历史' }));
expect(await screen.findByText('角色规范历史')).not.toBeNull();
expect(screen.queryByText('默认会话历史')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /默认会话/ }));
expect(await screen.findByText('默认会话历史')).not.toBeNull();
expect(screen.queryByText('角色规范历史')).toBeNull();
expect(invoke).toHaveBeenCalledWith(
'set_active_game_creator_agent_session',
{
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId: legacySessionId,
},
);
fireEvent.click(screen.getByRole('button', { name: '新建 Agent 会话' }));
expect(await screen.findByText('暂无对话')).not.toBeNull();
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '只属于新会话的问题' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText('新会话回复')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId: createdSessionId,
prompt: '只属于新会话的问题',
});
fireEvent.click(
screen.getByRole('button', { name: '归档当前 Agent 会话' }),
);
expect(await screen.findByText('默认会话历史')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'archive_game_creator_agent_session',
{
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId: createdSessionId,
},
);
fireEvent.click(
screen.getByRole('button', { name: /新会话\s+已归档/ }),
);
expect(await screen.findByText('只属于新会话的问题')).not.toBeNull();
expect(screen.getByLabelText('Agent 聊天内容')).toHaveProperty(
'disabled',
true,
);
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
expect(await screen.findByText('只属于新会话的问题')).not.toBeNull();
await waitFor(() => {
expect(invoke).toHaveBeenLastCalledWith(
'read_game_creator_agent_runtime',
{
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId: createdSessionId,
},
);
});
});
it('does not downgrade a real Agent session catalog error to legacy writes', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'list_game_creator_agent_sessions') {
throw new Error('读取 Agent Session 目录失败:permission denied');
}
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: '读取历史' }));
expect(
await screen.findAllByText(
'Agent 会话列表读取失败:读取 Agent Session 目录失败:permission denied',
),
).toHaveLength(2);
expect(invoke).not.toHaveBeenCalledWith(
'read_local_conversation',
expect.anything(),
);
expect(screen.getByRole('button', { name: '新建 Agent 会话' })).toHaveProperty(
'disabled',
true,
);
});
it('shows developer agent chat LLM configuration gaps before sending', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
@@ -4088,6 +4088,7 @@
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增只读 `file.list`。Agent 可自行列出项目文件摘要或相对路径范围内的条目,先观察项目结构再决定是否读取具体文件;该工具必须受 `file.list` 项目权限策略保护,observation 只返回项目相对路径、类型和大小,不读取文件内容、不返回本机绝对路径。
- 2026-07-10 调整:Agent Runtime V1 后台工具箱新增受策略保护的 `agent.delegate`。Agent 可把任务投递给另一个 Agent 的独立后台队列,复用目标 Agent 既有锁和 pending drain 语义;策略要求确认或拒绝时不得写目标 Agent 对话、不得启动目标任务,也不得写 `agent.runtime.agent.delegate` 审计记录。
- 2026-07-10 调整:Agent Runtime V1 新增 `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 调整:每个 Agent 新增独立持久化 Session 管理。legacy `agent-session-<agentId>` 继续读写 `.agent/conversations/agents/<agentId>.jsonl`;新 Session 写 `.agent/conversations/agents/<agentId>/sessions/<sessionId>.jsonl``.agent/runtime/sessions/<agentId>.json` 原子保存 Session catalog 和 active Session。开发单 Agent 聊天页支持列表、创建、切换、归档和归档历史只读查看;归档不删除消息,运行中、排队中、等待确认、取消中或 `needs-reconciliation` 的 Session 不允许改变 active/归档。聊天、流式回调、后台 run、任务历史、事件历史和 prompt 连续上下文按启动时 `sessionId` 归属并过滤,`conversation.read` 和 self `agent.run_status` 通过 runId 使用同一 Session;恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。Runtime 的 OS 锁、FIFO 队列和恢复屏障仍属于 Agent,同一 Agent 不因多个 Session 获得并行执行能力。
- 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。
- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。
- 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。
@@ -321,6 +321,7 @@ game-project/
- `.agent/run.latest.json``artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。
- 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md``memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents/<group>/<role>.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。
- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` / `chat_with_game_creator_role_agent_stream` 让对应 `agentLlm.<agentId>` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/<agentId>.jsonl`。这里的 `<agentId>` 以任务 `taskId` 为规范值,Tauri 只兼容旧 `group-role` 别名并映射到 taskId。每轮对话会同步写 `.agent/runtime/agents/<agentId>.json``.agent/runtime/events/<agentId>.jsonl`,字段包含 `agentId``taskId``sessionId``runId``source``status``phase``currentTask``currentGoal``currentAction``waitingOn``nextStep``loopIteration``maxLoopIterations``toolActionBudget``plan``planSteps``activePlanStepIndex``observations``recentToolCalls``toolPolicy``allowedTools``lastResponse``error`;流式事件会把最新 `runtimeState` 回传给界面。Runtime state 写入使用临时文件替换,event JSONL 读取会跳过坏行;`currentTask``currentGoal`、event detail、`lastResponse``agent.db` 摘要复用敏感上下文过滤,不保存明显 API Key / Bearer / Cookie 片段。单 agent 面板可把当前输入手动追加到对应 `memory/agents/<group>/<role>.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md``memory/agents/<group>/<role>.md`
- 2026-07-10 补充:每个 Agent 支持多个持久化 Session。旧 `agent-session-<agentId>` 永久映射原 `.agent/conversations/agents/<agentId>.jsonl`,不迁移、不复制、不删除;新 Session 写入 `.agent/conversations/agents/<agentId>/sessions/<sessionId>.jsonl`,目录索引和 active Session 写入 `.agent/runtime/sessions/<agentId>.json`。开发单 Agent 聊天页提供创建、切换、归档和已归档只读查看;归档只更新元数据,不截断对话。直接聊天、流式回调和后台任务在启动时捕获 `agentId + sessionId`Runtime task / event 继续使用每 Agent append-only JSONL,但列表、任务队列、连续上下文和最近对话按 Session 过滤;Runtime 内的 `conversation.read` 和 self `agent.run_status` 通过 runId 继续使用该 Session,恢复或处理待确认动作前校验 task、runtime state 和 pending action 的 Session 一致性。同一 Agent 仍共享一把 OS 锁并严格串行,不允许借 Session 绕过 pending、确认或 `needs-reconciliation` 屏障;不同 Agent 仍可并行。
- 生成 loop 中的角色 brief 也写同一套 Agent Runtime state / eventactive 角色用 `source=generate-draft` 和当前 `runId` 标记正在读取上下文、调用角色专属 LLM 或本地编排、生成 brief、完成或失败;carry-over 角色同样写入开始 / 完成事件,但不会伪装成重新调用 LLM。主窗口 Agent 状态列表、开发单 Agent 聊天页和项目内单 Agent 对话弹窗只读展示当前 Agent 的 runtime 状态、最近 task/run、阶段、当前目标、动作、等待对象、下一步、计划、观测和最近工具动作;这只是 V1 可观测性,不代表已经有独立后台常驻进程或可中断任意上游 LLM 请求。
- `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register``asset.update`v1 不引入 SQLite 依赖。
- `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。
@@ -329,7 +330,7 @@ game-project/
- `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。
- `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run`/agent-kill` 标记为 `killed``/agent-retry``/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl``.agent/output.jsonl``.agent/context.bundle.json`;状态 / 控制结果消息可一键填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,Agent 状态栏也可填入 output / activity / context bundle 的 `/read` 草稿。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。
- 主窗口 Agent 状态栏的“继续”保留确认卡,“继续说明”只把 `/agent-resume ` 放入聊天输入框,方便用户补充说明后再走同一确认流。
- v1 的 agent 状态列表和单 agent 对话都复用上述本地文件事实源:状态从 manifest / run trace 派生,单 agent 消息写对应 conversation JSONL;不引入 fork/archive 语义,不把 `pending` 包装成已经具备后台 claim / resume runner。
- v1 的 agent 状态列表和单 agent 对话都复用上述本地文件事实源:状态从 manifest / run trace 派生,单 agent 消息写对应 Session conversation JSONLSession 支持创建、切换和只改元数据的归档,不引入对话 fork不把 `pending` 包装成已经具备后台 claim / resume runner。
- `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval``new Function``localStorage``fetch``WebSocket``ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json``.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。
- `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/``memory/agents/``game/``assets/``exports/``.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`
- `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/``.agent/``exports/``..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。