修复策划 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
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:
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+29
-2
@@ -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 不进入普通上下文的约束保持不变。
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user