完善 Agent 多会话运行时
为开发者 Agent 聊天新增会话创建、切换、归档和历史只读界面 按会话隔离对话、运行状态、任务事件、提示上下文和后台回复 保持单 Agent FIFO 与 OS 锁并让重试恢复沿用原会话 补齐 Tauri 命令、持久化目录、旧会话兼容与回归测试 同步实施方案和项目决策记录
This commit is contained in:
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
@@ -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 / event:active 角色用 `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 JSONL;Session 支持创建、切换和只改元数据的归档,不引入对话 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 的手动预览启动不阻断。
|
||||
|
||||
Reference in New Issue
Block a user