支持Agent聊天流式状态

单 Agent 聊天新增 Tauri 流式事件并按 runId 过滤增量

开发窗口和项目 Agent 对话发送时显示保存、连接、接收和落盘状态

流式草稿先在界面增量显示,完成后再保存 assistant 消息

补充前端流式状态回归和 Rust SSE 回调测试
This commit is contained in:
AIGameCreator App
2026-07-09 16:09:59 +08:00
parent a2bbeb79ee
commit 3200e3b48c
6 changed files with 602 additions and 35 deletions
@@ -122,6 +122,49 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
agent_id: &str,
prompt: &str,
) -> Result<GameCreatorChatAgentReply, String> {
let (llm, config_path, request) =
build_game_creator_role_agent_chat_request(root, agent_id, prompt)?;
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
let response = request_game_creator_llm_text(&client, &llm, request)
.await
.map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?;
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
if reply_text.is_empty() {
return Err(format!("{config_path} 单 Agent 聊天未返回内容"));
}
Ok(GameCreatorChatAgentReply { reply_text })
}
pub(crate) async fn chat_with_game_creator_role_agent_stream_at<F>(
root: &Path,
agent_id: &str,
prompt: &str,
mut on_delta: F,
) -> Result<GameCreatorChatAgentReply, String>
where
F: FnMut(&platform_llm::LlmStreamDelta),
{
let (llm, config_path, request) =
build_game_creator_role_agent_chat_request(root, agent_id, prompt)?;
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
let response = client
.stream_run(request, |delta| on_delta(delta))
.await
.map_err(|error| format!("{config_path} 单 Agent 流式聊天调用 LLM 失败:{error}"))?;
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
if reply_text.is_empty() {
return Err(format!("{config_path} 单 Agent 流式聊天未返回内容"));
}
Ok(GameCreatorChatAgentReply { reply_text })
}
pub(crate) fn build_game_creator_role_agent_chat_request(
root: &Path,
agent_id: &str,
prompt: &str,
) -> Result<(GameCreatorLlmConfig, String, LlmRunRequest), String> {
let agent_id = agent_id.trim();
let prompt = prompt.trim();
if agent_id.is_empty() {
@@ -168,7 +211,6 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
let app_config = load_game_creator_app_config()?;
let llm = resolve_game_creator_llm_config_for_agent(&app_config, agent_id);
let config_path = format!("agentLlm.{agent_id}");
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
let user_prompt = if context.trim().is_empty() {
format!("用户这轮输入:\n{prompt}")
} else {
@@ -180,15 +222,7 @@ pub(crate) async fn chat_with_game_creator_role_agent_at(
])
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS);
let response = request_game_creator_llm_text(&client, &llm, request)
.await
.map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?;
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
if reply_text.is_empty() {
return Err(format!("{config_path} 单 Agent 聊天未返回内容"));
}
Ok(GameCreatorChatAgentReply { reply_text })
Ok((llm, config_path, request))
}
pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str {
@@ -248,6 +248,89 @@ pub(crate) async fn chat_with_game_creator_role_agent(
chat_with_game_creator_role_agent_at(root, agent_id.trim(), prompt.trim()).await
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_role_agent_stream(
app: tauri::AppHandle,
project_path: String,
agent_id: String,
prompt: String,
run_id: String,
) -> Result<GameCreatorChatAgentReply, String> {
let project_path = project_path.trim().to_string();
let agent_id = agent_id.trim().to_string();
let run_id = run_id.trim().to_string();
let root = Path::new(project_path.as_str());
enforce_project_permission_policy(root, "conversation.read")?;
let emit_app = app.clone();
let event_project_path = project_path.clone();
let event_agent_id = agent_id.clone();
let event_run_id = run_id.clone();
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "started".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
},
);
let result = chat_with_game_creator_role_agent_stream_at(
root,
agent_id.as_str(),
prompt.trim(),
|delta| {
let _ = emit_app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path: event_project_path.clone(),
agent_id: event_agent_id.clone(),
run_id: event_run_id.clone(),
status: "delta".to_string(),
delta_text: delta.delta_text.clone(),
accumulated_text: delta.accumulated_text.clone(),
finish_reason: delta.finish_reason.clone(),
},
);
},
)
.await;
match result {
Ok(reply) => {
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path,
agent_id,
run_id,
status: "completed".to_string(),
delta_text: String::new(),
accumulated_text: reply.reply_text.clone(),
finish_reason: None,
},
);
Ok(reply)
}
Err(error) => {
let _ = app.emit(
"game-creator-role-agent-chat-stream",
GameCreatorRoleAgentChatStreamEvent {
project_path,
agent_id,
run_id,
status: "failed".to_string(),
delta_text: String::new(),
accumulated_text: String::new(),
finish_reason: None,
},
);
Err(error)
}
}
}
#[tauri::command]
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
@@ -115,6 +115,18 @@ struct GameCreatorChatAgentReply {
reply_text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorRoleAgentChatStreamEvent {
project_path: String,
agent_id: String,
run_id: String,
status: String,
delta_text: String,
accumulated_text: String,
finish_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
@@ -928,6 +940,7 @@ fn main() {
generate_local_game_draft,
chat_with_game_creator_agent,
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
check_game_creator_llm_config,
read_game_creator_app_config,
write_game_creator_app_config,
@@ -589,6 +589,31 @@ fn spawn_mock_llm_server_responses_with_capture(
base_url
}
fn spawn_mock_llm_stream_server_with_capture(
response_body: String,
request_sender: Option<mpsc::Sender<String>>,
) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock stream llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock stream llm accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
if let Some(sender) = request_sender.as_ref() {
let _ = sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nx-request-id: req_role_agent_stream\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
stream
.write_all(response.as_bytes())
.expect("mock stream llm response");
});
base_url
}
fn spawn_barrier_mock_llm_server(
response_content: String,
barrier: Arc<(StdMutex<usize>, Condvar)>,
@@ -1051,6 +1076,80 @@ async fn chat_with_game_creator_role_agent_uses_agent_context_and_route() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn chat_with_game_creator_role_agent_stream_emits_deltas() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
write_local_agent_memory_at(&root, "art-director", "私有记忆:先做角色规范图")
.expect("write art memory");
let (sender, receiver) = mpsc::channel();
let base_url = spawn_mock_llm_stream_server_with_capture(
concat!(
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"\"}\n\n",
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"生成规范图\"}\n\n",
"data: {\"type\":\"response.completed\"}\n\n"
)
.to_string(),
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"llm": {{
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"apiKind": "openai_responses"
}},
"agentLlm": {{
"art-director": {{
"apiKey": "art-key",
"baseUrl": {base_url:?},
"model": "art-chat-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
let mut deltas = Vec::new();
let reply = chat_with_game_creator_role_agent_stream_at(
&root,
"art-director",
"我要一个主角设定",
|delta| {
deltas.push((
delta.delta_text.clone(),
delta.accumulated_text.clone(),
delta.finish_reason.clone(),
));
},
)
.await
.expect("role stream chat reply");
assert_eq!(reply.reply_text, "先生成规范图");
assert_eq!(
deltas,
vec![
("".to_string(), "".to_string(), None),
("生成规范图".to_string(), "先生成规范图".to_string(), None),
]
);
let request = receiver
.recv_timeout(Duration::from_secs(1))
.expect("captured role stream chat llm request");
assert!(request.contains("POST /responses HTTP/1.1"));
assert!(request.contains("\"stream\":true"));
assert!(request.contains("art-chat-model"));
assert!(request.contains("私有记忆:先做角色规范图"));
assert!(request.contains("我要一个主角设定"));
assert!(!request.contains("global-model"));
assert!(!request.contains("global-key"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn agent_loop_uses_per_agent_llm_overrides() {
let root = unique_project_path();
+187 -23
View File
@@ -212,6 +212,16 @@ interface GameCreatorChatAgentReply {
replyText: string;
}
interface GameCreatorRoleAgentChatStreamEvent {
projectPath: string;
agentId: string;
runId: string;
status: 'started' | 'delta' | 'completed' | 'failed';
deltaText: string;
accumulatedText: string;
finishReason: string | null;
}
interface GameCreatorLlmConfigStatus {
configured: boolean;
apiKeyPresent: boolean;
@@ -402,6 +412,22 @@ interface LocalConversationResult {
messages: LocalConversationMessageRecord[];
}
function createLocalConversationDraftMessage(
content: string,
): LocalConversationMessageRecord {
return {
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
content,
agentId: null,
updatedAt: Date.now(),
};
}
function createAgentChatRunId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
interface ProjectPermissionPolicy {
deniedCommands: string[];
confirmCommands: string[];
@@ -2944,8 +2970,10 @@ export function WorkspaceLauncher({
agentChatLoadVersionRef.current = saveVersion;
setAgentChatBusy(true);
setAgentChatInput('');
setAgentChatStatus('正在保存');
setAgentChatStatus('正在保存用户消息');
let savedUserResult: LocalConversationResult | null = null;
let stopStreamListen: (() => void) | null = null;
let streamListenDisposed = false;
try {
savedUserResult = await invoke<LocalConversationResult>(
'append_local_conversation_message',
@@ -2962,19 +2990,84 @@ export function WorkspaceLauncher({
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatMessages(savedUserResult.messages);
setAgentChatStatus('Agent 正在思考');
const reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: projectPathForChat,
agentId: agent.id,
prompt: content,
},
);
const savedUserMessages = savedUserResult.messages;
setAgentChatMessages(savedUserMessages);
setAgentChatStatus('正在连接 Agent LLM');
const streamRunId = createAgentChatRunId('launcher-agent-chat');
const listen = window.__TAURI__?.event?.listen;
if (listen) {
stopStreamListen =
await listen<GameCreatorRoleAgentChatStreamEvent>(
'game-creator-role-agent-chat-stream',
(event) => {
const payload = event.payload;
if (
payload.projectPath !== projectPathForChat ||
payload.agentId !== agent.id ||
payload.runId !== streamRunId ||
agentChatLoadVersionRef.current !== saveVersion
) {
return;
}
if (payload.status === 'started') {
setAgentChatStatus('Agent 已连接,正在等待回复');
return;
}
if (payload.status === 'delta') {
const draftText = payload.accumulatedText || payload.deltaText;
if (draftText) {
setAgentChatMessages([
...savedUserMessages,
createLocalConversationDraftMessage(draftText),
]);
}
setAgentChatStatus(
payload.finishReason
? `Agent 回复结束:${payload.finishReason}`
: '正在接收 Agent 回复',
);
return;
}
if (payload.status === 'completed') {
setAgentChatStatus('Agent 回复完成,正在保存');
return;
}
if (payload.status === 'failed') {
setAgentChatStatus('Agent 流式回复失败,正在记录错误');
}
},
);
if (streamListenDisposed) {
stopStreamListen();
stopStreamListen = null;
}
}
const reply = listen
? await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent_stream',
{
projectPath: projectPathForChat,
agentId: agent.id,
prompt: content,
runId: streamRunId,
},
)
: await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: projectPathForChat,
agentId: agent.id,
prompt: content,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
setAgentChatStatus('正在保存 Agent 回复');
setAgentChatMessages([
...savedUserMessages,
createLocalConversationDraftMessage(reply.replyText),
]);
const assistantResult = await invoke<LocalConversationResult>(
'append_local_conversation_message',
{
@@ -3037,6 +3130,8 @@ export function WorkspaceLauncher({
setAgentChatStatus(error instanceof Error ? error.message : String(error));
}
} finally {
streamListenDisposed = true;
stopStreamListen?.();
if (agentChatLoadVersionRef.current === saveVersion) {
setAgentChatBusy(false);
}
@@ -12283,8 +12378,10 @@ export function App() {
agentConversationSavingRef.current = true;
setAgentConversationSaving(true);
setAgentConversationInput('');
setAgentConversationStatus('正在保存');
setAgentConversationStatus('正在保存用户消息');
let savedUserResult: LocalConversationResult | null = null;
let stopStreamListen: (() => void) | null = null;
let streamListenDisposed = false;
try {
savedUserResult = await invoke<LocalConversationResult>(
'append_local_conversation_message',
@@ -12301,19 +12398,84 @@ export function App() {
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationMessages(savedUserResult.messages);
setAgentConversationStatus('Agent 正在思考');
const reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: nextProjectPath,
agentId: agent.id,
prompt: content,
},
);
const savedUserMessages = savedUserResult.messages;
setAgentConversationMessages(savedUserMessages);
setAgentConversationStatus('正在连接 Agent LLM');
const streamRunId = createAgentChatRunId('agent-conversation');
const listen = window.__TAURI__?.event?.listen;
if (listen) {
stopStreamListen =
await listen<GameCreatorRoleAgentChatStreamEvent>(
'game-creator-role-agent-chat-stream',
(event) => {
const payload = event.payload;
if (
payload.projectPath !== nextProjectPath ||
payload.agentId !== agent.id ||
payload.runId !== streamRunId ||
agentConversationLoadVersionRef.current !== saveVersion
) {
return;
}
if (payload.status === 'started') {
setAgentConversationStatus('Agent 已连接,正在等待回复');
return;
}
if (payload.status === 'delta') {
const draftText = payload.accumulatedText || payload.deltaText;
if (draftText) {
setAgentConversationMessages([
...savedUserMessages,
createLocalConversationDraftMessage(draftText),
]);
}
setAgentConversationStatus(
payload.finishReason
? `Agent 回复结束:${payload.finishReason}`
: '正在接收 Agent 回复',
);
return;
}
if (payload.status === 'completed') {
setAgentConversationStatus('Agent 回复完成,正在保存');
return;
}
if (payload.status === 'failed') {
setAgentConversationStatus('Agent 流式回复失败,正在记录错误');
}
},
);
if (streamListenDisposed) {
stopStreamListen();
stopStreamListen = null;
}
}
const reply = listen
? await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent_stream',
{
projectPath: nextProjectPath,
agentId: agent.id,
prompt: content,
runId: streamRunId,
},
)
: await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: nextProjectPath,
agentId: agent.id,
prompt: content,
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationStatus('正在保存 Agent 回复');
setAgentConversationMessages([
...savedUserMessages,
createLocalConversationDraftMessage(reply.replyText),
]);
const assistantResult = await invoke<LocalConversationResult>(
'append_local_conversation_message',
{
@@ -12379,6 +12541,8 @@ export function App() {
);
}
} finally {
streamListenDisposed = true;
stopStreamListen?.();
agentConversationSavingRef.current = false;
setAgentConversationSaving(false);
}
@@ -19450,6 +19614,7 @@ export function App() {
{selectedAgentLlmStatus ? (
<p className="status-line">{selectedAgentLlmStatus}</p>
) : null}
<p className="status-line">{agentConversationStatus}</p>
<p className="status-line">{selectedAgent.summary}</p>
</div>
<div className="panel-actions">
@@ -19617,7 +19782,6 @@ export function App() {
</button>
</form>
<p className="status-line">{agentConversationStatus}</p>
</section>
</div>
) : null}
@@ -1060,6 +1060,180 @@ describe('AI 游戏创作 App 界面边界', () => {
);
});
it('streams developer agent chat status and draft reply before persisting', async () => {
const persistedMessages: Array<{
role: 'user' | 'assistant';
content: string;
agentId: string | null;
}> = [];
let releaseStream: (() => void) | null = null;
let streamHandler: ((event: { payload: Record<string, unknown> }) => void) | null =
null;
const listen = vi.fn(
async (
eventName: string,
handler: (event: { payload: Record<string, unknown> }) => void,
) => {
expect(eventName).toBe('game-creator-role-agent-chat-stream');
streamHandler = handler;
return () => {
if (streamHandler === handler) {
streamHandler = null;
}
};
},
);
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'check_game_creator_llm_config') {
return {
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-5.5',
apiKind: 'openai_chat',
stream: true,
error: null,
agents: [
{
agentId: 'design-director',
label: '拆解创作方向',
configured: true,
apiKeyPresent: true,
baseUrl: 'https://llm.example.test/v1',
model: 'gpt-5.5',
apiKind: 'openai_chat',
stream: true,
error: null,
},
],
};
}
if (command === 'read_local_conversation') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: persistedMessages.map((message, index) => ({
schemaVersion: '1',
...message,
updatedAt: 1000 + index,
})),
};
}
if (command === 'chat_with_game_creator_role_agent_stream') {
const payloadBase = {
projectPath: args?.projectPath,
agentId: args?.agentId,
runId: args?.runId,
finishReason: null,
};
streamHandler?.({
payload: {
...payloadBase,
status: 'started',
deltaText: '',
accumulatedText: '',
},
});
streamHandler?.({
payload: {
...payloadBase,
status: 'delta',
deltaText: '专业',
accumulatedText: '专业',
},
});
streamHandler?.({
payload: {
...payloadBase,
status: 'delta',
deltaText: ' Agent',
accumulatedText: '专业 Agent',
},
});
await new Promise<void>((resolve) => {
releaseStream = resolve;
});
streamHandler?.({
payload: {
...payloadBase,
status: 'completed',
deltaText: '',
accumulatedText: '专业 Agent 已流式完成。',
},
});
return {
replyText: '专业 Agent 已流式完成。',
};
}
if (command === 'append_local_conversation_message') {
const message = args?.message as {
role: 'user' | 'assistant';
content: string;
agentId: string | null;
};
persistedMessages.push(message);
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: persistedMessages.map((record, index) => ({
schemaVersion: '1',
...record,
updatedAt: 2000 + index,
})),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke }, event: { listen } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: '读取历史' }));
expect(await screen.findByText(/已读取 0 条/)).not.toBeNull();
fireEvent.change(screen.getByLabelText('Agent 聊天内容'), {
target: { value: '请流式回答' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
expect(await screen.findByText('专业 Agent')).not.toBeNull();
expect(screen.getByText('正在接收 Agent 回复')).not.toBeNull();
expect(invoke).toHaveBeenCalledWith(
'chat_with_game_creator_role_agent_stream',
expect.objectContaining({
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
prompt: '请流式回答',
}),
);
expect(
invoke,
).not.toHaveBeenCalledWith('chat_with_game_creator_role_agent', expect.anything());
await act(async () => {
releaseStream?.();
});
expect(await screen.findByText('专业 Agent 已流式完成。')).not.toBeNull();
expect(await screen.findByText(/已保存 2 条/)).not.toBeNull();
expect(persistedMessages).toEqual([
{
role: 'user',
content: '请流式回答',
agentId: null,
},
{
role: 'assistant',
content: '专业 Agent 已流式完成。',
agentId: null,
},
]);
});
it('persists developer agent chat reply failures after saving the user message', async () => {
const persistedMessages: Array<{
role: 'user' | 'assistant';
@@ -12026,7 +12200,7 @@ describe('AI 游戏创作 App 界面边界', () => {
const form = input.closest('form') as HTMLFormElement;
fireEvent.change(input, { target: { value: '只保存一次' } });
fireEvent.submit(form);
await screen.findByText('正在保存');
await screen.findByText('正在保存用户消息');
fireEvent.submit(form);
@@ -12764,7 +12938,7 @@ describe('AI 游戏创作 App 界面边界', () => {
const input = await screen.findByLabelText('Agent 对话内容');
fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } });
fireEvent.submit(input.closest('form') as HTMLFormElement);
await screen.findByText('正在保存');
await screen.findByText('正在保存用户消息');
fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ }));
expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull();