补齐Agent会话分叉能力

新增 Session 分叉命令与开发窗口交互
统一 Session 变更和 Runtime 入队线性化锁
补齐持久化、失败回滚、并发边界与前端隔离测试
同步 Runtime 技术方案和项目决策记录
This commit is contained in:
AIGameCreator App
2026-07-14 18:26:29 +08:00
parent fdbaf1be80
commit 55463e7b2b
10 changed files with 1235 additions and 142 deletions
@@ -1277,6 +1277,35 @@ pub(crate) fn start_game_creator_agent_background_task_for_session_at(
.map(|(result, _run_id)| result)
}
#[cfg(test)]
pub(crate) fn start_game_creator_agent_background_task_with_session_lane_hook_at<F>(
root: &Path,
agent_id: &str,
session_id: Option<&str>,
task: &str,
run_id: &str,
after_lane_acquired: F,
) -> Result<AgentRuntimeResult, String>
where
F: FnOnce(),
{
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
validate_project_root(root)?;
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || {
after_lane_acquired();
start_game_creator_agent_background_task_with_link_in_session_lane_at(
root,
&agent_id,
session_id,
task,
run_id,
"agent-background-task",
None,
)
.map(|(result, _run_id)| result)
})
}
fn start_game_creator_agent_background_task_with_run_id_for_session_at(
root: &Path,
agent_id: &str,
@@ -1318,6 +1347,22 @@ fn start_game_creator_agent_background_task_with_link_at(
) -> Result<(AgentRuntimeResult, String), String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
validate_project_root(root)?;
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 入队", || {
start_game_creator_agent_background_task_with_link_in_session_lane_at(
root, &agent_id, session_id, task, run_id, source, task_link,
)
})
}
fn start_game_creator_agent_background_task_with_link_in_session_lane_at(
root: &Path,
agent_id: &str,
session_id: Option<&str>,
task: &str,
run_id: &str,
source: &str,
task_link: Option<&AgentRuntimeTaskLink>,
) -> Result<(AgentRuntimeResult, String), String> {
let isolated_instance = agent_id
.starts_with("child-")
.then(|| resolve_isolated_agent_instance_at(root, &agent_id))
@@ -1454,7 +1499,7 @@ fn start_game_creator_agent_background_task_with_link_at(
} else {
"后台任务从队首开始执行"
};
let state = start_game_creator_agent_runtime_task_for_session_at(
let state = start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
root,
&agent_id,
Some(&next_task.session_id),
@@ -1469,7 +1514,7 @@ fn start_game_creator_agent_background_task_with_link_at(
let result =
read_game_creator_agent_runtime_for_session_at(root, &agent_id, Some(&session_id))?;
let root = root.to_path_buf();
let background_agent_id = agent_id.clone();
let background_agent_id = agent_id.to_string();
let background_task = next_task.task;
tauri::async_runtime::spawn(async move {
let _runtime_lock = runtime_lock;
@@ -18444,6 +18489,30 @@ pub(crate) fn start_game_creator_agent_runtime_task_for_session_at(
) -> Result<AgentRuntimeState, String> {
let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?;
validate_project_root(root)?;
with_agent_conversation_session_lane_at(root, &agent_id, "Agent Session Runtime 启动", || {
start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
root,
&agent_id,
session_id,
task,
run_id,
source,
current_action,
plan,
)
})
}
fn start_game_creator_agent_runtime_task_for_session_in_session_lane_at(
root: &Path,
agent_id: &str,
session_id: Option<&str>,
task: &str,
run_id: &str,
source: &str,
current_action: &str,
plan: Vec<String>,
) -> Result<AgentRuntimeState, String> {
let isolated_instance = agent_id
.starts_with("child-")
.then(|| resolve_isolated_agent_instance_at(root, &agent_id))
@@ -944,6 +944,25 @@ pub(crate) fn create_game_creator_agent_session(
create_game_creator_agent_session_at(root, agent_id.trim(), title.trim())
}
#[tauri::command]
pub(crate) fn fork_game_creator_agent_session(
project_path: String,
agent_id: String,
source_session_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")?;
fork_game_creator_agent_session_at(
root,
agent_id.trim(),
source_session_id.trim(),
title.trim(),
)
}
#[tauri::command]
pub(crate) fn set_active_game_creator_agent_session(
project_path: String,
@@ -757,6 +757,10 @@ struct AgentConversationSessionRecord {
archived_at: Option<u64>,
message_count: u64,
legacy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_message_count: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
@@ -1449,6 +1453,7 @@ fn main() {
delete_local_game_memory,
list_game_creator_agent_sessions,
create_game_creator_agent_session,
fork_game_creator_agent_session,
set_active_game_creator_agent_session,
archive_game_creator_agent_session,
read_local_conversation,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+102 -7
View File
@@ -22,6 +22,7 @@ import {
FileText,
FolderKanban,
Gamepad2,
GitFork,
Home,
Image,
LogOut,
@@ -597,6 +598,8 @@ interface AgentConversationSessionRecord {
archivedAt: number | null;
messageCount: number;
legacy: boolean;
forkedFromSessionId?: string | null;
forkedMessageCount?: number | null;
}
interface AgentConversationSessionListResult {
@@ -630,6 +633,30 @@ function createAgentChatRunId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function formatAgentConversationSessionMeta(
session: AgentConversationSessionRecord,
sessions: AgentConversationSessionRecord[],
activeSessionId: string | null,
) {
const details = [String(session.messageCount)];
if (session.sessionId === activeSessionId) {
details.push('活动');
}
if (session.forkedFromSessionId) {
const source = sessions.find(
(candidate) => candidate.sessionId === session.forkedFromSessionId,
);
const sourceLabel = source?.title ?? session.forkedFromSessionId;
details.push(
session.forkedMessageCount === null ||
session.forkedMessageCount === undefined
? `分支自 ${sourceLabel}`
: `分支自 ${sourceLabel}(${session.forkedMessageCount} 条)`,
);
}
return details.join(' · ');
}
function agentRuntimePlanStepsFromPlan(plan: string[]): AgentRuntimePlanStep[] {
return plan
.filter((item) => item.trim().length > 0)
@@ -3333,7 +3360,9 @@ export function WorkspaceLauncher({
function handleAgentChatMessagesScroll(event: UIEvent<HTMLDivElement>) {
const messageList = event.currentTarget;
const distanceFromBottom =
messageList.scrollHeight - messageList.scrollTop - messageList.clientHeight;
messageList.scrollHeight -
messageList.scrollTop -
messageList.clientHeight;
agentChatShouldFollowLatestRef.current =
distanceFromBottom <= AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD;
}
@@ -4653,6 +4682,51 @@ export function WorkspaceLauncher({
}
}
async function handleAgentChatForkSession() {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
const sourceSession = selectedLauncherAgentChatSession();
if (
!projectPathForChat ||
!agent ||
!sourceSession ||
agentChatBusy ||
agentChatBackgroundBusy
) {
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setAgentChatStatus('需要在 Tauri App 内运行');
return;
}
setAgentChatBusy(true);
setAgentChatStatus('正在分叉会话');
try {
const result = await invoke<AgentConversationSessionListResult>(
'fork_game_creator_agent_session',
{
projectPath: projectPathForChat,
agentId: agent.id,
sourceSessionId: sourceSession.sessionId,
title: '',
},
);
await loadAgentChatConversation(
agent.id,
projectPathForChat,
result.activeSessionId,
result,
);
setAgentChatStatus(`已从“${sourceSession.title}”分叉新会话`);
} catch (error) {
setAgentChatStatus(
error instanceof Error ? error.message : String(error),
);
setAgentChatBusy(false);
}
}
async function handleAgentChatArchiveSession() {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
@@ -6234,6 +6308,21 @@ export function WorkspaceLauncher({
>
<Plus size={16} aria-hidden="true" />
</button>
<button
type="button"
aria-label="分叉当前 Agent 会话"
title="从当前历史分叉会话"
disabled={
agentChatBusy ||
agentChatBackgroundBusy ||
agentChatLegacySessionMode ||
currentAgentChatSessionMutationBlocked ||
!currentAgentChatSession
}
onClick={() => void handleAgentChatForkSession()}
>
<GitFork size={16} aria-hidden="true" />
</button>
<button
type="button"
aria-label="归档当前 Agent 会话"
@@ -6272,11 +6361,13 @@ export function WorkspaceLauncher({
}
>
<span>{session.title}</span>
<small>{`${session.messageCount}${
session.sessionId === agentChatActiveSessionId
? ' · 活动'
: ''
}`}</small>
<small>
{formatAgentConversationSessionMeta(
session,
agentChatSessions,
agentChatActiveSessionId,
)}
</small>
</button>
))}
{currentAgentChatArchivedSessions.map((session) => (
@@ -6294,7 +6385,11 @@ export function WorkspaceLauncher({
}
>
<span>{session.title}</span>
<small>已归档</small>
<small>{`已归档 · ${formatAgentConversationSessionMeta(
session,
agentChatSessions,
agentChatActiveSessionId,
)}`}</small>
</button>
))}
</div>
@@ -1472,7 +1472,7 @@ describe('AI 游戏创作 App 界面边界', () => {
).toHaveLength(1);
});
it('creates, switches, archives, and isolates developer Agent sessions', async () => {
it('creates, forks, switches, archives, and isolates developer Agent sessions', async () => {
type SessionRecord = {
sessionId: string;
title: string;
@@ -1481,11 +1481,17 @@ describe('AI 游戏创作 App 界面边界', () => {
archivedAt: number | null;
messageCount: number;
legacy: boolean;
forkedFromSessionId?: string | null;
forkedMessageCount?: number | null;
};
const legacySessionId = 'agent-session-design-director';
const roleSessionId = 'agent-session-design-director-role-spec';
const createdSessionId = 'agent-session-design-director-created';
const forkedSessionId = 'agent-session-design-director-forked';
const archivedForkedSessionId =
'agent-session-design-director-archived-forked';
let activeSessionId = roleSessionId;
let forkInvocationCount = 0;
const sessions: SessionRecord[] = [
{
sessionId: legacySessionId,
@@ -1556,6 +1562,33 @@ describe('AI 游戏创作 App 界面边界', () => {
messages.set(createdSessionId, []);
return sessionResult();
}
if (command === 'fork_game_creator_agent_session') {
const sourceSessionId = String(args?.sourceSessionId);
const source = sessions.find(
(candidate) => candidate.sessionId === sourceSessionId,
);
const nextForkedSessionId =
forkInvocationCount === 0
? forkedSessionId
: archivedForkedSessionId;
activeSessionId = nextForkedSessionId;
forkInvocationCount += 1;
sessions.push({
sessionId: nextForkedSessionId,
title: `分支:${source?.title ?? '会话'}`,
createdAt: 3,
updatedAt: 3,
archivedAt: null,
messageCount: messages.get(sourceSessionId)?.length ?? 0,
legacy: false,
forkedFromSessionId: sourceSessionId,
forkedMessageCount: messages.get(sourceSessionId)?.length ?? 0,
});
messages.set(nextForkedSessionId, [
...(messages.get(sourceSessionId) ?? []),
]);
return sessionResult();
}
if (command === 'archive_game_creator_agent_session') {
const session = sessions.find(
(candidate) => candidate.sessionId === args?.sessionId,
@@ -1603,6 +1636,35 @@ describe('AI 游戏创作 App 界面边界', () => {
},
);
fireEvent.click(
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
);
expect(await screen.findByText('默认会话历史')).not.toBeNull();
expect(await screen.findByText(/分支自 默认会话(1 条)/)).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('fork_game_creator_agent_session', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sourceSessionId: legacySessionId,
title: '',
});
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '只属于分叉会话的问题' },
});
selectDeveloperAgentChatMode('chat');
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: forkedSessionId,
prompt: '只属于分叉会话的问题',
});
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();
fireEvent.click(screen.getByRole('button', { name: '新建 Agent 会话' }));
expect(await screen.findByText('暂无对话')).not.toBeNull();
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
@@ -1633,6 +1695,20 @@ describe('AI 游戏创作 App 界面边界', () => {
'disabled',
true,
);
fireEvent.click(
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
);
expect(await screen.findByText(/分支自 新会话/)).not.toBeNull();
expect(screen.getByLabelText('Agent 聊天内容')).toHaveProperty(
'disabled',
false,
);
expect(invoke).toHaveBeenCalledWith('fork_game_creator_agent_session', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sourceSessionId: createdSessionId,
title: '',
});
fireEvent.click(screen.getByRole('button', { name: '刷新状态' }));
expect(await screen.findByText('只属于新会话的问题')).not.toBeNull();
await waitFor(() => {
@@ -1641,7 +1717,7 @@ describe('AI 游戏创作 App 界面边界', () => {
{
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
sessionId: createdSessionId,
sessionId: archivedForkedSessionId,
},
);
});
@@ -2196,9 +2272,7 @@ describe('AI 游戏创作 App 界面边界', () => {
});
});
expect(
await within(messageList).findByText(
'已连接 Agent LLM,上游正在重试',
),
await within(messageList).findByText('已连接 Agent LLM,上游正在重试'),
).not.toBeNull();
expect(messageList.scrollTop).toBe(120);
@@ -2408,6 +2482,9 @@ describe('AI 游戏创作 App 界面边界', () => {
'value',
'steer',
);
expect(
screen.getByRole('button', { name: '分叉当前 Agent 会话' }),
).toHaveProperty('disabled', true);
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '先补充角色背面规范' },
@@ -3848,13 +3925,9 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
expect(await screen.findByText('Runtime 状态读取失败')).not.toBeNull();
expect(screen.getByText('runtime json broken')).not.toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '折叠 Runtime 详情' }),
);
fireEvent.click(screen.getByRole('button', { name: '折叠 Runtime 详情' }));
expect(screen.queryByText('runtime json broken')).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '展开 Runtime 详情' }),
);
fireEvent.click(screen.getByRole('button', { name: '展开 Runtime 详情' }));
expect(screen.getByText('runtime json broken')).not.toBeNull();
});