From 45d7dc2a638cf970f8b8760dd836591def38b40d Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 14 Sep 2026 17:28:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AD=96=E5=88=92=20Agent=20?= =?UTF-8?q?=E5=8E=86=E5=8F=B2=E6=80=9D=E8=80=83=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户回合与响应顺序绑定持久化 reasoning,跨越 tool-only 响应。 合并同一正文对应的多段 reasoning,避免无归属内容堆积在消息列表底部。 补充策划 Runtime 顺序回归测试并更新里程碑文档。 --- .../src-tauri/src/agent/design_runtime.rs | 169 ++++++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 14 ++ apps/ai-game-creator-shell/src/app/types.ts | 9 + .../ProjectSupervisorView.tsx | 31 +++- .../tests/appSurface/design-agent.suite.ts | 53 +++++- ...ider推理与正文分离及策划Agent展示-2026-09-14.md | 16 +- server-rs/crates/platform-llm/src/lib.rs | 28 ++- 7 files changed, 312 insertions(+), 8 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index c7bdbec88..1dfe8d837 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -49,6 +49,18 @@ pub(crate) struct DesignView { messages: Vec, running: bool, can_retry: bool, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, + reasoning_entries: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DesignReasoningEntry { + id: String, + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + message_id: Option, } #[derive(Clone, Debug, Serialize)] @@ -65,6 +77,7 @@ pub(crate) struct DesignEvent { } fn design_view(session: &DesignSession, running: bool) -> DesignView { + let reasoning_entries = persisted_design_reasoning_entries(session); DesignView { session: DesignSessionSummary { session_id: session.session_id.clone(), @@ -82,9 +95,124 @@ fn design_view(session: &DesignSession, running: bool) -> DesignView { && session.turn.as_ref().is_some_and(|turn| turn.pending) && session.pending_approval.is_none() && session.pending_clarification.is_none(), + reasoning_text: reasoning_entries.last().map(|entry| entry.text.clone()), + reasoning_entries, } } +fn reasoning_text_from_history_item(item: &Value) -> Option { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + return None; + } + let mut text = String::new(); + if let Some(summary) = item.get("summary").and_then(Value::as_array) { + for part in summary { + if let Some(value) = part.get("text").and_then(Value::as_str) { + text.push_str(value.trim()); + } + } + } + if let Some(content) = item.get("content").and_then(Value::as_array) { + for part in content { + let part_type = part.get("type").and_then(Value::as_str).unwrap_or_default(); + if matches!( + part_type, + "reasoning" | "reasoning_content" | "reasoning_text" | "analysis" | "thinking" + ) { + if let Some(value) = part.get("text").and_then(Value::as_str) { + text.push_str(value.trim()); + } + } + } + } + (!text.trim().is_empty()).then_some(text) +} + +fn persisted_design_reasoning_entries(session: &DesignSession) -> Vec { + // Responses history contains tool-only provider responses. Their reasoning is + // followed by function calls and only the next provider response may contain + // visible assistant text, so pairing on the next `message` item makes the + // earlier reasoning look like an orphan and moves it to the bottom of the UI. + // Both persisted streams retain user-turn boundaries; pair reasoning and + // visible assistant messages by their response order within each turn. + let mut assistant_groups: Vec> = vec![Vec::new()]; + for message in &session.messages { + if message.role == "user" { + assistant_groups.push(Vec::new()); + } else if message.role == "assistant" { + assistant_groups + .last_mut() + .expect("assistant group always exists") + .push(message.id.clone()); + } + } + let mut entries = Vec::new(); + let mut group_index = 0; + let mut assistant_index = 0; + let mut sequence = 0_u64; + let mut current_reasoning = Vec::new(); + let mut pending_reasoning = Vec::new(); + let mut saw_response_output = false; + + for item in &session.history { + if item.get("role").and_then(Value::as_str) == Some("user") { + if !pending_reasoning.is_empty() || !current_reasoning.is_empty() { + pending_reasoning.append(&mut current_reasoning); + } + group_index += 1; + assistant_index = 0; + saw_response_output = false; + continue; + } + if item.get("type").and_then(Value::as_str) == Some("reasoning") { + if saw_response_output { + pending_reasoning.append(&mut current_reasoning); + saw_response_output = false; + } + if let Some(text) = reasoning_text_from_history_item(item) { + sequence += 1; + current_reasoning.push(DesignReasoningEntry { + id: item + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("reasoning-{sequence}")), + text, + message_id: None, + }); + } + continue; + } + if item.get("role").and_then(Value::as_str) == Some("assistant") + || item.get("type").and_then(Value::as_str) == Some("message") + { + pending_reasoning.extend(current_reasoning.drain(..)); + let assistant_id = assistant_groups + .get(group_index) + .and_then(|ids| ids.get(assistant_index)) + .cloned(); + assistant_index += 1; + for mut entry in pending_reasoning.drain(..) { + entry.message_id = assistant_id.clone(); + entries.push(entry); + } + saw_response_output = false; + } else if item.get("type").is_some() { + saw_response_output = true; + } + } + pending_reasoning.append(&mut current_reasoning); + let fallback_id = assistant_groups + .get(group_index) + .and_then(|ids| ids.last()) + .cloned(); + for mut entry in pending_reasoning { + entry.message_id = fallback_id.clone(); + entries.push(entry); + } + entries +} + fn design_event( root: &Path, turn_id: &str, @@ -1428,6 +1556,47 @@ mod tests { assert!(request.capture_reasoning); } + #[test] + fn persisted_reasoning_follows_response_order_across_tool_only_responses() { + let mut session = new_design_session("project", "quality"); + session.messages = vec![ + DesignMessage { + id: "turn:user".into(), + role: "user".into(), + text: "需求".into(), + }, + DesignMessage { + id: "call-1:tool".into(), + role: "tool".into(), + text: "读取资源".into(), + }, + DesignMessage { + id: "turn:response:0".into(), + role: "assistant".into(), + text: "给出方案".into(), + }, + ]; + session.history = vec![ + json!({"role":"user", "content":"需求"}), + json!({"type":"reasoning", "id":"r1", "content":[{"type":"reasoning_text", "text":"第一段思考"}]}), + json!({"type":"function_call", "call_id":"call-1", "name":"read_resource", "arguments":"{}"}), + json!({"type":"reasoning", "id":"r2", "content":[{"type":"reasoning_text", "text":"第二段思考"}]}), + json!({"type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"给出方案"}]}), + ]; + + let entries = persisted_design_reasoning_entries(&session); + assert_eq!( + entries + .iter() + .map(|entry| (entry.id.as_str(), entry.message_id.as_deref())) + .collect::>(), + vec![ + ("r1", Some("turn:response:0")), + ("r2", Some("turn:response:0")), + ] + ); + } + #[tokio::test(flavor = "current_thread")] async fn scripted_design_provider_emits_reasoning_without_persisting_it() { let (_temp, root, _resources) = init_design_project(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 7600fd677..d5a6c705e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1012,6 +1012,15 @@ export function App({ } function designMessagesToChat(view: DesignView): ChatMessage[] { + const reasoningByMessageId = new Map(); + for (const entry of view.reasoningEntries ?? []) { + if (!entry.messageId) { + continue; + } + const texts = reasoningByMessageId.get(entry.messageId) ?? []; + texts.push(entry.text); + reasoningByMessageId.set(entry.messageId, texts); + } return view.messages .filter((message) => message.text.trim()) .map((message) => ({ @@ -1019,6 +1028,7 @@ export function App({ text: message.text, runtimeOwned: true, messageId: message.id, + reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); } @@ -1041,6 +1051,7 @@ export function App({ const pendingTurnId = designAgentPendingViewRef.current?.clientTurnId; designAgentPendingViewRef.current = null; applyDesignView(view, projectPath); + setPlanningV2Reasoning(''); setPlanningV2TransientReplyTarget(''); if (designAgentTurnRef.current?.clientTurnId === pendingTurnId) { designAgentTurnRef.current = null; @@ -11928,6 +11939,9 @@ export function App({ : projectSupervisorTransientReply } designReasoning={planningV2Reasoning} + designReasoningEntries={ + useDesignAgentSurface ? (designAgentView?.reasoningEntries ?? []) : [] + } visibleMessages={visibleMessages} visibleProfessionalAgentCards={visibleProfessionalAgentCards} showProfessionalCollaboration={ diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index a89c1698a..64a79bfab 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -993,6 +993,7 @@ export interface ChatMessage { draftCommand?: string; draftCommandLabel?: string; messageId?: string | null; + reasoningText?: string; agentId?: string | null; updatedAt?: number; runtimeOwned?: boolean; @@ -1038,11 +1039,19 @@ export interface DesignAgentMessage { text: string; } +export interface DesignReasoningEntry { + id: string; + text: string; + messageId?: string | null; +} + export interface DesignView { session: DesignSessionSummary; messages: DesignAgentMessage[]; running: boolean; canRetry: boolean; + reasoningText?: string | null; + reasoningEntries?: DesignReasoningEntry[]; } export interface DesignEvent { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index f111ac332..36d6785c8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -16,7 +16,11 @@ import type { PlanGddDecisionAction, PlanGddStateViewV1, } from '../../app/types'; -import type { DesignClarificationRequest, DesignView } from '../../app/types'; +import type { + DesignClarificationRequest, + DesignReasoningEntry, + DesignView, +} from '../../app/types'; import { ChatMarkdownMessage } from '../../components/ChatMarkdownMessage'; import { projectProfessionalAgentLabel, @@ -97,6 +101,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { showProfessionalCollaboration?: boolean; transientReply: string; designReasoning?: string; + designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; visibleProfessionalAgentCards: AgentStatusCard[]; workspaceStatus: string; @@ -149,6 +154,7 @@ export function ProjectSupervisorView({ showProfessionalCollaboration = true, transientReply, designReasoning = '', + designReasoningEntries = [], visibleMessages, visibleProfessionalAgentCards, workspaceStatus, @@ -260,14 +266,35 @@ export function ProjectSupervisorView({ role={message.role} text={projectSupervisorChatMessageText(message)} /> + {message.reasoningText ? ( +
+ 思考过程 +
{message.reasoningText}
+
+ ) : null} ))} + {designReasoningEntries + .filter((entry) => !entry.messageId) + .map((entry) => ( +
+ 思考过程 +
{entry.text}
+
+ ))} {designReasoning ? (
- 思考过程(点击展开) + 思考过程
{designReasoning}
) : null} diff --git a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts index 3160d8756..499ec2464 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/design-agent.suite.ts @@ -74,6 +74,28 @@ function designConversationView() { }; } +function designHistoryView() { + return { + ...designConversationView(), + messages: [ + { id: 'assistant-1', role: 'assistant', text: '第一轮正文' }, + { id: 'assistant-2', role: 'assistant', text: '第二轮正文' }, + ], + reasoningEntries: [ + { + id: 'reasoning-1', + messageId: 'assistant-1', + text: '第一轮思考', + }, + { + id: 'reasoning-2', + messageId: 'assistant-2', + text: '第二轮思考', + }, + ], + }; +} + export function registerDesignAgentSurfaceTests() { it('hydrates an existing design session and decides approval through design commands', async () => { const harness = createProjectSupervisorRuntimeHarness({ @@ -203,11 +225,40 @@ export function registerDesignAgentSurfaceTests() { reasoningText: '先分析需求,再组织方案。', }); - const summary = await screen.findByText('思考过程(点击展开)'); + const summary = await screen.findByText('思考过程'); const details = summary.closest('details') as HTMLDetailsElement; expect(details.open).toBe(false); fireEvent.click(summary); expect(details.open).toBe(true); expect(screen.getByText('先分析需求,再组织方案。')).not.toBeNull(); }); + + it('renders historical reasoning as independent collapsed sections', async () => { + const harness = createProjectSupervisorRuntimeHarness({ + designAgentView: designHistoryView(), + }); + window.__TAURI__ = { + core: { invoke: harness.invoke }, + event: { listen: harness.listen }, + }; + window.history.pushState({}, '', '/'); + render( + React.createElement(App, { + initialProjectPath: harness.projectPath, + orchestrationMode: 'single-supervisor', + planningStartMode: true, + projectSupervisorOnly: true, + }), + ); + + const summaries = await screen.findAllByText('思考过程'); + expect(summaries).toHaveLength(2); + const details = summaries.map( + (summary) => summary.closest('details') as HTMLDetailsElement, + ); + expect(details.every((element) => !element.open)).toBe(true); + fireEvent.click(summaries[0]); + expect(details[0].open).toBe(true); + expect(details[1].open).toBe(false); + }); } diff --git a/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md b/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md index dc122b85c..87be9f2c9 100644 --- a/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md +++ b/docs/project-memory/plans/【里程碑】Provider推理与正文分离及策划Agent展示-2026-09-14.md @@ -17,6 +17,7 @@ - 已完成共享 reasoning 字段、Provider 解析、策划事件映射以及正文流式收尾的前两轮提交。 - 当前第三轮聚焦策划 Agent 前端 reasoning 生命周期:按 `projectPath + clientTurnId` 绑定事件,回合结束后保留本轮 reasoning,下一轮或项目切换时清理。 - 现有 `
` 展示保持默认收起,并提供明确的展开/收起入口;不新增持久化字段,也不改动 GameAgent、Direct/Codex、supervisor 或已退役链路。 +- 已核对实际会话产物:Responses 原生 `history` 已持久化 `reasoning` / `reasoning_text`,当前修复补齐 `reasoning_text` 提取,并在策划会话恢复时回填最近一轮 reasoning。 ## 背景与现状 @@ -195,6 +196,19 @@ npm run check:encoding git diff --check ``` +## 第六轮定位:历史 reasoning 位置修复 + +持久化 Responses history 中,reasoning 不一定紧邻可见 `message`:一次 Provider 响应可能先产生 reasoning 和多个 `function_call`,下一次响应才产生正文。原实现遇到下一段 reasoning 就提前结束上一段,无法绑定到 `session.messages` 中的正确 assistant 响应,前端遂把无 `messageId` 的条目统一追加到列表底部。 + +修复方式是按每个用户回合分别收集: + +- `session.history` 中 reasoning 的响应顺序; +- `session.messages` 中 assistant 消息的响应顺序。 + +两者按顺序绑定,允许 reasoning 跨越 tool-only Responses;同一正文对应多段 reasoning 时前端合并显示,仍使用默认折叠的单个思考区域。只有没有任何可见 assistant 消息的异常历史才保留为底部 orphan。 + +验证:新增 tool-only Responses 顺序回归测试,策划 Runtime 定向测试 11/11 通过;前端聚合逻辑保留历史多段 reasoning 的顺序。 + ## 当前状态与下一步 -当前仅完成问题定位和方案设计,未修改业务代码。进入实现前应先评审本里程碑的字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 是否进入 debug 记录;评审通过后再为单个里程碑建立对应的 `【实施计划】` 文档。 +当前已完成 Provider 解析、策划 Runtime 生命周期、历史 reasoning 恢复及响应顺序归属修复;待本轮提交后继续按定向回归结果推进后续验收。字段语义、默认关闭策略、Responses 事件覆盖范围和 reasoning 不进入普通上下文的约束保持不变。 diff --git a/server-rs/crates/platform-llm/src/lib.rs b/server-rs/crates/platform-llm/src/lib.rs index 8bfb685e8..8c8e246ef 100644 --- a/server-rs/crates/platform-llm/src/lib.rs +++ b/server-rs/crates/platform-llm/src/lib.rs @@ -3645,9 +3645,15 @@ fn is_hidden_reasoning_part(part_type: Option<&str>) -> bool { return false; }; - ["reasoning", "reasoning_content", "analysis", "thinking"] - .iter() - .any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type)) + [ + "reasoning", + "reasoning_content", + "reasoning_text", + "analysis", + "thinking", + ] + .iter() + .any(|hidden_type| part_type.eq_ignore_ascii_case(hidden_type)) } fn decode_utf8_stream_chunk(bytes: &[u8]) -> Result<(String, Vec), LlmError> { @@ -3915,7 +3921,7 @@ fn parse_responses_sse_event(data: &str) -> Result, Ll // 终态事件的 response 字段就是一个完整 Response 对象,直接反序列化后复用非流式的正文 // 提取:它已经处理了 output_text 优先、output[].content[] 回退,以及 reasoning / -// reasoning_content / analysis / thinking 这些隐藏 part 的过滤。另写裸 JSON 提取器必然 +// reasoning_content / reasoning_text / analysis / thinking 这些隐藏 part 的过滤。另写裸 JSON 提取器必然 // 漏掉过滤层,会把思维链当正文吐给调用方。 fn extract_responses_terminal_text(parsed: &serde_json::Value) -> Option { let response = parsed.get("response")?; @@ -5408,6 +5414,20 @@ mod tests { assert_eq!(response.reasoning, "先判断。再回答。"); } + #[test] + fn responses_response_captures_reasoning_text_content_from_persisted_output() { + let response = parse_responses_response_with_capture( + LlmProvider::OpenAiCompatible, + "fallback-model", + true, + r#"{"id":"resp_reasoning_text","output":[{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"先分析需求,再组织方案。"}],"encrypted_content":"opaque"},{"type":"message","content":[{"type":"output_text","text":"正文"}]}],"status":"completed"}"#, + ) + .expect("Responses reasoning_text should parse"); + + assert_eq!(response.text, "正文"); + assert_eq!(response.reasoning, "先分析需求,再组织方案。"); + } + #[test] fn responses_response_captures_reasoning_alongside_tool_call() { let response = parse_responses_response_with_capture(