修复策划 Agent 历史思考位置
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m8s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust tests (pull_request) Has been cancelled

按用户回合与响应顺序绑定持久化 reasoning,跨越 tool-only 响应。

合并同一正文对应的多段 reasoning,避免无归属内容堆积在消息列表底部。

补充策划 Runtime 顺序回归测试并更新里程碑文档。
This commit is contained in:
2026-09-14 17:28:00 +08:00
parent 0a34f4e514
commit 45d7dc2a63
7 changed files with 312 additions and 8 deletions
@@ -49,6 +49,18 @@ pub(crate) struct DesignView {
messages: Vec<DesignMessage>,
running: bool,
can_retry: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_text: Option<String>,
reasoning_entries: Vec<DesignReasoningEntry>,
}
#[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<String>,
}
#[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<String> {
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<DesignReasoningEntry> {
// 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<String>> = 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<_>>(),
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();
+14
View File
@@ -1012,6 +1012,15 @@ export function App({
}
function designMessagesToChat(view: DesignView): ChatMessage[] {
const reasoningByMessageId = new Map<string, string[]>();
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={
@@ -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 {
@@ -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 ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{message.reasoningText}</pre>
</details>
) : null}
</div>
))}
{designReasoningEntries
.filter((entry) => !entry.messageId)
.map((entry) => (
<details
key={`reasoning-${entry.id}`}
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<pre>{entry.text}</pre>
</details>
))}
{designReasoning ? (
<details
className="design-agent-reasoning"
aria-label="策划 Agent 思考过程"
>
<summary></summary>
<summary></summary>
<pre>{designReasoning}</pre>
</details>
) : null}
@@ -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);
});
}
@@ -17,6 +17,7 @@
- 已完成共享 reasoning 字段、Provider 解析、策划事件映射以及正文流式收尾的前两轮提交。
- 当前第三轮聚焦策划 Agent 前端 reasoning 生命周期:按 `projectPath + clientTurnId` 绑定事件,回合结束后保留本轮 reasoning,下一轮或项目切换时清理。
- 现有 `<details>` 展示保持默认收起,并提供明确的展开/收起入口;不新增持久化字段,也不改动 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 不进入普通上下文的约束保持不变
+24 -4
View File
@@ -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<u8>), LlmError> {
@@ -3915,7 +3921,7 @@ fn parse_responses_sse_event(data: &str) -> Result<Option<ParsedStreamEvent>, 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<String> {
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(