impl client message id and client retry

This commit is contained in:
2026-07-16 16:51:13 +08:00
parent 2a84e6d89b
commit 9dc65e1d47
11 changed files with 393 additions and 78 deletions
@@ -206,7 +206,7 @@
## 2026-07-03 画布Agent会话元数据入 SpacetimeDB、消息正文存 OSS
- 背景:图片画布工程需要对话式编辑历史,但消息正文随对话和工具结果增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。
- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。
- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。用户消息以独立 `clientMessageId` 在会话锁内幂等,数字 `message.id` 只作后端定位;旧 OSS 消息允许缺失幂等键,早期用户消息字符串 `id` 在读取时迁入 `clientMessageId`画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。
- 影响范围:图片画布右侧 Agent 面板、`shared-contracts` / `packages/shared``editorAgent` 契约、`spacetime-module` / `spacetime-client``platform-oss` 内部读签名边界、画布生成落板规则。
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema``cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml``cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 JSON client 定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md``docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -78,7 +78,7 @@ npm run check:server-rs-ddd
### 图片画布 Agent 对话
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId``x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
- `spacetime-module``editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return``list_editor_agent_conversations_and_return``get_editor_agent_conversation_and_return``touch_editor_agent_conversation_and_return``delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。规划或工具失败必须形成可回读的失败消息,不能只返回瞬时 `errorMessage`
@@ -32,7 +32,7 @@
## 当前分支落地状态
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、`clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。工具失败时仍应把失败 assistant / system 消息、`status=failed`、模型和错误信息写入 OSS 会话历史;不能只在本次 JSON 响应中返回瞬时 `errorMessage`
- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。
@@ -44,6 +44,7 @@
- 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。
- 会话标题:新会话默认「新对话」,首条含文本的用户消息发出后自动截取前 N 字作为标题;列表摘要、详情和消息回包均携带同一必填标题,前端只展示该标题,不以会话 ID 或本地推导兜底。标题写入失败会使该消息请求失败,不能静默继续。
- 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。
- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId``editorAgentClient` 对网络错误和通用瞬时状态码显式启用 1 次 POST transport 重试,重试复用同一个已序列化 body、`clientMessageId``x-request-id`。该字段独立于数字 `message.id` 并随用户消息写入 OSS。旧消息缺失时按 `None` 兼容;早期 SSE 文档若把客户端键存成用户消息字符串 `id`,读取时将其迁入 `clientMessageId`,同时重建数字定位符。后端在会话锁内检查重复键:内容一致时返回已持久化的同一回合结果,尚无结果时复用原用户消息继续规划;文本或附件身份不同则返回 `409`,不得再次追加用户消息或调用 LLM。
## 生成结果落画板(对现有占位规则的例外)
@@ -99,6 +99,8 @@ export interface EditorAgentMessage {
// Frontend must not use this to organize messages.
// It is an opaque backend locator for pending tool-call operations.
id: number;
// Present only on user messages created from a client send request.
clientMessageId?: string | null;
role: EditorAgentMessageRole;
text: string;
attachments: EditorAgentAttachmentRef[];
@@ -141,6 +143,7 @@ export interface EditorAgentConversationResponse {
}
export interface EditorAgentMessageRequest {
clientMessageId: string;
text: string;
attachments?: EditorAgentAttachmentRef[];
}
@@ -74,7 +74,9 @@ use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignToo
use platform_editor_agent::agent::tools::generate_video::{
GenerateVideoTool, GenerateVideoToolArgs,
};
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string};
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
pub async fn editor_agent_message(
State(state): State<AppState>,
@@ -85,7 +87,8 @@ pub async fn editor_agent_message(
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
validate_editor_agent_message_request(&payload)?;
let client_message_id = validate_editor_agent_message_request(&payload)?;
let normalized_text = payload.text.trim().to_string();
// Load conversation & attachments
let conversation = state
.spacetime_client()
@@ -107,66 +110,102 @@ pub async fn editor_agent_message(
let mut document: EditorAgentConversationMessagesDocument =
read_messages_document(&state, &conversation).await?;
// Determine initialization before attachment bookkeeping adds a system message.
let was_empty = document.messages.is_empty();
let now = now_rfc3339();
if !attachments.is_empty() {
let mut attachment_info = String::new();
attachment_info.push_str("user has just uploaded attachments of the order: ");
for a in &attachments {
attachment_info.push_str(&format!("{} ,", a.clone().into_image_id()))
}
document.messages.push(EditorAgentMessage {
id: 0,
role: EditorAgentMessageRole::System,
text: attachment_info,
attachments: Vec::new(),
tool_call: None,
created_at: now.clone(),
});
}
// Build conversation history as LlmMessage vec
let previous_messages: Vec<LlmMessage> = document
.messages
let existing_user_index = find_idempotent_editor_agent_user_message(
&document,
client_message_id.as_str(),
normalized_text.as_str(),
attachments.as_slice(),
)?;
let (user_message, history_end, conversation_summary) =
if let Some(user_index) = existing_user_index {
let delta_messages = document.messages[user_index + 1..]
.iter()
.take_while(|message| message.role != EditorAgentMessageRole::User)
.cloned()
.collect::<Vec<_>>();
if !delta_messages.is_empty() {
return Ok(Json(EditorAgentMessageResponse {
conversation: conversation_summary_from_record(conversation),
delta_messages,
error_message: None,
}));
}
(
document.messages[user_index].clone(),
user_index,
conversation_summary_from_record(conversation.clone()),
)
} else {
// Determine initialization before attachment bookkeeping adds a system message.
let was_empty = document.messages.is_empty();
let now = now_rfc3339();
if !attachments.is_empty() {
let mut attachment_info = String::new();
attachment_info.push_str("user has just uploaded attachments of the order: ");
for attachment in &attachments {
attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id()));
}
document.messages.push(EditorAgentMessage {
id: document.messages.len(),
client_message_id: None,
role: EditorAgentMessageRole::System,
text: attachment_info,
attachments: Vec::new(),
tool_call: None,
created_at: now.clone(),
});
}
let history_end = document.messages.len();
let user_message = EditorAgentMessage {
id: document.messages.len(),
client_message_id: Some(client_message_id),
role: EditorAgentMessageRole::User,
text: normalized_text,
attachments,
tool_call: None,
created_at: now,
};
document.messages.push(user_message.clone());
write_messages_document(&state, &conversation, &document).await?;
// Persist and return the authoritative summary for every turn. Initialization sets the
// title from the first user prompt; a metadata write failure must fail the request.
let updated_conversation = state
.spacetime_client()
.touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
conversation_id: conversation.conversation_id.clone(),
owner_user_id: conversation.owner_user_id.clone(),
title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
(
user_message,
history_end,
conversation_summary_from_record(updated_conversation),
)
};
// The current user message is passed separately to prompt(), so memory stops before it.
let previous_messages: Vec<LlmMessage> = document.messages[..history_end]
.iter()
.map(|msg| match msg.role {
EditorAgentMessageRole::User => LlmMessage::user(&msg.text),
EditorAgentMessageRole::Assistant => LlmMessage::assistant(&msg.text),
EditorAgentMessageRole::System => LlmMessage::system(&msg.text),
.map(|message| match message.role {
EditorAgentMessageRole::User => LlmMessage::user(&message.text),
EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text),
EditorAgentMessageRole::System => LlmMessage::system(&message.text),
})
// limit to last 18 messages (larger considering attachment, tool calls are injected as system messages)
// Tool calls and attachment bookkeeping are separate system messages.
.rev()
.take(18)
.rev()
.collect();
// Save user message to document.
let user_message = EditorAgentMessage {
id: document.messages.len(),
role: EditorAgentMessageRole::User,
text: payload.text.trim().to_string(),
attachments,
tool_call: None,
created_at: now,
};
document.messages.push(user_message.clone());
write_messages_document(&state, &conversation, &document).await?;
// Persist and return the authoritative summary for every turn. Initialization sets the
// title from the first user prompt; a metadata write failure must fail the request.
let updated_conversation = state
.spacetime_client()
.touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
conversation_id: conversation.conversation_id.clone(),
owner_user_id: conversation.owner_user_id.clone(),
title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())),
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?;
let conversation_summary = conversation_summary_from_record(updated_conversation);
// Build tool context from document
let tool_context = context::build_tool_context(&document);
@@ -212,7 +251,9 @@ pub async fn editor_agent_message(
.memory(memory)
.build();
let agent_result = agent.prompt(LlmMessage::user(user_message.text)).await;
let agent_result = agent
.prompt(LlmMessage::user(user_message.text.clone()))
.await;
let assistant_now = now_rfc3339();
@@ -246,14 +287,63 @@ pub async fn editor_agent_message(
fn validate_editor_agent_message_request(
payload: &EditorAgentMessageRequest,
) -> Result<(), AppError> {
) -> Result<String, AppError> {
let client_message_id = normalize_required_string(payload.client_message_id.as_str())
.ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?;
if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS {
return Err(editor_agent_bad_request(format!(
"clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters"
)));
}
let attachment_reference_ids = payload
.attachments
.iter()
.map(|attachment| attachment.reference_id.clone())
.collect::<Vec<_>>();
validate_user_message(payload.text.as_str(), attachment_reference_ids.as_slice())
.map_err(|error| editor_agent_bad_request(error.to_string()))
.map_err(|error| editor_agent_bad_request(error.to_string()))?;
Ok(client_message_id)
}
fn find_idempotent_editor_agent_user_message(
document: &EditorAgentConversationMessagesDocument,
client_message_id: &str,
text: &str,
attachments: &[shared_contracts::editor_agent::EditorAgentAttachmentRef],
) -> Result<Option<usize>, AppError> {
let Some((index, message)) = document
.messages
.iter()
.enumerate()
.find(|(_, message)| message.client_message_id.as_deref() == Some(client_message_id))
else {
return Ok(None);
};
if message.role != EditorAgentMessageRole::User
|| message.text != text
|| !editor_agent_attachment_requests_match(&message.attachments, attachments)
{
return Err(
AppError::from_status(axum::http::StatusCode::CONFLICT).with_details(json!({
"provider": "editor-agent",
"field": "clientMessageId",
"message": "clientMessageId already exists with different message content",
})),
);
}
Ok(Some(index))
}
fn editor_agent_attachment_requests_match(
stored: &[shared_contracts::editor_agent::EditorAgentAttachmentRef],
submitted: &[shared_contracts::editor_agent::EditorAgentAttachmentRef],
) -> bool {
stored.len() == submitted.len()
&& stored.iter().zip(submitted).all(|(left, right)| {
left.source == right.source && left.reference_id == right.reference_id
})
}
#[cfg(test)]
@@ -277,12 +367,14 @@ mod tests {
#[test]
fn validates_editor_agent_message_before_normalizing_attachments() {
let empty_payload = EditorAgentMessageRequest {
client_message_id: "client-message-empty".to_string(),
text: " ".to_string(),
attachments: Vec::new(),
};
assert!(validate_editor_agent_message_request(&empty_payload).is_err());
let too_many_payload = EditorAgentMessageRequest {
client_message_id: "client-message-many".to_string(),
text: "生成一张图".to_string(),
attachments: (0..10)
.map(|index| attachment(format!("res-{index}")))
@@ -291,10 +383,73 @@ mod tests {
assert!(validate_editor_agent_message_request(&too_many_payload).is_err());
let attachment_only_payload = EditorAgentMessageRequest {
client_message_id: "client-message-attachment".to_string(),
text: String::new(),
attachments: vec![attachment("res-1")],
};
assert!(validate_editor_agent_message_request(&attachment_only_payload).is_ok());
let missing_client_message_id = EditorAgentMessageRequest {
client_message_id: " ".to_string(),
text: "生成一张图".to_string(),
attachments: Vec::new(),
};
assert!(validate_editor_agent_message_request(&missing_client_message_id).is_err());
let oversized_client_message_id = EditorAgentMessageRequest {
client_message_id: "x".repeat(EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS + 1),
text: "生成一张图".to_string(),
attachments: Vec::new(),
};
assert!(validate_editor_agent_message_request(&oversized_client_message_id).is_err());
}
#[test]
fn detects_idempotent_message_replays_and_content_conflicts() {
let stored_attachment = attachment("res-1");
let document = EditorAgentConversationMessagesDocument {
version: 2,
conversation_id: "conversation-1".to_string(),
messages: vec![EditorAgentMessage {
id: 0,
client_message_id: Some("client-message-1".to_string()),
role: EditorAgentMessageRole::User,
text: "生成一张图".to_string(),
attachments: vec![stored_attachment.clone()],
tool_call: None,
created_at: "2026-07-16T00:00:00Z".to_string(),
}],
};
assert_eq!(
find_idempotent_editor_agent_user_message(
&document,
"client-message-1",
"生成一张图",
&[stored_attachment.clone()],
)
.expect("same request should be an idempotent replay"),
Some(0),
);
assert!(
find_idempotent_editor_agent_user_message(
&document,
"client-message-1",
"生成另一张图",
&[stored_attachment],
)
.is_err()
);
assert_eq!(
find_idempotent_editor_agent_user_message(
&document,
"client-message-2",
"生成一张图",
&[],
)
.expect("new request should not match"),
None,
);
}
}
fn editor_agent_system_prompt() -> &'static str {
@@ -321,6 +476,7 @@ fn build_delta_messages(
PromptOutput::Text(text) => {
messages.push(EditorAgentMessage {
id: absolute_idx,
client_message_id: None,
role: EditorAgentMessageRole::Assistant,
text,
attachments: Vec::new(),
@@ -338,6 +494,7 @@ fn build_delta_messages(
)?;
messages.push(EditorAgentMessage {
id: absolute_idx,
client_message_id: None,
role: EditorAgentMessageRole::System,
text: tco.message,
attachments: Vec::new(),
@@ -170,22 +170,37 @@ pub struct EditorAgentToolCall {
#[serde(default)]
pub error: Option<String>,
}
fn deserialize_id_or_zero<'de, D>(deserializer: D) -> Result<usize, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[derive(Clone, Debug, Deserialize)]
#[serde(untagged)]
enum RawEditorAgentMessageId {
Number(usize),
String(String),
#[allow(dead_code)]
#[serde(untagged)]
enum IdOrZero {
Num(usize),
Str(String),
Other(serde_json::Value),
Other(serde_json::Value),
}
impl Default for RawEditorAgentMessageId {
fn default() -> Self {
Self::Number(0)
}
}
impl RawEditorAgentMessageId {
fn numeric_id(&self) -> usize {
match self {
Self::Number(id) => *id,
Self::String(_) | Self::Other(_) => 0,
}
}
match IdOrZero::deserialize(deserializer)? {
IdOrZero::Num(n) => Ok(n),
_ => Ok(0),
fn legacy_client_message_id(&self, role: EditorAgentMessageRole) -> Option<String> {
if role != EditorAgentMessageRole::User {
return None;
}
match self {
Self::String(id) if !id.trim().is_empty() => Some(id.trim().to_string()),
Self::Number(_) | Self::String(_) | Self::Other(_) => None,
}
}
}
@@ -194,6 +209,8 @@ where
pub struct EditorAgentMessage {
// to compatible for legacy version
pub id: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_message_id: Option<String>,
pub role: EditorAgentMessageRole,
pub text: String,
#[serde(default)]
@@ -276,8 +293,10 @@ struct RawEditorAgentConversationMessagesDocument {
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawEditorAgentMessage {
#[serde(default, deserialize_with = "deserialize_id_or_zero")]
id: usize,
#[serde(default)]
id: RawEditorAgentMessageId,
#[serde(default)]
client_message_id: Option<String>,
role: EditorAgentMessageRole,
#[serde(default)]
text: String,
@@ -293,11 +312,16 @@ struct RawEditorAgentMessage {
impl RawEditorAgentMessage {
fn into_single_message(self) -> EditorAgentMessage {
let id = self.id.numeric_id();
let client_message_id = self
.client_message_id
.or_else(|| self.id.legacy_client_message_id(self.role));
let tool_call = self
.tool_call
.or_else(|| legacy_generations_to_tool_call(self.generations));
EditorAgentMessage {
id: self.id,
id,
client_message_id,
role: self.role,
text: self.text,
attachments: self.attachments,
@@ -311,13 +335,19 @@ impl RawEditorAgentMessage {
return vec![self.into_single_message()];
}
let id = self.id.numeric_id();
let client_message_id = self
.client_message_id
.clone()
.or_else(|| self.id.legacy_client_message_id(self.role));
let mut messages = Vec::new();
if self.role != EditorAgentMessageRole::Assistant
|| !self.text.trim().is_empty()
|| !self.attachments.is_empty()
{
messages.push(EditorAgentMessage {
id: self.id,
id,
client_message_id,
role: self.role,
text: self.text.clone(),
attachments: self.attachments,
@@ -330,7 +360,8 @@ impl RawEditorAgentMessage {
let tool_call_message =
legacy_generation_to_tool_call_message(generation, self.created_at.as_str());
EditorAgentMessage {
id: self.id,
id,
client_message_id: None,
role: EditorAgentMessageRole::System,
text: tool_call_message.text,
attachments: Vec::new(),
@@ -477,6 +508,7 @@ pub struct EditorAgentConversationResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct EditorAgentMessageRequest {
pub client_message_id: String,
pub text: String,
#[serde(default)]
pub attachments: Vec<EditorAgentAttachmentRef>,
@@ -495,6 +527,37 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn message_request_requires_client_message_id_and_user_message_preserves_it() {
let missing_client_message_id =
serde_json::from_value::<EditorAgentMessageRequest>(json!({
"text": "生成一张图",
"attachments": []
}));
assert!(missing_client_message_id.is_err());
let request = serde_json::from_value::<EditorAgentMessageRequest>(json!({
"clientMessageId": "client-message-1",
"text": "生成一张图",
"attachments": []
}))
.expect("clientMessageId should deserialize");
assert_eq!(request.client_message_id, "client-message-1");
let message = EditorAgentMessage {
id: 0,
client_message_id: Some(request.client_message_id),
role: EditorAgentMessageRole::User,
text: request.text,
attachments: request.attachments,
tool_call: None,
created_at: "2026-07-16T00:00:00Z".to_string(),
};
let payload = serde_json::to_value(message).expect("message should serialize");
assert_eq!(payload["id"], 0);
assert_eq!(payload["clientMessageId"], "client-message-1");
}
#[test]
fn tool_call_display_args_and_media_use_camel_case() {
let missing_display_args = serde_json::from_value::<EditorAgentToolCall>(json!({
@@ -642,7 +705,12 @@ mod tests {
assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION);
assert_eq!(document.messages.len(), 3);
assert_eq!(document.messages[0].id, 0);
assert_eq!(
document.messages[0].client_message_id.as_deref(),
Some("message-user-1")
);
assert_eq!(document.messages[1].id, 1);
assert!(document.messages[1].client_message_id.is_none());
assert_eq!(document.messages[1].role, EditorAgentMessageRole::Assistant);
assert_eq!(document.messages[1].text, "已为你生成森林背景。");
assert!(document.messages[1].tool_call.is_none());
@@ -139,6 +139,7 @@ describe('useEditorAgentConversation', () => {
expect(client.sendMessage).toHaveBeenCalledWith(
'conversation-1',
expect.objectContaining({
clientMessageId: expect.stringMatching(/^editor-agent-/u),
text: '把这个角色改成像素风',
attachments: [],
}),
@@ -155,6 +156,9 @@ describe('useEditorAgentConversation', () => {
'把这个角色改成像素风',
'我来处理',
]);
expect(result.current.messages[0]?.clientMessageId).toMatch(
/^editor-agent-/u,
);
expect(result.current.messages[1]?.toolCall).toEqual(
expect.objectContaining({
toolName: 'generate_image',
@@ -83,13 +83,23 @@ function isAbortError(error: unknown) {
);
}
function createEditorAgentClientMessageId() {
const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return `editor-agent-${randomId}`;
}
function createLocalUserMessage(params: {
id: number;
clientMessageId: string;
text: string;
attachments: EditorAgentAttachmentRef[];
}): EditorAgentMessage {
return {
id: params.id,
clientMessageId: params.clientMessageId,
role: 'user',
text: params.text,
attachments: params.attachments,
@@ -359,12 +369,14 @@ export function useEditorAgentConversation({
return;
}
const conversationId = await ensureConversationForSend();
const clientMessageId = createEditorAgentClientMessageId();
const abortController = new AbortController();
activeRequestAbortControllerRef.current = abortController;
setErrorMessage(null);
setIsWaiting(true);
const optimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
@@ -377,6 +389,7 @@ export function useEditorAgentConversation({
const response = await client.sendMessage(
conversationId,
{
clientMessageId,
text,
attachments,
},
+54
View File
@@ -599,6 +599,60 @@ describe('apiClient', () => {
expect(result).toEqual({ value: 42 });
});
it('reuses the exact post body and request id for an enabled unsafe transport retry', async () => {
setStoredAccessToken('editor-agent-token', { emit: false });
fetchMock
.mockRejectedValueOnce(new TypeError('network unavailable'))
.mockResolvedValueOnce(
createResponseMock({
status: 200,
body: JSON.stringify({
ok: true,
data: { deltaMessages: [] },
error: null,
meta: { apiVersion: '2026-06-16' },
}),
headers: { 'Content-Type': 'application/json' },
}),
);
const body = JSON.stringify({
clientMessageId: 'client-message-1',
text: '生成一张图',
attachments: [],
});
await requestJson(
'/api/editor/agent-conversations/conversation-1/messages',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
},
'发送画布 Agent 消息失败',
{
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 1,
maxDelayMs: 1,
retryUnsafeMethods: true,
},
},
);
expect(fetchMock).toHaveBeenCalledTimes(2);
const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit;
const secondInit = fetchMock.mock.calls[1]?.[1] as RequestInit;
expect(firstInit.body).toBe(body);
expect(secondInit.body).toBe(body);
expect((firstInit.headers as Record<string, string>)['x-request-id']).toBe(
'web-11111111-2222-3333-4444-555555555555',
);
expect((secondInit.headers as Record<string, string>)['x-request-id']).toBe(
(firstInit.headers as Record<string, string>)['x-request-id'],
);
});
it('aborts requests when timeoutMs is reached', async () => {
setStoredAccessToken('timeout-token', { emit: false });
fetchMock.mockImplementation(
@@ -123,6 +123,7 @@ describe('editorAgentClient', () => {
requestJsonMock.mockResolvedValueOnce(responseBody);
const result = await sendEditorAgentMessage('conversation-1', {
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
});
@@ -134,6 +135,7 @@ describe('editorAgentClient', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientMessageId: 'client-message-1',
text: '帮我把角色改成像素风',
attachments: [],
}),
@@ -142,6 +144,12 @@ describe('editorAgentClient', () => {
expect.objectContaining({
timeoutMs: 1_200_000,
authImpact: 'local',
retry: {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
},
}),
);
});
@@ -12,6 +12,12 @@ import { requestJson } from '../apiClient';
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
const EDITOR_AGENT_MESSAGE_RETRY = {
maxRetries: 1,
baseDelayMs: 250,
maxDelayMs: 250,
retryUnsafeMethods: true,
} as const;
export type SendEditorAgentMessageOptions = {
signal?: AbortSignal;
@@ -116,6 +122,7 @@ export async function sendEditorAgentMessage(
{
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
authImpact: 'local',
retry: EDITOR_AGENT_MESSAGE_RETRY,
},
);
}