From 29d0cbb4dfa87ffd14d1d0884840bb9eba7ea5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:01:50 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=88=87=E7=89=87=E5=85=B1=E7=94=A8?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目 - item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照 - 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断 - 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/codex_app_server/mod.rs | 58 +- .../src-tauri/src/agent/direct_chat_entry.rs | 523 ++++++++++++++++++ .../src/agent/direct_thread_manager.rs | 6 + .../src-tauri/src/commands.rs | 8 + 5 files changed, 585 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e64b321dd..bf380a888 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,7 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; +mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -48,6 +49,7 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; +pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index fcfa250be..27f81fc6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -744,13 +744,17 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "itemType": item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - }) +/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 +fn direct_chat_entry_payload( + root: &std::path::Path, + item: &serde_json::Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> serde_json::Value { + direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) + .and_then(|entry| serde_json::to_value(entry).ok()) + .unwrap_or_else(|| serde_json::json!({})) } fn direct_thread_item_id(item: &serde_json::Value) -> Option { @@ -3053,6 +3057,13 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } + let entry_payload = direct_chat_entry_payload( + history_root, + &item, + Some(turn_id.as_str()), + true, + direct_tool_call_now_ms(), + ); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3074,7 +3085,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: direct_thread_item_call_id(&item), - payload: serde_json::json!({}), + payload: entry_payload, }, ); } @@ -3216,7 +3227,13 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_thread_item_started_payload(item), + payload: direct_chat_entry_payload( + history_root, + item, + Some(turn_id.as_str()), + completed, + direct_tool_call_now_ms(), + ), }, ); } @@ -4527,10 +4544,27 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - assert_eq!( - direct_thread_item_started_payload(&item), - serde_json::json!({ "itemType": "mcpToolCall" }) + // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 + let payload = direct_chat_entry_payload( + std::path::Path::new("."), + &item, + Some("turn-1"), + false, + 1000, ); + assert_eq!( + payload.get("kind").and_then(serde_json::Value::as_str), + Some("tool") + ); + assert_eq!( + payload.get("itemId").and_then(serde_json::Value::as_str), + Some("item-1") + ); + assert_eq!( + payload.get("turnId").and_then(serde_json::Value::as_str), + Some("turn-1") + ); + assert!(payload.get("toolCall").is_some(), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs new file mode 100644 index 000000000..a348cddbd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs @@ -0,0 +1,523 @@ +//! DirectProject 聊天条目投影。 +//! +//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 +//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 +//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 +//! +//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 +//! 排列属于前端聊天投影。 + +use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; +use crate::DirectToolCall; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; + +/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 +const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; +/// 工具输入/输出上限:与卡片明细同口径。 +const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; + +/// 前端唯一消费的聊天条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectChatEntry { + /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 + pub(crate) item_id: String, + /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// `message` | `reasoning` | `tool` + pub(crate) kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_call: Option, + /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 + pub(crate) at: u64, +} + +impl DirectChatEntry { + /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 + pub(crate) fn identity(&self) -> &str { + self.call_id.as_deref().unwrap_or(&self.item_id) + } +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn item_text(item: &Value) -> Option { + if let Some(text) = item.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value) -> u64 { + item.get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default() +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn raw_tool_kind(name: &str) -> &'static str { + match name { + "exec_command" | "shell" | "exec" => "command", + "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", + "web_search" | "web_search_preview" => "web_search", + _ => "mcp_tool", + } +} + +fn raw_tool_title(kind: &str) -> &'static str { + match kind { + "command" => "执行命令", + "file_change" => "编辑文件", + "web_search" => "联网检索", + "mcp_tool" => "调用工具", + _ => "工具调用", + } +} + +fn stringified(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn first_line(value: &str, max_chars: usize) -> String { + bounded(value.lines().next().unwrap_or_default().trim(), max_chars) +} + +/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 +/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 +fn raw_tool_entry( + root: &Path, + item: &Value, + item_id: String, + call_id: Option, + turn_id: Option, + at: u64, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let call_id = call_id?; + let (kind, title, summary, detail) = match item_type { + "function_call" => { + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let kind = raw_tool_kind(name); + let command = bounded( + &sanitize_detail_text(root, arguments), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ); + let summary = first_line(&command, 120); + ( + kind, + raw_tool_title(kind).to_string(), + summary, + crate::DirectToolCallDetail { + command: Some(command), + output: None, + changes: Vec::new(), + }, + ) + } + "function_call_output" => { + let output = item.get("output").and_then(stringified).map(|output| { + bounded( + &sanitize_detail_text(root, &output), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ) + }); + ( + "other", + String::new(), + String::new(), + crate::DirectToolCallDetail { + command: None, + output, + changes: Vec::new(), + }, + ) + } + _ => return None, + }; + let status = if completed { "completed" } else { "running" }; + let started_at = if at > 0 { at } else { now_ms }; + Some(DirectChatEntry { + item_id, + call_id: Some(call_id.clone()), + turn_id: turn_id.clone(), + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(DirectToolCall { + schema_version: "agc-tool-call.v1".to_string(), + id: call_id, + turn_id: turn_id.unwrap_or_default(), + kind: kind.to_string(), + title, + summary, + status: status.to_string(), + detail, + started_at, + updated_at: if at > 0 { at } else { now_ms }, + }), + at, + }) +} + +/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 +pub(crate) fn direct_chat_entry_from_item( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> Option { + let (item_id, call_id) = item_identity(item)?; + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item); + + if let Some(entry) = raw_tool_entry( + root, + item, + item_id.clone(), + call_id.clone(), + turn_id.clone(), + at, + completed, + now_ms, + ) { + return Some(entry); + } + + if let Some(tool_call) = direct_tool_call_from_item( + root, + item, + turn_id.as_deref().unwrap_or_default(), + completed, + now_ms, + ) { + let call_id = call_id.or_else(|| Some(tool_call.id.clone())); + return Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(tool_call), + at, + }); + } + + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + let (kind, role) = if item_type == "reasoning" { + (DIRECT_CHAT_ENTRY_KIND_REASONING, None) + } else { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) + }; + let text = item_text(item)?; + let text = bounded( + &sanitize_detail_text(root, &text), + DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, + ); + Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: kind.to_string(), + role, + text: Some(text), + tool_call: None, + at, + }) +} + +/// 历史切片投影:文件顺序就是条目顺序。 +/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 +pub(crate) fn direct_chat_entries_from_history( + root: &Path, + items: &[Value], +) -> Vec { + let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); + let mut entries: Vec = Vec::new(); + for item in items { + let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { + continue; + }; + match entries + .iter_mut() + .find(|existing| existing.identity() == entry.identity()) + { + Some(existing) => merge_chat_entry(existing, entry), + None => entries.push(entry), + } + } + entries +} + +/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 +fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { + if let (Some(existing_call), Some(incoming_call)) = + (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) + { + existing_call.detail.command = existing_call + .detail + .command + .take() + .or_else(|| incoming_call.detail.command.clone()); + existing_call.detail.output = existing_call + .detail + .output + .take() + .or_else(|| incoming_call.detail.output.clone()); + if existing_call.detail.changes.is_empty() { + existing_call.detail.changes = incoming_call.detail.changes.clone(); + } + if existing_call.title.trim().is_empty() { + existing_call.title = incoming_call.title.clone(); + } + if existing_call.summary.trim().is_empty() { + existing_call.summary = incoming_call.summary.clone(); + } + if existing_call.kind == "other" && incoming_call.kind != "other" { + existing_call.kind = incoming_call.kind.clone(); + } + existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); + existing_call.status = incoming_call.status.clone(); + } + if existing.text.is_none() { + existing.text = incoming.text; + } + if existing.role.is_none() { + existing.role = incoming.role; + } + if existing.turn_id.is_none() { + existing.turn_id = incoming.turn_id; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_projects_role_and_text() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); + assert_eq!(entry.role.as_deref(), Some("user")); + assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_carries_call_id_and_card() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); + assert_eq!( + entry.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); + assert!(entry.tool_call.is_some()); + } + + #[test] + fn reasoning_item_projects_text_without_role() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); + assert_eq!(entry.role, None); + assert_eq!(entry.text.as_deref(), Some("先看目录")); + } + + #[test] + fn non_chat_items_and_secrets_are_not_leaked() { + assert!(direct_chat_entry_from_item( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .is_none()); + + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant entry"); + let text = entry.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + } + + #[test] + fn history_merges_call_input_and_output_into_one_card() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let entries = direct_chat_entries_from_history(root(), &items); + assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); + let card = entries[0].tool_call.as_ref().expect("card"); + assert_eq!(card.kind, "command"); + assert_eq!(card.title, "执行命令"); + assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index f72f37380..44a2b67ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -10,6 +10,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; +use crate::agent::DirectChatEntry; + const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -71,6 +73,10 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 + pub(crate) entries: Vec, + /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 + pub(crate) first_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index ca97077f1..d941f1c14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,10 +5383,18 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; + let entries = direct_chat_entries_from_history(root, &items); + let first_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + entries, + first_item_id, }) }) .await